WS-7 Observability — closure #8

Merged
bert.hausmans merged 30 commits from feat/ws-7-observability into main 2026-05-07 22:49:31 +02:00
7 changed files with 575 additions and 9 deletions
Showing only changes of commit b1d5bcda76 - Show all commits

View File

@@ -15,9 +15,13 @@ use Illuminate\Http\Request;
* 1. Portal-token request PORTAL_TOKEN
* 2. Authenticated super_admin SUPER_ADMIN
* 3. Authenticated org_admin ORGANIZER_ADMIN
* 4. Authenticated volunteer (role match) VOLUNTEER
* 5. Other authenticated user ORG_MEMBER
* 6. None of the above UNAUTHENTICATED
* 4. Other authenticated user ORG_MEMBER (covers volunteers Crewli has
* no `volunteer` Spatie role today; volunteers join an organisation as
* org_member with org-pivot semantics. Promote to a dedicated case once
* the role model differentiates them at the user level.)
* 5. None of the above UNAUTHENTICATED
*
* The VOLUNTEER case is reserved for that future split.
*/
enum ActorType: string
{
@@ -46,10 +50,6 @@ enum ActorType: string
return self::ORGANIZER_ADMIN;
}
if ($user->hasRole('volunteer')) {
return self::VOLUNTEER;
}
return self::ORG_MEMBER;
}
}

View File

@@ -0,0 +1,195 @@
<?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);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Listeners\Observability;
use Illuminate\Queue\Events\JobProcessing;
use Sentry\State\Scope;
use function Sentry\configureScope;
/**
* Listener for {@see JobProcessing} that attaches the `queue.attempt` tag
* (RFC-WS-7 §3.6) to the active Sentry scope before the job runs. Default
* stack-trace grouping is preserved RFC §3.11 explicitly forbids
* per-attempt fingerprinting so retries that eventually succeed remain
* grouped with retries that always fail.
*/
final class TagJobAttemptOnSentry
{
public function handle(JobProcessing $event): void
{
$attempt = (string) $event->job->attempts();
configureScope(static function (Scope $scope) use ($attempt): void {
$scope->setTag('queue.attempt', $attempt);
});
}
}

View File

@@ -196,6 +196,14 @@ class AppServiceProvider extends ServiceProvider
ApplyBindingsOnFormSectionSubmitted::class,
);
// RFC-WS-7 §3.6 / §3.11 — tag captured Sentry events with the queue
// attempt count. Default stack-trace grouping is preserved (no
// per-attempt fingerprinting).
\Illuminate\Support\Facades\Event::listen(
\Illuminate\Queue\Events\JobProcessing::class,
\App\Listeners\Observability\TagJobAttemptOnSentry::class,
);
ResetPassword::createUrlUsing(function ($user, string $token) {
return config('crewli.portal_url').'/wachtwoord-resetten?token='.$token.'&email='.urlencode($user->email);
});

View File

@@ -36,6 +36,11 @@ return Application::configure(basePath: dirname(__DIR__))
'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 {

View File

@@ -120,7 +120,7 @@ Route::middleware('throttle:30,1')->group(function (): void {
// Platform Admin routes
Route::prefix('admin')
->middleware(['auth:sanctum', 'impersonation', 'role:super_admin'])
->middleware(['auth:sanctum', 'impersonation', 'sentry.context', 'role:super_admin'])
->name('admin.')
->group(function () {
// Organisations
@@ -155,7 +155,7 @@ Route::prefix('admin')
});
// Protected routes
Route::middleware(['auth:sanctum', 'impersonation'])->group(function () {
Route::middleware(['auth:sanctum', 'impersonation', 'sentry.context'])->group(function () {
// Impersonation (stop — accessible by impersonated user, not just super_admin)
Route::post('admin/stop-impersonation', [AdminImpersonationController::class, 'stop']);

View File

@@ -0,0 +1,329 @@
<?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
{
// Crewli has no `volunteer` Spatie role today; volunteers fall into
// org_member. The VOLUNTEER ActorType case is reserved for a future
// split — see ActorType::resolve() docblock.
$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);
}
}