refactor: BindSentryContext to AuthScopeContextListener for auth-scope tags

Sentry-context binding split into two responsibilities:

- Route-scope (app, http.method, route_name) stays in middleware on
  the api group as BindSentryRouteContext — works on every request,
  no auth required.
- Auth-scope (user_id, actor_type) moves to AuthScopeContextListener
  on Illuminate\Auth\Events\Authenticated — works on every
  authentication mechanism (Sanctum, portal-tokens, future
  authenticators) without per-route middleware-attachment. Listener
  also augments Log::withContext with user_id (closes OBS-2).

Architecturally fault-preventing rather than fault-detecting: new
authenticated route groups need no separate sentry.context aliasing,
so silent observability gaps are no longer possible (closes OBS-3).

Impersonation tagging is co-located with HandleImpersonation: after
the user-swap, the middleware re-tags Sentry scope with the target
user_id/actor_type and adds impersonation.active /
impersonation.impersonator_user_id / impersonation.session_id. The
Authenticated event fires for the admin (Sanctum's natural flow),
the listener tags the admin, then HandleImpersonation overwrites
post-swap.

Files renamed:
- BindSentryContext -> BindSentryRouteContext (route-scope only)
- BindSentryContextTest -> BindSentryRouteContextTest (4 cases)

Files added:
- AuthScopeContextListener
- AuthScopeContextListenerTest (6 cases)

bootstrap/app.php drops the sentry.context alias and prepends
BindSentryRouteContext to the api group. routes/api.php drops every
sentry.context middleware string from auth:sanctum groups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:53:14 +02:00
parent 42994522eb
commit 9414d09472
11 changed files with 401 additions and 532 deletions

View File

@@ -4,13 +4,17 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\ImpersonationService;
use App\Enums\Observability\ActorType;
use App\Models\User;
use App\Services\ImpersonationService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Sentry\State\Scope;
use Symfony\Component\HttpFoundation\Response;
use function Sentry\configureScope;
class HandleImpersonation
{
/**
@@ -88,6 +92,24 @@ class HandleImpersonation
'impersonation_session_id' => $session->id,
]);
// Re-bind Sentry auth-scope tags after the user swap. The
// Authenticated event already fired with the admin; AuthScopeContextListener
// tagged the admin's user_id/actor_type. We now overwrite both with
// the target's data and add the impersonation.* invariants
// (RFC-WS-7 §3.6) so captured events attribute correctly.
$targetActorType = ActorType::resolve($targetUser, $request);
configureScope(static function (Scope $scope) use ($admin, $targetUser, $session, $targetActorType): void {
$scope->setUser([
'id' => $targetUser->id,
'username' => $targetUser->id,
]);
$scope->setTag('user_id', $targetUser->id);
$scope->setTag('actor_type', $targetActorType->value);
$scope->setTag('impersonation.active', 'true');
$scope->setTag('impersonation.impersonator_user_id', $admin->id);
$scope->setTag('impersonation.session_id', $session->id);
});
// Increment actions count
$this->impersonationService->incrementActionsCount($session);