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

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Http\Middleware\HandleImpersonation;
use App\Models\ImpersonationSession;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Auth\Events\Authenticated;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Sentry\Event as SentryEvent;
use Sentry\SentrySdk;
use Sentry\State\Scope;
use Tests\TestCase;
use function Sentry\configureScope;
/**
* Auth-scope Sentry tags + Log::withContext applied via the
* {@see \App\Listeners\Observability\AuthScopeContextListener} on every
* Authenticated event.
*
* Impersonation re-binding (target user_id/actor_type plus impersonation.*
* tags) is co-located in {@see HandleImpersonation} and exercised by
* the relevant tests in this file.
*/
final class AuthScopeContextListenerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
SentrySdk::getCurrentHub()->pushScope();
}
/**
* @return array<string, string>
*/
private function captureScopeTags(): array
{
$event = SentryEvent::createEvent();
configureScope(static function (Scope $scope) use ($event): void {
$scope->applyToEvent($event);
});
return $event->getTags();
}
private function captureScopeUserId(): ?string
{
$event = SentryEvent::createEvent();
configureScope(static function (Scope $scope) use ($event): void {
$scope->applyToEvent($event);
});
return $event->getUser()?->getId();
}
public function test_authenticated_event_tags_user_id(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
event(new Authenticated('web', $user));
$tags = $this->captureScopeTags();
$this->assertSame($user->id, $tags['user_id'] ?? null);
$this->assertSame($user->id, $this->captureScopeUserId());
}
public function test_authenticated_event_tags_actor_type_super_admin(): void
{
$user = User::factory()->create();
$user->assignRole('super_admin');
event(new Authenticated('web', $user));
$this->assertSame('super_admin', $this->captureScopeTags()['actor_type'] ?? null);
}
public function test_authenticated_event_tags_actor_type_organizer_admin(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
event(new Authenticated('web', $user));
$this->assertSame('organizer_admin', $this->captureScopeTags()['actor_type'] ?? null);
}
public function test_authenticated_event_tags_actor_type_org_member(): void
{
$user = User::factory()->create();
$user->assignRole('org_member');
event(new Authenticated('web', $user));
$this->assertSame('org_member', $this->captureScopeTags()['actor_type'] ?? null);
}
public function test_authenticated_event_does_not_set_impersonation_tags(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
event(new Authenticated('web', $user));
$tags = $this->captureScopeTags();
$this->assertArrayNotHasKey('impersonation.active', $tags);
$this->assertArrayNotHasKey('impersonation.impersonator_user_id', $tags);
}
public function test_handle_impersonation_rebinds_user_id_and_tags_impersonation_after_swap(): void
{
Organisation::factory()->create(); // tenancy fixture
$admin = User::factory()->create([
'mfa_enabled' => true,
'mfa_method' => \App\Enums\MfaMethod::TOTP->value,
'mfa_secret' => encrypt('JBSWY3DPEHPK3PXP'),
'mfa_confirmed_at' => now(),
]);
$admin->assignRole('super_admin');
$target = User::factory()->create();
$target->assignRole('org_admin');
// Authenticated event for the admin (Sanctum's normal flow).
event(new Authenticated('web', $admin));
$this->assertSame($admin->id, $this->captureScopeTags()['user_id'] ?? null);
$this->assertSame('super_admin', $this->captureScopeTags()['actor_type'] ?? null);
// Manufacture an impersonation session and run HandleImpersonation
// through to the post-swap re-binding logic.
$session = ImpersonationSession::create([
'admin_id' => $admin->id,
'target_user_id' => $target->id,
'reason' => 'test',
'mfa_method' => \App\Enums\MfaMethod::TOTP->value,
'ip_address' => '127.0.0.1',
'started_at' => now(),
'expires_at' => now()->addHour(),
]);
\Illuminate\Support\Facades\Cache::put(
'impersonation:'.$admin->id.':'.$target->id,
$session->id,
now()->addHour(),
);
$request = Request::create('http://localhost/api/v1/me/profile', 'GET');
$request->headers->set('X-Impersonate-User', $target->id);
$request->setUserResolver(static fn () => $admin);
$request->server->set('REMOTE_ADDR', '127.0.0.1');
$middleware = app(HandleImpersonation::class);
$middleware->handle($request, static fn () => response('ok'));
$tags = $this->captureScopeTags();
$this->assertSame($target->id, $tags['user_id'] ?? null);
$this->assertSame('organizer_admin', $tags['actor_type']);
$this->assertSame('true', $tags['impersonation.active'] ?? null);
$this->assertSame($admin->id, $tags['impersonation.impersonator_user_id'] ?? null);
}
}

View File

