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);
|
||||
});
|
||||
|
||||
@@ -34,17 +34,18 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
// round-trip. Runs early so unauthenticated 4xx responses
|
||||
// still carry a request_id header.
|
||||
\App\Http\Middleware\BindRequestLogContext::class,
|
||||
// RFC-WS-7 §3.6 — route-scope Sentry tags (app/route_name/
|
||||
// http.method). Auth-scope tags (user_id/actor_type/
|
||||
// organisation_id/actor_scope/impersonation.*) bind in
|
||||
// AuthScopeContextListener on the Authenticated event,
|
||||
// not in middleware. See the listener for rationale.
|
||||
\App\Http\Middleware\BindSentryRouteContext::class,
|
||||
]);
|
||||
|
||||
$middleware->alias([
|
||||
'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class,
|
||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||
'impersonation' => \App\Http\Middleware\HandleImpersonation::class,
|
||||
// RFC-WS-7 §3.6 — applied inside auth:sanctum groups so it runs
|
||||
// after authentication and can read $request->user(). Cannot live
|
||||
// on the api group because route-level auth middleware runs after
|
||||
// group middleware in Laravel.
|
||||
'sentry.context' => \App\Http\Middleware\BindSentryContext::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
||||
@@ -120,7 +120,7 @@ Route::middleware('throttle:30,1')->group(function (): void {
|
||||
|
||||
// Platform Admin routes
|
||||
Route::prefix('admin')
|
||||
->middleware(['auth:sanctum', 'impersonation', 'sentry.context', 'role:super_admin'])
|
||||
->middleware(['auth:sanctum', 'impersonation', 'role:super_admin'])
|
||||
->name('admin.')
|
||||
->group(function () {
|
||||
// Organisations
|
||||
@@ -155,7 +155,7 @@ Route::prefix('admin')
|
||||
});
|
||||
|
||||
// Protected routes
|
||||
Route::middleware(['auth:sanctum', 'impersonation', 'sentry.context'])->group(function () {
|
||||
Route::middleware(['auth:sanctum', 'impersonation'])->group(function () {
|
||||
// Impersonation (stop — accessible by impersonated user, not just super_admin)
|
||||
Route::post('admin/stop-impersonation', [AdminImpersonationController::class, 'stop']);
|
||||
|
||||
|
||||
172
api/tests/Feature/Observability/AuthScopeContextListenerTest.php
Normal file
172
api/tests/Feature/Observability/AuthScopeContextListenerTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user