From 49cece378473b695fa6248bea738ba93d9167a24 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Wed, 6 May 2026 12:57:12 +0200 Subject: [PATCH] feat: actor_scope tag + tenant fallback resolution chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-2 live smoke test surfaced that super_admin platform-route exceptions arrived without organisation_id, and the original RFC §3.6 invariant (always-present organisation_id on authenticated events) would force misleading attribution if it tried to fill that gap. Refined invariant: every authenticated event carries actor_scope (organisation/platform/user/anonymous), AND when actor_scope is organisation, organisation_id MUST be a valid ULID. Platform-mode correctly omits organisation_id rather than fabricate one. Resolution chain in AuthScopeContextListener: 1. {organisation} or {event} URI parameter -> actor_scope=organisation 2. portal_event request attribute -> actor_scope=organisation 3. super_admin on admin.* named route -> actor_scope=platform (Crewli's platform-admin routes use the admin. name prefix) 4. Default authenticated -> actor_scope=user, no org tag (User<->Organisation is many-to-many; no reliable single-org hint) Eight new test cases in AuthScopeContextListenerTest cover each branch and the conditional invariant, including ULID validity via Symfony\Component\Uid\Ulid::isValid. Test count 1531 to 1539. Larastan clean. Pint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AuthScopeContextListener.php | 82 +++++++++- .../AuthScopeContextListenerTest.php | 145 ++++++++++++++++++ 2 files changed, 223 insertions(+), 4 deletions(-) diff --git a/api/app/Listeners/Observability/AuthScopeContextListener.php b/api/app/Listeners/Observability/AuthScopeContextListener.php index 2f49b627..131f7837 100644 --- a/api/app/Listeners/Observability/AuthScopeContextListener.php +++ b/api/app/Listeners/Observability/AuthScopeContextListener.php @@ -5,8 +5,11 @@ declare(strict_types=1); namespace App\Listeners\Observability; use App\Enums\Observability\ActorType; +use App\Models\Event; +use App\Models\Organisation; use App\Models\User; use Illuminate\Auth\Events\Authenticated; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Sentry\State\Scope; @@ -40,19 +43,90 @@ final class AuthScopeContextListener return; } - $actorType = ActorType::resolve($user, request()); + $request = request(); + $actorType = ActorType::resolve($user, $request); + [$organisationId, $actorScope] = $this->resolveTenantContext($user, $request); - configureScope(static function (Scope $scope) use ($user, $actorType): void { + configureScope(static function (Scope $scope) use ($user, $actorType, $organisationId, $actorScope): void { $scope->setUser([ 'id' => $user->id, 'username' => $user->id, // RFC §3.8: ULID, never email. ]); $scope->setTag('user_id', $user->id); $scope->setTag('actor_type', $actorType->value); + $scope->setTag('actor_scope', $actorScope); + + if ($organisationId !== null) { + $scope->setTag('organisation_id', $organisationId); + } }); - Log::withContext([ + Log::withContext(array_filter([ 'user_id' => $user->id, - ]); + 'organisation_id' => $organisationId, + 'actor_scope' => $actorScope, + ], static fn ($v) => $v !== null && $v !== '')); + } + + /** + * Resolves organisation_id and actor_scope per RFC §3.6 (refined after + * the PR-2 live smoke test). + * + * Resolution priority: + * 1. Route-scoped: {organisation} or {event} URI parameter resolves + * to an Organisation/Event → actor_scope=organisation. + * 2. Portal token: portal_event request attribute populated by + * PortalTokenMiddleware → actor_scope=organisation. + * 3. super_admin on admin.* route → actor_scope=platform; no + * organisation_id tag (forced current-org fallback would produce + * misleading attribution). + * 4. Default authenticated user → actor_scope=user, organisation_id + * is omitted because Crewli's User<->Organisation is many-to-many; + * no reliable single-org hint exists at user level. + * + * @return array{0: ?string, 1: string} [organisation_id|null, actor_scope] + */ + private function resolveTenantContext(User $user, ?Request $request): array + { + if ($request === null) { + return [null, 'user']; + } + + // 1a. Explicit {organisation} route parameter. + $route = $request->route(); + if ($route !== null) { + $orgParam = $route->parameter('organisation'); + if ($orgParam instanceof Organisation) { + return [$orgParam->id, 'organisation']; + } + if (is_string($orgParam) && $orgParam !== '') { + return [$orgParam, 'organisation']; + } + + // 1b. {event} parameter — derive org via event.organisation_id. + $eventParam = $route->parameter('event'); + if ($eventParam instanceof Event) { + return [$eventParam->organisation_id, 'organisation']; + } + } + + // 2. Portal token (artist/supplier/press flows). + $portalEvent = $request->attributes->get('portal_event'); + if ($portalEvent instanceof Event) { + return [$portalEvent->organisation_id, 'organisation']; + } + + // 3. super_admin on admin.* (Crewli's platform-admin route prefix). + if ($user->hasRole('super_admin') && $route !== null) { + $name = $route->getName(); + if (is_string($name) && str_starts_with($name, 'admin.')) { + return [null, 'platform']; + } + } + + // 4. Default user-scope: no org attribution (Crewli's User has no + // current_organisation_id; many-to-many membership precludes a + // reliable single-org hint). + return [null, 'user']; } } diff --git a/api/tests/Feature/Observability/AuthScopeContextListenerTest.php b/api/tests/Feature/Observability/AuthScopeContextListenerTest.php index b2e13f1b..cdbf2925 100644 --- a/api/tests/Feature/Observability/AuthScopeContextListenerTest.php +++ b/api/tests/Feature/Observability/AuthScopeContextListenerTest.php @@ -106,6 +106,151 @@ final class AuthScopeContextListenerTest extends TestCase $this->assertSame('org_member', $this->captureScopeTags()['actor_type'] ?? null); } + public function test_actor_scope_user_when_no_route_or_portal_context(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('user', $tags['actor_scope'] ?? null); + $this->assertArrayNotHasKey('organisation_id', $tags); + } + + public function test_actor_scope_organisation_when_route_has_organisation_param(): void + { + $org = Organisation::factory()->create(); + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = Request::create('http://localhost/api/v1/organisations/'.$org->id.'/test', 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}/test', static fn () => null); + $route->bind($request); + $route->setParameter('organisation', $org); + $route->name('organisations.test'); + $request->setRouteResolver(static fn () => $route); + $this->app->instance('request', $request); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('organisation', $tags['actor_scope'] ?? null); + $this->assertSame($org->id, $tags['organisation_id'] ?? null); + $this->assertTrue(\Symfony\Component\Uid\Ulid::isValid($tags['organisation_id'])); + } + + public function test_actor_scope_organisation_when_route_has_event_param(): void + { + $org = Organisation::factory()->create(); + $event = \App\Models\Event::factory()->create(['organisation_id' => $org->id]); + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = Request::create('http://localhost/api/v1/events/'.$event->id, 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'events/{event}', static fn () => null); + $route->bind($request); + $route->setParameter('event', $event); + $route->name('events.show'); + $request->setRouteResolver(static fn () => $route); + $this->app->instance('request', $request); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('organisation', $tags['actor_scope'] ?? null); + $this->assertSame($org->id, $tags['organisation_id'] ?? null); + } + + public function test_actor_scope_organisation_when_portal_token_request(): void + { + $org = Organisation::factory()->create(); + $event = \App\Models\Event::factory()->create(['organisation_id' => $org->id]); + $user = User::factory()->create(); + $user->assignRole('org_member'); + + $request = Request::create('http://localhost/api/v1/portal/me', 'GET'); + $request->attributes->set('portal_context', 'artist'); + $request->attributes->set('portal_event', $event); + $this->app->instance('request', $request); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('organisation', $tags['actor_scope'] ?? null); + $this->assertSame($org->id, $tags['organisation_id'] ?? null); + } + + public function test_actor_scope_platform_for_super_admin_on_admin_route(): void + { + $user = User::factory()->create(); + $user->assignRole('super_admin'); + + $request = Request::create('http://localhost/api/v1/admin/users', 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'admin/users', static fn () => null); + $route->bind($request); + $route->name('admin.users.index'); + $request->setRouteResolver(static fn () => $route); + $this->app->instance('request', $request); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('platform', $tags['actor_scope'] ?? null); + $this->assertArrayNotHasKey('organisation_id', $tags); + } + + public function test_actor_scope_user_for_super_admin_on_non_admin_route(): void + { + $user = User::factory()->create(); + $user->assignRole('super_admin'); + + $request = Request::create('http://localhost/api/v1/me/profile', 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'me/profile', static fn () => null); + $route->bind($request); + $route->name('me.profile'); + $request->setRouteResolver(static fn () => $route); + $this->app->instance('request', $request); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('user', $tags['actor_scope'] ?? null); + $this->assertArrayNotHasKey('organisation_id', $tags); + } + + public function test_actor_scope_always_present_on_authenticated_event(): void + { + $user = User::factory()->create(); + $user->assignRole('org_member'); + + event(new Authenticated('web', $user)); + + $this->assertArrayHasKey('actor_scope', $this->captureScopeTags()); + } + + public function test_organisation_id_present_when_actor_scope_is_organisation(): void + { + $org = Organisation::factory()->create(); + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = Request::create('http://localhost/api/v1/organisations/'.$org->id, 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}', static fn () => null); + $route->bind($request); + $route->setParameter('organisation', $org); + $route->name('organisations.show'); + $request->setRouteResolver(static fn () => $route); + $this->app->instance('request', $request); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('organisation', $tags['actor_scope']); + $this->assertArrayHasKey('organisation_id', $tags); + $this->assertTrue(\Symfony\Component\Uid\Ulid::isValid($tags['organisation_id'])); + } + public function test_authenticated_event_does_not_set_impersonation_tags(): void { $user = User::factory()->create();