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:
@@ -1,195 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Enums\Observability\ActorType;
|
||||
use App\Models\Event;
|
||||
use App\Models\ImpersonationSession;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Sentry\State\Scope;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
use function Sentry\configureScope;
|
||||
|
||||
/**
|
||||
* Attach the Crewli context tags listed in RFC-WS-7 §3.6 to every Sentry
|
||||
* event captured during this request.
|
||||
*
|
||||
* Multi-tenant invariant (§3.6): if the request is authenticated AND a
|
||||
* controller is about to act on tenant data, organisation_id MUST be
|
||||
* resolvable. In `local` and `testing` we throw so missing-tag bugs surface
|
||||
* during development; in other environments we log a warning and fall
|
||||
* through (don't break user flows over a missing tag).
|
||||
*/
|
||||
final class BindSentryContext
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$actorType = ActorType::resolve($user, $request);
|
||||
$organisationId = $this->resolveOrganisationId($request);
|
||||
$eventId = $this->resolveEventId($request);
|
||||
$impersonatorUserId = $this->resolveImpersonatorUserId($request);
|
||||
|
||||
$this->enforceTenantInvariant($request, $user, $organisationId);
|
||||
|
||||
configureScope(function (Scope $scope) use ($request, $user, $actorType, $organisationId, $eventId, $impersonatorUserId): void {
|
||||
$scope->setTag('app', 'api');
|
||||
$scope->setTag('http.method', $request->method());
|
||||
$scope->setTag('actor_type', $actorType->value);
|
||||
|
||||
$routeName = $request->route()?->getName();
|
||||
if (is_string($routeName) && $routeName !== '') {
|
||||
$scope->setTag('route_name', $routeName);
|
||||
}
|
||||
|
||||
if ($user instanceof User) {
|
||||
$scope->setTag('user_id', $user->id);
|
||||
$scope->setUser([
|
||||
'id' => $user->id,
|
||||
'username' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
if (is_string($organisationId) && $organisationId !== '') {
|
||||
$scope->setTag('organisation_id', $organisationId);
|
||||
}
|
||||
|
||||
if (is_string($eventId) && $eventId !== '') {
|
||||
$scope->setTag('event_id', $eventId);
|
||||
}
|
||||
|
||||
$scope->setTag('impersonation.active', $impersonatorUserId !== null ? 'true' : 'false');
|
||||
if ($impersonatorUserId !== null) {
|
||||
$scope->setTag('impersonation.impersonator_user_id', $impersonatorUserId);
|
||||
}
|
||||
});
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function resolveOrganisationId(Request $request): ?string
|
||||
{
|
||||
$portalEvent = $request->attributes->get('portal_event');
|
||||
if ($portalEvent instanceof Event) {
|
||||
return $portalEvent->organisation_id;
|
||||
}
|
||||
|
||||
$route = $request->route();
|
||||
if ($route === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$org = $route->parameter('organisation');
|
||||
if ($org instanceof Organisation) {
|
||||
return $org->id;
|
||||
}
|
||||
if (is_string($org) && $org !== '') {
|
||||
return $org;
|
||||
}
|
||||
|
||||
$event = $route->parameter('event');
|
||||
if ($event instanceof Event) {
|
||||
return $event->organisation_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveEventId(Request $request): ?string
|
||||
{
|
||||
$portalEvent = $request->attributes->get('portal_event');
|
||||
if ($portalEvent instanceof Event) {
|
||||
return $portalEvent->id;
|
||||
}
|
||||
|
||||
$event = $request->route()?->parameter('event');
|
||||
if ($event instanceof Event) {
|
||||
return $event->id;
|
||||
}
|
||||
if (is_string($event) && $event !== '') {
|
||||
return $event;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveImpersonatorUserId(Request $request): ?string
|
||||
{
|
||||
$impersonator = $request->attributes->get('impersonator');
|
||||
if ($impersonator instanceof User) {
|
||||
return $impersonator->id;
|
||||
}
|
||||
|
||||
$session = $request->attributes->get('impersonation_session');
|
||||
if ($session instanceof ImpersonationSession) {
|
||||
return $session->admin_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function enforceTenantInvariant(Request $request, mixed $user, ?string $organisationId): void
|
||||
{
|
||||
if (! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($organisationId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->routeRequiresTenantContext($request) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$env = app()->environment();
|
||||
$message = sprintf(
|
||||
'BindSentryContext: authenticated request to "%s" lacks resolvable organisation_id',
|
||||
$request->path(),
|
||||
);
|
||||
|
||||
if (in_array($env, ['local', 'testing'], true)) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
Log::warning($message, [
|
||||
'route' => $request->route()?->getName(),
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Crewli tenant invariant: a route that declares an `{organisation}`
|
||||
* or `{event}` URI parameter MUST resolve to a real organisation_id by
|
||||
* the time this middleware runs. Routes that don't declare those params
|
||||
* are user-scoped (account, portal, lists across user's memberships) and
|
||||
* legitimately have no tenant context.
|
||||
*/
|
||||
private function routeRequiresTenantContext(Request $request): bool
|
||||
{
|
||||
if ($request->attributes->get('portal_event') !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$route = $request->route();
|
||||
if ($route === null) {
|
||||
// Synthetic test requests without a Laravel route still trigger
|
||||
// the invariant if the path looks tenant-scoped, so dev-time
|
||||
// bugs surface in unit/feature tests.
|
||||
return str_contains($request->path(), 'organisations/')
|
||||
|| str_contains($request->path(), 'events/');
|
||||
}
|
||||
|
||||
$names = $route->parameterNames();
|
||||
|
||||
return in_array('organisation', $names, true) || in_array('event', $names, true);
|
||||
}
|
||||
}
|
||||
41
api/app/Http/Middleware/BindSentryRouteContext.php
Normal file
41
api/app/Http/Middleware/BindSentryRouteContext.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Sentry\State\Scope;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
use function Sentry\configureScope;
|
||||
|
||||
/**
|
||||
* Binds route-scope context to Sentry events on every API request.
|
||||
*
|
||||
* Auth-scope tags (user_id, actor_type, organisation_id, impersonation.*,
|
||||
* actor_scope) live in {@see \App\Listeners\Observability\AuthScopeContextListener}
|
||||
* so they bind on Authenticated event rather than route entry. That keeps
|
||||
* the auth-scope binding uniform across Sanctum, portal-tokens, and any
|
||||
* future authenticator without per-route middleware-attachment.
|
||||
*
|
||||
* RFC-WS-7 §3.6.
|
||||
*/
|
||||
final class BindSentryRouteContext
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
configureScope(static function (Scope $scope) use ($request): void {
|
||||
$scope->setTag('app', 'api');
|
||||
$scope->setTag('http.method', $request->method());
|
||||
|
||||
$routeName = $request->route()?->getName();
|
||||
if (is_string($routeName) && $routeName !== '') {
|
||||
$scope->setTag('route_name', $routeName);
|
||||
}
|
||||
});
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
58
api/app/Listeners/Observability/AuthScopeContextListener.php
Normal file
58
api/app/Listeners/Observability/AuthScopeContextListener.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listeners\Observability;
|
||||
|
||||
use App\Enums\Observability\ActorType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Authenticated;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Sentry\State\Scope;
|
||||
|
||||
use function Sentry\configureScope;
|
||||
|
||||
/**
|
||||
* Binds auth-scope context to Sentry and Laravel log context on every
|
||||
* successful authentication (Sanctum, portal-token, future authenticators).
|
||||
*
|
||||
* Auth-scope tags (user_id, actor_type, organisation_id, actor_scope) are
|
||||
* decoupled from route-scope tags (which live in BindSentryRouteContext)
|
||||
* so that authentication-mechanism additions don't require touching every
|
||||
* route-group's middleware stack.
|
||||
*
|
||||
* Lifecycle nuance for impersonation: Sanctum's auth resolves to the
|
||||
* admin user and fires Authenticated → this listener tags the admin.
|
||||
* Then HandleImpersonation middleware swaps the auth user to the target
|
||||
* and re-binds Sentry scope itself (impersonation.active=true plus the
|
||||
* target's user_id/actor_type). That keeps impersonation-specific logic
|
||||
* co-located with the middleware that performs the swap.
|
||||
*
|
||||
* RFC-WS-7 §3.6, §3.13 (Log::withContext OBS-2 fix).
|
||||
*/
|
||||
final class AuthScopeContextListener
|
||||
{
|
||||
public function handle(Authenticated $event): void
|
||||
{
|
||||
$user = $event->user;
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actorType = ActorType::resolve($user, request());
|
||||
|
||||
configureScope(static function (Scope $scope) use ($user, $actorType): 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);
|
||||
});
|
||||
|
||||
Log::withContext([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,15 @@ class AppServiceProvider extends ServiceProvider
|
||||
\App\Listeners\Observability\TagJobAttemptOnSentry::class,
|
||||
);
|
||||
|
||||
// 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.
|
||||
\Illuminate\Support\Facades\Event::listen(
|
||||
\Illuminate\Auth\Events\Authenticated::class,
|
||||
\App\Listeners\Observability\AuthScopeContextListener::class,
|
||||
);
|
||||
|
||||
ResetPassword::createUrlUsing(function ($user, string $token) {
|
||||
return config('crewli.portal_url').'/wachtwoord-resetten?token='.$token.'&email='.urlencode($user->email);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user