Locality & Context Management
Locality middleware manages the current execution context - identifying which advisor, household, organization, or federation the request is operating within. This is critical for multi-tenant isolation and authorization.
Components
Section titled “Components”| Component | Location | Purpose |
|---|---|---|
| InitializeLocality | app/Http/Middleware/ | Extract locality from route |
| CheckLocalityConsistentWithLoginUser | app/Http/Middleware/ | Validate login user matches locality |
| LocalityManager | app/Locality/Services/ | Manage locality state (facade + service) |
| Locality | app/Locality/ | Abstract base class |
| AdvisorLocality | app/Locality/ | Advisor context |
| HouseholdLocality | app/Locality/ | Household context (extends ImpersonatableAdvisorLocality) |
| OrganizationLocality | app/Locality/ | Organization context |
| FederationLocality | app/Locality/ | Federation context |
| UserLocality | app/Locality/ | User context |
| ImpersonatableAdvisorLocality | app/Locality/ | Base for impersonatable contexts |
| AddXRightCapitalUserInfoHeader | app/Http/Middleware/ | Add user info to response |
| AddXRightCapitalCalculationId | app/Http/Middleware/ | Add calculation ID to response |
1. InitializeLocality Middleware
Section titled “1. InitializeLocality Middleware”File: app/Http/Middleware/InitializeLocality.php
Extracts locality information from route and initializes the context via LocalityManager.
Implementation
Section titled “Implementation”final class InitializeLocality{ public function handle(Request $request, Closure $next): Response { $route = $request->route();
if ($route !== null) { try { LocalityManager::initializeLocalityFromRoute($route); } catch (ModelNotFoundException $e) { throw new NotFoundHttpException( 'The ' . Str::snake(class_basename($e->getModel()), ' ') . ' cannot be found.', $e ); } }
return $next($request); }}Key Behavior
Section titled “Key Behavior”- Passes the entire
Routeobject toLocalityManager::initializeLocalityFromRoute() - Catches
ModelNotFoundExceptionand converts to 404 with user-friendly message - Uses
Str::snake(class_basename())to format model name in error
Error Response
Section titled “Error Response”{ "message": "The household cannot be found."}HTTP Status: 404 Not Found
2. LocalityManager
Section titled “2. LocalityManager”File: app/Locality/Services/LocalityManager.php (accessed via facade)
Central manager for locality state throughout the request lifecycle. Uses encrypted IDs from route parameters.
Facade
Section titled “Facade”final class LocalityManager extends Facade{ protected static function getFacadeAccessor(): string { return LocalityManagerService::class; }}Service Implementation
Section titled “Service Implementation”final class LocalityManager{ private ?Locality $locality = null; private bool $ignoreLicenseStatus = false;
public function initializeLocalityFromRoute(Route $route): void { // Priority order for locality detection $locality_map = [ 'household' => fn(int $id) => new HouseholdLocality(Household::findOrFail($id)), 'advisor' => fn(int $id) => new AdvisorLocality(Advisor::findOrFail($id)), 'user' => fn(int $id) => new UserLocality(User::findOrFail($id)), 'organization' => fn(int $id) => new OrganizationLocality(Organization::findOrFail($id)), 'federation' => fn(int $id) => new FederationLocality(Federation::findOrFail($id)), ];
foreach ($locality_map as $param => $factory) { $encrypted_id = $route->parameter($param);
if ($encrypted_id !== null) { // Decrypt the ID from route parameter $id = Crypt::decryptId($encrypted_id); $this->locality = $factory($id); return; } } }
public function locality(): ?Locality { return $this->locality; }
public function switchLocality(Locality $locality): void { $this->locality = $locality; }
public function switchHousehold(Household $household): void { $this->locality?->switchHousehold($household); }
public static function switchIgnoreLicenseStatus(bool $ignore, Closure $callback): mixed { // Temporarily switch license status check $previous = app(self::class)->ignoreLicenseStatus; app(self::class)->ignoreLicenseStatus = $ignore;
try { return $callback(); } finally { app(self::class)->ignoreLicenseStatus = $previous; } }}Key Features
Section titled “Key Features”- Encrypted IDs: Route parameters are encrypted, decrypted via
Crypt::decryptId() - Priority order: household → advisor → user → organization → federation
- Facade access: Use
LocalityManager::locality()etc. - License status switching:
switchIgnoreLicenseStatus()for temporary bypass
3. CheckLocalityConsistentWithLoginUser
Section titled “3. CheckLocalityConsistentWithLoginUser”File: app/Http/Middleware/CheckLocalityConsistentWithLoginUser.php
Validates that the login user has access to the current locality context.
Implementation
Section titled “Implementation”final class CheckLocalityConsistentWithLoginUser{ public function handle(Request $request, Closure $next): Response { $locality = LocalityManager::locality();
if ($locality === null) { return $next($request); }
$login_user = Auth::getUserOrFail();
// Different validation based on locality type if ($locality instanceof HouseholdLocality) { $this->validateHouseholdAccess($login_user, $locality); } elseif ($locality instanceof AdvisorLocality) { $this->validateAdvisorAccess($login_user, $locality); } elseif ($locality instanceof OrganizationLocality) { $this->validateOrganizationAccess($login_user, $locality); }
return $next($request); }
private function validateHouseholdAccess(User $user, HouseholdLocality $locality): void { // Validate user can access this household if ($user->type === UserType::CLIENT) { // Client must belong to this household if ($user->household_id !== $locality->getHousehold()->id) { throw new AccessDeniedHttpException('You cannot access this household.'); } } else { // Advisor must own or have access to this household $household = $locality->getHousehold(); if ($user->advisor_id !== $household->advisor_id) { throw new AccessDeniedHttpException('You cannot access this household.'); } } }}Key Behavior
Section titled “Key Behavior”- Validates based on locality type (Household, Advisor, Organization)
- For clients: must belong to the household
- For advisors: must own or have access to the resource
- Throws
AccessDeniedHttpExceptionon mismatch
4. Locality Abstract Class
Section titled “4. Locality Abstract Class”File: app/Locality/Locality.php
Base abstract class for all locality types.
abstract class Locality{ abstract public function getAdvisor(): ?Advisor;
abstract public function getAdvisorOrFail(): Advisor;
abstract public function getAdvisorId(): ?int;
abstract public function switchHousehold(Household $household): void;}Key Methods
Section titled “Key Methods”| Method | Purpose |
|---|---|
getAdvisor() | Get advisor from locality (nullable) |
getAdvisorOrFail() | Get advisor or throw exception |
getAdvisorId() | Get advisor ID (nullable) |
switchHousehold() | Switch to a different household within locality |
5. Locality Types
Section titled “5. Locality Types”ImpersonatableAdvisorLocality
Section titled “ImpersonatableAdvisorLocality”Base class for localities that support impersonation:
abstract class ImpersonatableAdvisorLocality extends Locality{ protected Advisor $advisor; protected ?Household $household = null;
public function getAdvisor(): Advisor { return $this->advisor; }
public function getAdvisorOrFail(): Advisor { return $this->advisor; }
public function getAdvisorId(): int { return $this->advisor->id; }}HouseholdLocality
Section titled “HouseholdLocality”final class HouseholdLocality extends ImpersonatableAdvisorLocality{ public function __construct(Household $household) { $this->household = $household; $this->advisor = $household->advisor; }
public function getHousehold(): Household { return $this->household; }
public function switchHousehold(Household $household): void { $this->household = $household; }
public function cacheKey(): string { return "household:{$this->household->id}"; }}AdvisorLocality
Section titled “AdvisorLocality”final class AdvisorLocality extends ImpersonatableAdvisorLocality{ public function __construct(Advisor $advisor) { $this->advisor = $advisor; }
public function switchHousehold(Household $household): void { $this->household = $household; }
public function cacheKey(): string { return "advisor:{$this->advisor->id}"; }}OrganizationLocality
Section titled “OrganizationLocality”final class OrganizationLocality extends Locality{ public function __construct( private readonly Organization $organization ) {}
public function getOrganization(): Organization { return $this->organization; }
public function getAdvisor(): ?Advisor { return null; }
public function getAdvisorOrFail(): Advisor { throw new RuntimeException('Organization locality does not have an advisor.'); }
public function getAdvisorId(): ?int { return null; }
public function switchHousehold(Household $household): void { throw new RuntimeException('Cannot switch household in organization locality.'); }}FederationLocality
Section titled “FederationLocality”final class FederationLocality extends Locality{ public function __construct( private readonly Federation $federation ) {}
public function getFederation(): Federation { return $this->federation; }
public function getAdvisor(): ?Advisor { return null; }
public function getAdvisorOrFail(): Advisor { throw new RuntimeException('Federation locality does not have an advisor.'); }
public function getAdvisorId(): ?int { return null; }
public function switchHousehold(Household $household): void { throw new RuntimeException('Cannot switch household in federation locality.'); }}UserLocality
Section titled “UserLocality”final class UserLocality extends Locality{ public function __construct( private readonly User $user ) {}
public function getUser(): User { return $this->user; }
public function getAdvisor(): ?Advisor { return $this->user->advisor; }
public function getAdvisorOrFail(): Advisor { return $this->user->advisor ?? throw new RuntimeException('User does not have an advisor.'); }
public function getAdvisorId(): ?int { return $this->user->advisor_id; }
public function switchHousehold(Household $household): void { // User locality may support household switching }}6. AddXRightCapitalUserInfoHeader
Section titled “6. AddXRightCapitalUserInfoHeader”File: app/Http/Middleware/AddXRightCapitalUserInfoHeader.php
Adds encrypted user ID and impersonator info to response headers for access logging.
Implementation
Section titled “Implementation”final class AddXRightCapitalUserInfoHeader{ public const string HEADER_X_RIGHTCAPITAL_USERID = 'X-RightCapital-UserID'; public const string HEADER_X_RIGHTCAPITAL_EMPLOYEE_EMAIL = 'X-RightCapital-Employee-Email';
public function handle(Request $request, Closure $next): SymfonyResponse { $response = $next($request);
$login_user_id = Auth::id();
if ($login_user_id !== null && ($response instanceof Response || $response instanceof JsonResponse)) { // Encrypt user ID in header $response->withHeaders([ self::HEADER_X_RIGHTCAPITAL_USERID => Crypt::encryptId($login_user_id), ]);
// Add impersonator email from session $employee = Session::get(SessionEntity::KEY_IMPERSONATOR_EMPLOYEE);
if ($employee !== null) { $response->withHeaders([ self::HEADER_X_RIGHTCAPITAL_EMPLOYEE_EMAIL => $employee['email'], ]); } }
return $response; }}Key Behavior
Section titled “Key Behavior”- Encrypts user ID using
Crypt::encryptId()(not plain ID) - Gets impersonator from session key
SessionEntity::KEY_IMPERSONATOR_EMPLOYEE(notapp('impersonator')) - Only adds headers to
ResponseorJsonResponseinstances
Response Headers
Section titled “Response Headers”X-RightCapital-UserID: encrypted_id_stringX-RightCapital-Employee-Email: employee@rightcapital.com # If impersonating7. AddXRightCapitalCalculationId
Section titled “7. AddXRightCapitalCalculationId”File: app/Http/Middleware/AddXRightCapitalCalculationId.php
Adds calculation tracking IDs for debugging and correlation.
Implementation
Section titled “Implementation”final class AddXRightCapitalCalculationId{ public const string HEADER_CALCULATION_IDS = 'X-RightCapital-Calculation-IDs';
public function handle(Request $request, Closure $next): SymfonyResponse { $response = $next($request);
$calculation_ids = Context::getCalculationIds();
if (count($calculation_ids) > 0 && $response instanceof Response) { $response->header(self::HEADER_CALCULATION_IDS, implode(',', $calculation_ids));
// Handle CORS expose headers $expose_headers = $response->headers->get('Access-Control-Expose-Headers', ''); $expose_list = array_filter(explode(',', $expose_headers)); $expose_list[] = self::HEADER_CALCULATION_IDS; $response->header('Access-Control-Expose-Headers', implode(',', array_unique($expose_list))); }
return $response; }}Key Behavior
Section titled “Key Behavior”- Uses
Context::getCalculationIds()to get calculation IDs (notapp('calculation.id')) - Supports multiple calculation IDs (comma-separated)
- Merges CORS expose headers to ensure header is visible to browser
Response Headers
Section titled “Response Headers”X-RightCapital-Calculation-IDs: calc_123,calc_456Access-Control-Expose-Headers: X-RightCapital-Calculation-IDs,...8. Encrypted Route Parameters
Section titled “8. Encrypted Route Parameters”Route parameters containing IDs are encrypted for security:
Route Definition
Section titled “Route Definition”Route::prefix('advisors/{advisor}')->group(function () { Route::get('/', [AdvisorController::class, 'show']);
Route::prefix('households/{household}')->group(function () { Route::get('/', [HouseholdController::class, 'show']); });});URL Format
Section titled “URL Format”/advisors/encrypted_advisor_id/households/encrypted_household_idDecryption in LocalityManager
Section titled “Decryption in LocalityManager”$encrypted_id = $route->parameter('household');$id = Crypt::decryptId($encrypted_id);$household = Household::findOrFail($id);9. Locality Hierarchy
Section titled “9. Locality Hierarchy”Federation └── Organization └── Advisor └── HouseholdWhen a household locality is set:
LocalityManager::locality()→ HouseholdLocality$locality->getAdvisor()→ Household’s Advisor$locality->getHousehold()→ Household
10. Using Locality in Controllers
Section titled “10. Using Locality in Controllers”Accessing Current Locality
Section titled “Accessing Current Locality”class HouseholdController extends Controller{ public function show() { // Locality was set by middleware from route parameter $locality = LocalityManager::locality();
// Type check if ($locality instanceof HouseholdLocality) { $household = $locality->getHousehold(); $advisor = $locality->getAdvisor(); }
return new HouseholdResource($household); }}Switching Household
Section titled “Switching Household”class PlanController extends Controller{ public function compare(Household $other) { // Switch to compare with another household LocalityManager::switchHousehold($other);
// Perform comparison... }}Summary
Section titled “Summary”| Component | Purpose |
|---|---|
InitializeLocality | Extract locality from route, pass to LocalityManager |
CheckLocalityConsistentWithLoginUser | Validate login user can access locality |
LocalityManager | Facade + service for locality state management |
Locality (abstract) | Base class with getAdvisor(), switchHousehold() |
ImpersonatableAdvisorLocality | Base for localities with advisor context |
HouseholdLocality | Household context (extends ImpersonatableAdvisorLocality) |
AdvisorLocality | Advisor context |
OrganizationLocality | Organization context (no advisor) |
FederationLocality | Federation context (no advisor) |
UserLocality | User context |
AddXRightCapitalUserInfoHeader | Encrypted user ID + impersonator from session |
AddXRightCapitalCalculationId | Calculation IDs from Context + CORS headers |