@@ -1,328 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Http\Middleware\BindSentryContext;
use App\Models\Event;
use App\Models\ImpersonationSession;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use RuntimeException;
use Sentry\Event as SentryEvent;
use Sentry\SentrySdk;
use Sentry\State\Scope;
use Tests\TestCase;
use function Sentry\configureScope;
final class BindSentryContextTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->resetSentryScope();
}
private function resetSentryScope(): void
{
SentrySdk::getCurrentHub()->pushScope();
}
/**
* Read the current Sentry scope by applying it to a fresh Event and
* harvesting the tags / user context.
*
* @return array{tags: array<string, string>, user: ?array<string, mixed>}
*/
private function captureScope(): array
{
$event = SentryEvent::createEvent();
configureScope(static function (Scope $scope) use ($event): void {
$scope->applyToEvent($event);
});
$userBag = $event->getUser();
return [
'tags' => $event->getTags(),
'user' => $userBag === null ? null : array_filter([
'id' => $userBag->getId(),
'username' => $userBag->getUsername(),
], static fn ($v) => $v !== null),
];
}
private function runMiddleware(Request $request): void
{
(new BindSentryContext)->handle($request, static fn (Request $req) => response('ok'));
}
private function makeAuthenticatedRequest(User $user, string $path = 'api/v1/me/profile'): Request
{
$request = Request::create('http://localhost/'.$path, 'GET');
$request->setUserResolver(static fn () => $user);
return $request;
}
public function test_authenticated_org_admin_request_tags_organisation_id(): void
{
$org = Organisation::factory()->create();
$user = User::factory()->create();
$org->users()->attach($user, ['role' => 'org_admin']);
$user->assignRole('org_admin');
$request = $this->makeAuthenticatedRequest($user, 'api/v1/organisations/'.$org->id.'/some-path');
$request->setRouteResolver(function () use ($org, $request) {
$route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}/some-path', static fn () => null);
$route->bind($request);
$route->setParameter('organisation', $org);
$route->name('organisations.test');
return $route;
});
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame($org->id, $scope['tags']['organisation_id'] ?? null);
}
public function test_authenticated_org_admin_request_tags_user_id(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
$request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile');
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame($user->id, $scope['tags']['user_id'] ?? null);
$this->assertSame($user->id, $scope['user']['id'] ?? null);
$this->assertSame($user->id, $scope['user']['username'] ?? null);
}
public function test_authenticated_org_admin_request_tags_actor_type_organizer_admin(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
$request = $this->makeAuthenticatedRequest($user);
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame('organizer_admin', $scope['tags']['actor_type'] ?? null);
}
public function test_super_admin_request_tags_actor_type_super_admin(): void
{
$user = User::factory()->create();
$user->assignRole('super_admin');
$request = $this->makeAuthenticatedRequest($user, 'api/v1/admin/organisations');
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame('super_admin', $scope['tags']['actor_type'] ?? null);
}
public function test_org_member_authenticated_user_tags_actor_type_org_member(): void
{
// Volunteer-ness is behaviour in Crewli (shift assignments), not
// identity — non-admin authenticated users resolve to ORG_MEMBER.
$user = User::factory()->create();
$user->assignRole('org_member');
$request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile');
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame('org_member', $scope['tags']['actor_type'] ?? null);
}
public function test_portal_token_request_tags_actor_type_portal_token(): void
{
$org = Organisation::factory()->create();
$event = Event::factory()->create(['organisation_id' => $org->id]);
$request = Request::create('http://localhost/api/v1/portal/me', 'GET');
$request->attributes->set('portal_context', 'artist');
$request->attributes->set('portal_event', $event);
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame('portal_token', $scope['tags']['actor_type'] ?? null);
}
public function test_portal_token_request_tags_organisation_id_from_token(): void
{
$org = Organisation::factory()->create();
$event = Event::factory()->create(['organisation_id' => $org->id]);
$request = Request::create('http://localhost/api/v1/portal/me', 'GET');
$request->attributes->set('portal_context', 'artist');
$request->attributes->set('portal_event', $event);
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame($org->id, $scope['tags']['organisation_id'] ?? null);
$this->assertSame($event->id, $scope['tags']['event_id'] ?? null);
}
public function test_unauthenticated_request_tags_actor_type_unauthenticated(): void
{
$request = Request::create('http://localhost/api/v1/auth/login', 'POST');
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame('unauthenticated', $scope['tags']['actor_type'] ?? null);
$this->assertArrayNotHasKey('user_id', $scope['tags']);
}
public function test_authenticated_request_to_tenant_scoped_route_without_org_throws_in_test_environment(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
$request = $this->makeAuthenticatedRequest($user, 'api/v1/organisations/missing/events');
// Synthesise a route that DECLARES {organisation} but has no
// bound parameter — the invariant must fire.
$route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}/events', static fn () => null);
$route->bind($request);
$request->setRouteResolver(static fn () => $route);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/lacks resolvable organisation_id/');
$this->runMiddleware($request);
}
public function test_authenticated_request_to_user_scoped_route_skips_invariant(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
// /me/profile route declares no {organisation} or {event} param —
// user-scoped, invariant skipped.
$request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile');
$route = new \Illuminate\Routing\Route(['GET'], 'me/profile', static fn () => null);
$route->bind($request);
$request->setRouteResolver(static fn () => $route);
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame('organizer_admin', $scope['tags']['actor_type'] ?? null);
}
public function test_impersonation_active_tag_when_session_active(): void
{
$admin = User::factory()->create();
$admin->assignRole('super_admin');
$target = User::factory()->create();
$target->assignRole('org_admin');
$request = $this->makeAuthenticatedRequest($target, 'api/v1/me/profile');
$request->attributes->set('impersonator', $admin);
$session = new ImpersonationSession;
$session->id = '01J0000000000000000000IMPS';
$session->admin_id = $admin->id;
$session->target_user_id = $target->id;
$request->attributes->set('impersonation_session', $session);
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame('true', $scope['tags']['impersonation.active'] ?? null);
}
public function test_impersonation_impersonator_user_id_tag_when_session_active(): void
{
$admin = User::factory()->create();
$admin->assignRole('super_admin');
$target = User::factory()->create();
$target->assignRole('org_admin');
$request = $this->makeAuthenticatedRequest($target, 'api/v1/me/profile');
$request->attributes->set('impersonator', $admin);
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame($admin->id, $scope['tags']['impersonation.impersonator_user_id'] ?? null);
}
public function test_impersonation_active_false_when_no_session(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
$request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile');
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame('false', $scope['tags']['impersonation.active'] ?? null);
$this->assertArrayNotHasKey('impersonation.impersonator_user_id', $scope['tags']);
}
public function test_route_name_tag_present(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
$request = $this->makeAuthenticatedRequest($user, 'api/v1/me/profile');
$route = new \Illuminate\Routing\Route(['GET'], 'me/profile', static fn () => null);
$route->name('me.profile');
$route->bind($request);
$request->setRouteResolver(static fn () => $route);
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame('me.profile', $scope['tags']['route_name'] ?? null);
}
public function test_http_method_tag_present(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
$request = Request::create('http://localhost/api/v1/me/profile', 'PATCH');
$request->setUserResolver(static fn () => $user);
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame('PATCH', $scope['tags']['http.method'] ?? null);
}
public function test_app_tag_is_api(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
$request = $this->makeAuthenticatedRequest($user);
$this->runMiddleware($request);
$scope = $this->captureScope();
$this->assertSame('api', $scope['tags']['app'] ?? null);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Http\Middleware\BindSentryRouteContext;
use Illuminate\Http\Request;
use Sentry\Event as SentryEvent;
use Sentry\SentrySdk;
use Sentry\State\Scope;
use Tests\TestCase;
use function Sentry\configureScope;
/**
* Route-scope tags (app, http.method, route_name) on every API request.
*
* Auth-scope assertions (user_id, actor_type, organisation_id, etc.) live
* in {@see AuthScopeContextListenerTest} that's the file to look at if
* you're changing what gets tagged on authenticated events.
*/
final class BindSentryRouteContextTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
SentrySdk::getCurrentHub()->pushScope();
}
/**
* @return array<string, string>
*/
private function captureScopeTags(): array
{
$event = SentryEvent::createEvent();
configureScope(static function (Scope $scope) use ($event): void {
$scope->applyToEvent($event);
});
return $event->getTags();
}
private function runMiddleware(Request $request): void
{
(new BindSentryRouteContext())->handle($request, static fn (Request $req) => response('ok'));
}
public function test_app_tag_is_api(): void
{
$request = Request::create('http://localhost/api/v1/_anything', 'GET');
$this->runMiddleware($request);
$this->assertSame('api', $this->captureScopeTags()['app'] ?? null);
}
public function test_http_method_tag_present(): void
{
$request = Request::create('http://localhost/api/v1/me/profile', 'PATCH');
$this->runMiddleware($request);
$this->assertSame('PATCH', $this->captureScopeTags()['http.method'] ?? null);
}
public function test_route_name_tag_present(): void
{
$request = Request::create('http://localhost/api/v1/me/profile', 'GET');
$route = new \Illuminate\Routing\Route(['GET'], 'me/profile', static fn () => null);
$route->name('me.profile');
$route->bind($request);
$request->setRouteResolver(static fn () => $route);
$this->runMiddleware($request);
$this->assertSame('me.profile', $this->captureScopeTags()['route_name'] ?? null);
}
public function test_route_name_tag_omitted_when_route_has_no_name(): void
{
$request = Request::create('http://localhost/api/v1/anonymous', 'GET');
$this->runMiddleware($request);
$this->assertArrayNotHasKey('route_name', $this->captureScopeTags());
}
}

View File

@@ -76,7 +76,7 @@ final class ExceptionReportingTest extends TestCase
// Test-only routes that exercise each branch of the
// ignore_exceptions / before_send / capture pipeline.
Route::middleware(['auth:sanctum', 'sentry.context'])->group(function (): void {
Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class])->group(function (): void {
Route::get('_obs_runtime', static fn () => throw new RuntimeException('boom'))
->name('test.obs.runtime');
Route::get('_obs_validation', static function (): never {