WS-7 Observability — closure #8
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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