WS-7 PR-2 commit 2. - app/Http/Middleware/BindSentryContext.php: sets RFC §3.6 tags on the active Sentry scope (app, http.method, route_name, actor_type, user_id, organisation_id, event_id, impersonation). Multi-tenant invariant: throws RuntimeException in local/testing when an auth request to a tenant-scoped route lacks organisation_id; logs a warning in production so the user flow still completes. - app/Listeners/Observability/TagJobAttemptOnSentry.php: tags queue.attempt on the scope from the JobProcessing event. Default stack-trace grouping preserved per §3.11. - ActorType: VOLUNTEER case reserved for a future role split. Current resolver maps non-admin authenticated users to ORG_MEMBER. - bootstrap/app.php: registers sentry.context alias. Applied inside auth:sanctum groups in routes/api.php so it runs after auth. - AppServiceProvider::boot registers the queue listener. Test count: 1507 to 1523. Larastan clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
196 lines
6.1 KiB
PHP
196 lines
6.1 KiB
PHP
<?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);
|
|
}
|
|
}
|