Files
crewli/api/app/Http/Middleware/HandleImpersonation.php
bert.hausmans 9414d09472 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>
2026-05-06 12:53:14 +02:00

139 lines
4.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Middleware;
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
{
/**
* Routes that are blocked during impersonation (all methods).
* Prefix-matched against the request path (without api/v1 prefix).
*/
private const BLOCKED_ROUTE_PREFIXES = [
'auth/logout',
'auth/refresh',
'auth/mfa',
'auth/trusted-devices',
'me/change-password',
'me/change-email',
'admin/impersonate',
'verify-email-change',
];
public function __construct(
private readonly ImpersonationService $impersonationService,
) {}
public function handle(Request $request, Closure $next): Response
{
$targetUserId = $request->header('X-Impersonate-User');
if (! $targetUserId) {
return $next($request);
}
/** @var User|null $admin */
$admin = $request->user();
if (! $admin) {
return response()->json(['message' => 'Authentication required.'], 401);
}
// Block sensitive routes during impersonation
if ($this->isSensitiveRoute($request)) {
return response()->json([
'message' => 'This action is not allowed during impersonation.',
], 403);
}
// Validate impersonation session via Redis
$session = $this->impersonationService->validateRequest(
$admin->id,
$targetUserId,
$request->ip(),
);
if (! $session) {
return response()->json([
'message' => 'Impersonation session is invalid or has expired.',
'impersonation_ended' => true,
], 403);
}
// Load the target user
$targetUser = User::find($targetUserId);
if (! $targetUser) {
return response()->json(['message' => 'Target user not found.'], 404);
}
// Store impersonation context in request attributes
$request->attributes->set('impersonator', $admin);
$request->attributes->set('impersonation_session', $session);
// Swap auth context — the rest of the request sees the target user
app('auth')->setUser($targetUser);
// Tag all log entries with impersonation context
Log::shareContext([
'impersonated_by' => $admin->id,
'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);
return $next($request);
}
private function isSensitiveRoute(Request $request): bool
{
// Get path relative to API prefix (strip api/v1/)
$path = $request->path();
$path = preg_replace('#^api/v1/#', '', $path);
// Block profile mutations but allow GET (viewing)
if (str_starts_with($path, 'me/profile') && $request->method() !== 'GET') {
return true;
}
foreach (self::BLOCKED_ROUTE_PREFIXES as $prefix) {
if (str_starts_with($path, $prefix)) {
return true;
}
}
return false;
}
}