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:
2026-05-06 12:57:12 +02:00
parent 9414d09472
commit 49cece3784
2 changed files with 223 additions and 4 deletions

View File

@@ -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'];
}
}