fix: register AuthScopeContextListener for Sanctum bearer-token flow

Live HTTP smoke test on the post-architectural-fixes branch surfaced
that captured Sentry events carried only route-scope tags (app,
route_name, http.method) — auth-scope tags (user_id, actor_type,
actor_scope) were absent on every request.

Root cause: Sanctum's Guard fires Laravel\Sanctum\Events\TokenAuthenticated
(vendor/laravel/sanctum/src/Guard.php:77) on bearer-token resolution,
NOT Illuminate\Auth\Events\Authenticated. The Authenticated event only
fires from SessionGuard
(vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php:833),
which Crewli does not use — CookieBearerToken middleware injects the
httpOnly cookie as Authorization: Bearer, then auth:sanctum invokes
Sanctum's Guard. So the listener never ran on Crewli's HTTP path.

Offline tests in AuthScopeContextListenerTest passed because they
dispatch event(new Authenticated(...)) directly, bypassing the Guard
layer. Sanctum::actingAs() in tests has the same blind spot — it
short-circuits the Guard via guard('sanctum')->setUser() and fires
neither event.

Fix:
- New handleTokenAuthenticated(TokenAuthenticated $event) method on
  AuthScopeContextListener extracts the user via $event->token->tokenable
  and delegates to a private bindForUser() shared with handle().
- AppServiceProvider registers the listener for both Authenticated
  (covers SessionGuard / login flow / future authenticators) and
  TokenAuthenticated (covers Crewli's bearer-token Sanctum flow).

Regression coverage: AuthScopeBindingHttpFlowTest exercises the real
Sanctum Guard via $user->createToken() + Authorization: Bearer header.
Three cases:
  - super_admin on a user-scope route: actor_scope=user, all auth tags
    present.
  - super_admin on an admin.* route: actor_scope=platform, no
    organisation_id (correct platform-mode behaviour).
  - org_admin on a route with {organisation} param: actor_scope=
    organisation, organisation_id valid ULID.

Test count 1541 to 1544. Larastan clean. Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 13:58:42 +02:00
parent 0379016c7e
commit adab3be781
3 changed files with 193 additions and 15 deletions

View File

@@ -205,12 +205,20 @@ class AppServiceProvider extends ServiceProvider
);
// RFC-WS-7 §3.6 — auth-scope Sentry tags + Log::withContext on
// every successful authentication. Decoupled from middleware so
// future authenticators (e.g. portal-token) only need to fire
// the Authenticated event for tagging to work.
// every successful authentication. Listens to two events:
// - Authenticated covers SessionGuard flows (login etc.).
// - TokenAuthenticated covers Sanctum bearer-token flows; this
// is Crewli's actual SPA auth path because CookieBearerToken
// middleware injects the cookie as an Authorization header.
// Without this, live HTTP events would carry no auth-scope
// tags even though the offline (event-dispatch) tests pass.
\Illuminate\Support\Facades\Event::listen(
\Illuminate\Auth\Events\Authenticated::class,
\App\Listeners\Observability\AuthScopeContextListener::class,
[\App\Listeners\Observability\AuthScopeContextListener::class, 'handle'],
);
\Illuminate\Support\Facades\Event::listen(
\Laravel\Sanctum\Events\TokenAuthenticated::class,
[\App\Listeners\Observability\AuthScopeContextListener::class, 'handleTokenAuthenticated'],
);
ResetPassword::createUrlUsing(function ($user, string $token) {