diff --git a/api/app/Enums/Observability/ActorType.php b/api/app/Enums/Observability/ActorType.php index 5a36fe8b..1fd6c7ac 100644 --- a/api/app/Enums/Observability/ActorType.php +++ b/api/app/Enums/Observability/ActorType.php @@ -15,9 +15,13 @@ use Illuminate\Http\Request; * 1. Portal-token request → PORTAL_TOKEN * 2. Authenticated super_admin → SUPER_ADMIN * 3. Authenticated org_admin → ORGANIZER_ADMIN - * 4. Authenticated volunteer (role match) → VOLUNTEER - * 5. Other authenticated user → ORG_MEMBER - * 6. None of the above → UNAUTHENTICATED + * 4. Other authenticated user → ORG_MEMBER (covers volunteers — Crewli has + * no `volunteer` Spatie role today; volunteers join an organisation as + * org_member with org-pivot semantics. Promote to a dedicated case once + * the role model differentiates them at the user level.) + * 5. None of the above → UNAUTHENTICATED + * + * The VOLUNTEER case is reserved for that future split. */ enum ActorType: string { @@ -46,10 +50,6 @@ enum ActorType: string return self::ORGANIZER_ADMIN; } - if ($user->hasRole('volunteer')) { - return self::VOLUNTEER; - } - return self::ORG_MEMBER; } } diff --git a/api/app/Http/Middleware/BindSentryContext.php b/api/app/Http/Middleware/BindSentryContext.php new file mode 100644 index 00000000..d414e210 --- /dev/null +++ b/api/app/Http/Middleware/BindSentryContext.php @@ -0,0 +1,195 @@ +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); + } +} diff --git a/api/app/Listeners/Observability/TagJobAttemptOnSentry.php b/api/app/Listeners/Observability/TagJobAttemptOnSentry.php new file mode 100644 index 00000000..dac565bd --- /dev/null +++ b/api/app/Listeners/Observability/TagJobAttemptOnSentry.php @@ -0,0 +1,29 @@ +job->attempts(); + + configureScope(static function (Scope $scope) use ($attempt): void { + $scope->setTag('queue.attempt', $attempt); + }); + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 7ccaf536..02706692 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -196,6 +196,14 @@ class AppServiceProvider extends ServiceProvider ApplyBindingsOnFormSectionSubmitted::class, ); + // RFC-WS-7 §3.6 / §3.11 — tag captured Sentry events with the queue + // attempt count. Default stack-trace grouping is preserved (no + // per-attempt fingerprinting). + \Illuminate\Support\Facades\Event::listen( + \Illuminate\Queue\Events\JobProcessing::class, + \App\Listeners\Observability\TagJobAttemptOnSentry::class, + ); + ResetPassword::createUrlUsing(function ($user, string $token) { return config('crewli.portal_url').'/wachtwoord-resetten?token='.$token.'&email='.urlencode($user->email); }); diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index 3194ba61..ea84f8fa 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -36,6 +36,11 @@ return Application::configure(basePath: dirname(__DIR__)) 'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'impersonation' => \App\Http\Middleware\HandleImpersonation::class, + // RFC-WS-7 §3.6 — applied inside auth:sanctum groups so it runs + // after authentication and can read $request->user(). Cannot live + // on the api group because route-level auth middleware runs after + // group middleware in Laravel. + 'sentry.context' => \App\Http\Middleware\BindSentryContext::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/api/routes/api.php b/api/routes/api.php index 412c70d1..ae915fd4 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -120,7 +120,7 @@ Route::middleware('throttle:30,1')->group(function (): void { // Platform Admin routes Route::prefix('admin') - ->middleware(['auth:sanctum', 'impersonation', 'role:super_admin']) + ->middleware(['auth:sanctum', 'impersonation', 'sentry.context', 'role:super_admin']) ->name('admin.') ->group(function () { // Organisations @@ -155,7 +155,7 @@ Route::prefix('admin') }); // Protected routes -Route::middleware(['auth:sanctum', 'impersonation'])->group(function () { +Route::middleware(['auth:sanctum', 'impersonation', 'sentry.context'])->group(function () { // Impersonation (stop — accessible by impersonated user, not just super_admin) Route::post('admin/stop-impersonation', [AdminImpersonationController::class, 'stop']); diff --git a/api/tests/Feature/Observability/BindSentryContextTest.php b/api/tests/Feature/Observability/BindSentryContextTest.php new file mode 100644 index 00000000..c9c201c2 --- /dev/null +++ b/api/tests/Feature/Observability/BindSentryContextTest.php @@ -0,0 +1,329 @@ +seed(RoleSeeder::class); + $this->resetSentryScope(); + } + + private function resetSentryScope(): void + { + SentrySdk::getCurrentHub()->pushScope(); + } + + /** + * Read the current Sentry scope by applying it to a fresh Event and + * harvesting the tags / user context. + * + * @return array{tags: array, user: ?array} + */ + private function captureScope(): array + { + $event = SentryEvent::createEvent(); + + configureScope(static function (Scope $scope) use ($event): void { + $scope->applyToEvent($event); + }); + + $userBag = $event->getUser(); + + return [ + 'tags' => $event->getTags(), + 'user' => $userBag === null ? null : array_filter([ + 'id' => $userBag->getId(), + 'username' => $userBag->getUsername(), + ], static fn ($v) => $v !== null), + ]; + } + + private function runMiddleware(Request $request): void + { + (new BindSentryContext)->handle($request, static fn (Request $req) => response('ok')); + } + + private function makeAuthenticatedRequest(User $user, string $path = 'api/v1/me/profile'): Request + { + $request = Request::create('http://localhost/'.$path, 'GET'); + $request->setUserResolver(static fn () => $user); + + return $request; + } + + public function test_authenticated_org_admin_request_tags_organisation_id(): void + { + $org = Organisation::factory()->create(); + $user = User::factory()->create(); + $org->users()->attach($user, ['role' => 'org_admin']); + $user->assignRole('org_admin'); + + $request = $this->makeAuthenticatedRequest($user, 'api/v1/organisations/'.$org->id.'/some-path'); + $request->setRouteResolver(function () use ($org, $request) { + $route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}/some-path', static fn () => null); + $route->bind($request); + $route->setParameter('organisation', $org); + $route->name('organisations.test'); + + return $route; + }); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame($org->id, $scope['tags']['organisation_id'] ?? null); + } + + public function test_authenticated_org_admin_request_tags_user_id(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile'); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame($user->id, $scope['tags']['user_id'] ?? null); + $this->assertSame($user->id, $scope['user']['id'] ?? null); + $this->assertSame($user->id, $scope['user']['username'] ?? null); + } + + public function test_authenticated_org_admin_request_tags_actor_type_organizer_admin(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = $this->makeAuthenticatedRequest($user); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame('organizer_admin', $scope['tags']['actor_type'] ?? null); + } + + public function test_super_admin_request_tags_actor_type_super_admin(): void + { + $user = User::factory()->create(); + $user->assignRole('super_admin'); + + $request = $this->makeAuthenticatedRequest($user, 'api/v1/admin/organisations'); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame('super_admin', $scope['tags']['actor_type'] ?? null); + } + + public function test_org_member_authenticated_user_tags_actor_type_org_member(): void + { + // Crewli has no `volunteer` Spatie role today; volunteers fall into + // org_member. The VOLUNTEER ActorType case is reserved for a future + // split — see ActorType::resolve() docblock. + $user = User::factory()->create(); + $user->assignRole('org_member'); + + $request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile'); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame('org_member', $scope['tags']['actor_type'] ?? null); + } + + public function test_portal_token_request_tags_actor_type_portal_token(): void + { + $org = Organisation::factory()->create(); + $event = Event::factory()->create(['organisation_id' => $org->id]); + + $request = Request::create('http://localhost/api/v1/portal/me', 'GET'); + $request->attributes->set('portal_context', 'artist'); + $request->attributes->set('portal_event', $event); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame('portal_token', $scope['tags']['actor_type'] ?? null); + } + + public function test_portal_token_request_tags_organisation_id_from_token(): void + { + $org = Organisation::factory()->create(); + $event = Event::factory()->create(['organisation_id' => $org->id]); + + $request = Request::create('http://localhost/api/v1/portal/me', 'GET'); + $request->attributes->set('portal_context', 'artist'); + $request->attributes->set('portal_event', $event); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame($org->id, $scope['tags']['organisation_id'] ?? null); + $this->assertSame($event->id, $scope['tags']['event_id'] ?? null); + } + + public function test_unauthenticated_request_tags_actor_type_unauthenticated(): void + { + $request = Request::create('http://localhost/api/v1/auth/login', 'POST'); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame('unauthenticated', $scope['tags']['actor_type'] ?? null); + $this->assertArrayNotHasKey('user_id', $scope['tags']); + } + + public function test_authenticated_request_to_tenant_scoped_route_without_org_throws_in_test_environment(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = $this->makeAuthenticatedRequest($user, 'api/v1/organisations/missing/events'); + // Synthesise a route that DECLARES {organisation} but has no + // bound parameter — the invariant must fire. + $route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}/events', static fn () => null); + $route->bind($request); + $request->setRouteResolver(static fn () => $route); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/lacks resolvable organisation_id/'); + + $this->runMiddleware($request); + } + + public function test_authenticated_request_to_user_scoped_route_skips_invariant(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + // /me/profile route declares no {organisation} or {event} param — + // user-scoped, invariant skipped. + $request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile'); + $route = new \Illuminate\Routing\Route(['GET'], 'me/profile', static fn () => null); + $route->bind($request); + $request->setRouteResolver(static fn () => $route); + + $this->runMiddleware($request); + + $scope = $this->captureScope(); + $this->assertSame('organizer_admin', $scope['tags']['actor_type'] ?? null); + } + + public function test_impersonation_active_tag_when_session_active(): void + { + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + $target = User::factory()->create(); + $target->assignRole('org_admin'); + + $request = $this->makeAuthenticatedRequest($target, 'api/v1/me/profile'); + $request->attributes->set('impersonator', $admin); + $session = new ImpersonationSession; + $session->id = '01J0000000000000000000IMPS'; + $session->admin_id = $admin->id; + $session->target_user_id = $target->id; + $request->attributes->set('impersonation_session', $session); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame('true', $scope['tags']['impersonation.active'] ?? null); + } + + public function test_impersonation_impersonator_user_id_tag_when_session_active(): void + { + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + $target = User::factory()->create(); + $target->assignRole('org_admin'); + + $request = $this->makeAuthenticatedRequest($target, 'api/v1/me/profile'); + $request->attributes->set('impersonator', $admin); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame($admin->id, $scope['tags']['impersonation.impersonator_user_id'] ?? null); + } + + public function test_impersonation_active_false_when_no_session(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile'); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame('false', $scope['tags']['impersonation.active'] ?? null); + $this->assertArrayNotHasKey('impersonation.impersonator_user_id', $scope['tags']); + } + + public function test_route_name_tag_present(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile'); + $route = new \Illuminate\Routing\Route(['GET'], 'me/profile', static fn () => null); + $route->name('me.profile'); + $route->bind($request); + $request->setRouteResolver(static fn () => $route); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame('me.profile', $scope['tags']['route_name'] ?? null); + } + + public function test_http_method_tag_present(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = Request::create('http://localhost/api/v1/me/profile', 'PATCH'); + $request->setUserResolver(static fn () => $user); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame('PATCH', $scope['tags']['http.method'] ?? null); + } + + public function test_app_tag_is_api(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = $this->makeAuthenticatedRequest($user); + + $this->runMiddleware($request); + $scope = $this->captureScope(); + + $this->assertSame('api', $scope['tags']['app'] ?? null); + } +}