feat: actor_scope tag + tenant fallback resolution chain
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user