user(); $actorType = ActorType::resolve($user, $request); $organisationId = $this->resolveOrganisationId($request); $eventId = $this->resolveEventId($request); $impersonatorUserId = $this->resolveImpersonatorUserId($request); $this->enforceTenantInvariant($request, $user, $organisationId); configureScope(function (Scope $scope) use ($request, $user, $actorType, $organisationId, $eventId, $impersonatorUserId): void { $scope->setTag('app', 'api'); $scope->setTag('http.method', $request->method()); $scope->setTag('actor_type', $actorType->value); $routeName = $request->route()?->getName(); if (is_string($routeName) && $routeName !== '') { $scope->setTag('route_name', $routeName); } if ($user instanceof User) { $scope->setTag('user_id', $user->id); $scope->setUser([ 'id' => $user->id, 'username' => $user->id, ]); } if (is_string($organisationId) && $organisationId !== '') { $scope->setTag('organisation_id', $organisationId); } if (is_string($eventId) && $eventId !== '') { $scope->setTag('event_id', $eventId); } $scope->setTag('impersonation.active', $impersonatorUserId !== null ? 'true' : 'false'); if ($impersonatorUserId !== null) { $scope->setTag('impersonation.impersonator_user_id', $impersonatorUserId); } }); return $next($request); } private function resolveOrganisationId(Request $request): ?string { $portalEvent = $request->attributes->get('portal_event'); if ($portalEvent instanceof Event) { return $portalEvent->organisation_id; } $route = $request->route(); if ($route === null) { return null; } $org = $route->parameter('organisation'); if ($org instanceof Organisation) { return $org->id; } if (is_string($org) && $org !== '') { return $org; } $event = $route->parameter('event'); if ($event instanceof Event) { return $event->organisation_id; } return null; } private function resolveEventId(Request $request): ?string { $portalEvent = $request->attributes->get('portal_event'); if ($portalEvent instanceof Event) { return $portalEvent->id; } $event = $request->route()?->parameter('event'); if ($event instanceof Event) { return $event->id; } if (is_string($event) && $event !== '') { return $event; } return null; } private function resolveImpersonatorUserId(Request $request): ?string { $impersonator = $request->attributes->get('impersonator'); if ($impersonator instanceof User) { return $impersonator->id; } $session = $request->attributes->get('impersonation_session'); if ($session instanceof ImpersonationSession) { return $session->admin_id; } return null; } private function enforceTenantInvariant(Request $request, mixed $user, ?string $organisationId): void { if (! $user instanceof User) { return; } if ($organisationId !== null) { return; } if ($this->routeRequiresTenantContext($request) === false) { return; } $env = app()->environment(); $message = sprintf( 'BindSentryContext: authenticated request to "%s" lacks resolvable organisation_id', $request->path(), ); if (in_array($env, ['local', 'testing'], true)) { throw new RuntimeException($message); } Log::warning($message, [ 'route' => $request->route()?->getName(), 'user_id' => $user->id, ]); } /** * The Crewli tenant invariant: a route that declares an `{organisation}` * or `{event}` URI parameter MUST resolve to a real organisation_id by * the time this middleware runs. Routes that don't declare those params * are user-scoped (account, portal, lists across user's memberships) and * legitimately have no tenant context. */ private function routeRequiresTenantContext(Request $request): bool { if ($request->attributes->get('portal_event') !== null) { return true; } $route = $request->route(); if ($route === null) { // Synthetic test requests without a Laravel route still trigger // the invariant if the path looks tenant-scoped, so dev-time // bugs surface in unit/feature tests. return str_contains($request->path(), 'organisations/') || str_contains($request->path(), 'events/'); } $names = $route->parameterNames(); return in_array('organisation', $names, true) || in_array('event', $names, true); } }