diff --git a/.claude-sync.conf b/.claude-sync.conf index 268a383f..762142c5 100644 --- a/.claude-sync.conf +++ b/.claude-sync.conf @@ -13,3 +13,8 @@ dev-docs/design-document.md dev-docs/UX_SPEC_FESTIVAL_HIERARCHY.md dev-docs/ARCH-BINDINGS.md dev-docs/ARCH-API-VALIDATION.md +dev-docs/RFC-WS-7-OBSERVABILITY.md +dev-docs/GLITCHTIP.md +dev-docs/ARCH-OBSERVABILITY.md +dev-docs/runbooks/observability-triage.md +dev-docs/runbooks/observability-erasure.md diff --git a/.gitignore b/.gitignore index 236a2c0c..0f4834fe 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,15 @@ docs/.vitepress/cache # Claude Project Knowledge sync output (regenerated by scripts/sync-claude-docs.sh) .claude-sync/ + +# Claude Code runtime state +.claude/*.lock + +# GlitchTip +docker/glitchtip/.env +backups/ + +# WS-7 RFC §3.5: Vite sourcemaps are uploaded to GlitchTip and stripped +# from dist/ before deploy. Defensive exclusion in case dist/ is ever +# committed by mistake (it's already covered by `dist/` above). +apps/app/dist/**/*.map diff --git a/Makefile b/Makefile index fffccbdb..31e895f3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help services services-stop api app docs migrate fresh db-shell test test-db-create schema-dump +.PHONY: help services services-stop services-glitchtip-status api app docs migrate fresh db-shell test test-db-create schema-dump # Colors GREEN := \033[0;32m @@ -6,6 +6,10 @@ YELLOW := \033[0;33m CYAN := \033[0;36m NC := \033[0m +# Compose files merged for local dev. Both files share one project so +# Mailpit (bm_mailpit) is reachable from the GlitchTip containers. +COMPOSE_FILES := -f docker-compose.yml -f docker-compose.glitchtip.yml + help: @echo "" @echo "$(GREEN)╔══════════════════════════════════════════════════════════════╗$(NC)" @@ -13,8 +17,9 @@ help: @echo "$(GREEN)╚══════════════════════════════════════════════════════════════╝$(NC)" @echo "" @echo " $(YELLOW)Services (Docker):$(NC)" - @echo " make services Start MySQL, Redis, Mailpit" - @echo " make services-stop Stop all Docker services" + @echo " make services Start MySQL, Redis, Mailpit, GlitchTip" + @echo " make services-stop Stop all Docker services" + @echo " make services-glitchtip-status Tail GlitchTip web container logs" @echo "" @echo " $(YELLOW)Development Servers:$(NC)" @echo " make api Laravel API → http://localhost:8000" @@ -34,21 +39,27 @@ help: services: @echo "$(GREEN)Starting Docker services...$(NC)" - @docker compose up -d + @docker compose $(COMPOSE_FILES) up -d @echo "" @echo "$(GREEN)Services:$(NC)" - @echo " $(CYAN)MySQL:$(NC) localhost:3306 (crewli / secret)" - @echo " $(CYAN)Redis:$(NC) localhost:6379" - @echo " $(CYAN)Mailpit:$(NC) http://localhost:8025" + @echo " $(CYAN)MySQL:$(NC) localhost:3306 (crewli / secret)" + @echo " $(CYAN)Redis:$(NC) localhost:6379" + @echo " $(CYAN)Mailpit:$(NC) http://localhost:8025" + @echo " $(CYAN)GlitchTip:$(NC) http://localhost:8200" @echo "" @echo "$(YELLOW)Waiting for MySQL...$(NC)" @until docker exec bm_mysql mysqladmin ping -h localhost -u root -proot --silent 2>/dev/null; do sleep 1; done @echo "$(GREEN)✓ Ready!$(NC)" + @echo "$(YELLOW)Note:$(NC) GlitchTip web takes ~60s on first boot (migrations)." + @echo " Tail logs with: $(CYAN)make services-glitchtip-status$(NC)" services-stop: - @docker compose down + @docker compose $(COMPOSE_FILES) down @echo "$(GREEN)✓ Services stopped$(NC)" +services-glitchtip-status: + @docker compose $(COMPOSE_FILES) logs -f glitchtip-web + api: @echo "$(GREEN)Starting Laravel API → http://localhost:8000$(NC)" @cd api && php artisan serve diff --git a/api/.env.example b/api/.env.example index c578a3ff..b5a67430 100644 --- a/api/.env.example +++ b/api/.env.example @@ -78,3 +78,14 @@ SANCTUM_STATEFUL_DOMAINS=localhost:5174,localhost:5175 # env-gate + this flag) keeps Telescope out even if one layer is # breached. See /dev-docs/TELESCOPE.md. TELESCOPE_ENABLED=false + +# Sentry / GlitchTip (RFC-WS-7 §3.3, §3.4). +# DSN routes events to the self-hosted GlitchTip project crewli-api. +# Empty = SDK no-op — leave blank in local development. Source the real +# value from the 1Password vault entry "Crewli / GlitchTip / DSNs" +# (key SENTRY_DSN_BACKEND) for staging / production. +SENTRY_DSN_BACKEND= +# Release identifier in the form crewli-api@. The deploy +# pipeline injects this per build; leave blank locally. Empty release +# means events are still captured but won't carry release context. +SENTRY_RELEASE= diff --git a/api/app/Enums/Observability/ActorType.php b/api/app/Enums/Observability/ActorType.php new file mode 100644 index 00000000..95804b05 --- /dev/null +++ b/api/app/Enums/Observability/ActorType.php @@ -0,0 +1,54 @@ +attributes->get('portal_context') !== null) { + return self::PORTAL_TOKEN; + } + + if (! $user instanceof User) { + return self::UNAUTHENTICATED; + } + + if ($user->hasRole('super_admin')) { + return self::SUPER_ADMIN; + } + + if ($user->hasRole('org_admin')) { + return self::ORGANIZER_ADMIN; + } + + return self::ORG_MEMBER; + } +} diff --git a/api/app/Http/Middleware/BindRequestLogContext.php b/api/app/Http/Middleware/BindRequestLogContext.php new file mode 100644 index 00000000..54514499 --- /dev/null +++ b/api/app/Http/Middleware/BindRequestLogContext.php @@ -0,0 +1,80 @@ +resolveRequestId($request); + $request->attributes->set('observability.request_id', $requestId); + + Log::withContext(array_filter([ + 'request_id' => $requestId, + 'organisation_id' => $this->resolveOrganisationId($request), + 'user_id' => $request->user()?->getAuthIdentifier(), + 'route' => $request->route()?->getName(), + ], static fn ($v) => $v !== null && $v !== '')); + + $response = $next($request); + + $response->headers->set('X-Request-Id', $requestId); + + return $response; + } + + private function resolveRequestId(Request $request): string + { + $supplied = $request->header('X-Request-Id'); + + if (is_string($supplied) && Str::isUlid($supplied)) { + return $supplied; + } + + return (string) Str::ulid(); + } + + 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; + } +} diff --git a/api/app/Http/Middleware/BindSentryRouteContext.php b/api/app/Http/Middleware/BindSentryRouteContext.php new file mode 100644 index 00000000..87b15327 --- /dev/null +++ b/api/app/Http/Middleware/BindSentryRouteContext.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/api/app/Http/Middleware/HandleImpersonation.php b/api/app/Http/Middleware/HandleImpersonation.php index e44644c9..0e998781 100644 --- a/api/app/Http/Middleware/HandleImpersonation.php +++ b/api/app/Http/Middleware/HandleImpersonation.php @@ -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); diff --git a/api/app/Listeners/Observability/AuthScopeContextListener.php b/api/app/Listeners/Observability/AuthScopeContextListener.php new file mode 100644 index 00000000..68fcff10 --- /dev/null +++ b/api/app/Listeners/Observability/AuthScopeContextListener.php @@ -0,0 +1,162 @@ +user; + + if (! $user instanceof User) { + return; + } + + $this->bindForUser($user); + } + + public function handleTokenAuthenticated(TokenAuthenticated $event): void + { + $tokenable = $event->token->tokenable ?? null; + + if (! $tokenable instanceof User) { + return; + } + + $this->bindForUser($tokenable); + } + + private function bindForUser(User $user): void + { + $request = request(); + $actorType = ActorType::resolve($user, $request); + [$organisationId, $actorScope] = $this->resolveTenantContext($user, $request); + + configureScope(static function (Scope $scope) use ($user, $actorType, $organisationId, $actorScope): void { + $scope->setUser([ + 'id' => $user->id, + 'username' => $user->id, // RFC §3.8: ULID, never email. + ]); + // Default baseline; HandleImpersonation middleware overschrijft + // naar 'true' + zet impersonation.impersonator_user_id wanneer + // impersonation actief is. RFC §3.6 vereist always-present + // binary signal voor betrouwbare filtering. + $scope->setTag('impersonation.active', 'false'); + $scope->setTag('user_id', $user->id); + $scope->setTag('actor_type', $actorType->value); + $scope->setTag('actor_scope', $actorScope); + + if ($organisationId !== null) { + $scope->setTag('organisation_id', $organisationId); + } + }); + + Log::withContext(array_filter([ + 'user_id' => $user->id, + 'organisation_id' => $organisationId, + 'actor_scope' => $actorScope, + ], static fn ($v) => $v !== null && $v !== '')); + } + + /** + * Resolves organisation_id and actor_scope per RFC §3.6 (refined after + * the PR-2 live smoke test). + * + * Resolution priority: + * 1. Route-scoped: {organisation} or {event} URI parameter resolves + * to an Organisation/Event → actor_scope=organisation. + * 2. Portal token: portal_event request attribute populated by + * PortalTokenMiddleware → actor_scope=organisation. + * 3. super_admin on admin.* route → actor_scope=platform; no + * organisation_id tag (forced current-org fallback would produce + * misleading attribution). + * 4. Default authenticated user → actor_scope=user, organisation_id + * is omitted because Crewli's User<->Organisation is many-to-many; + * no reliable single-org hint exists at user level. + * + * @return array{0: ?string, 1: string} [organisation_id|null, actor_scope] + */ + private function resolveTenantContext(User $user, ?Request $request): array + { + if ($request === null) { + return [null, 'user']; + } + + // 1a. Explicit {organisation} route parameter. + $route = $request->route(); + if ($route !== null) { + $orgParam = $route->parameter('organisation'); + if ($orgParam instanceof Organisation) { + return [$orgParam->id, 'organisation']; + } + if (is_string($orgParam) && $orgParam !== '') { + return [$orgParam, 'organisation']; + } + + // 1b. {event} parameter — derive org via event.organisation_id. + $eventParam = $route->parameter('event'); + if ($eventParam instanceof Event) { + return [$eventParam->organisation_id, 'organisation']; + } + } + + // 2. Portal token (artist/supplier/press flows). + $portalEvent = $request->attributes->get('portal_event'); + if ($portalEvent instanceof Event) { + return [$portalEvent->organisation_id, 'organisation']; + } + + // 3. super_admin on admin.* (Crewli's platform-admin route prefix). + if ($user->hasRole('super_admin') && $route !== null) { + $name = $route->getName(); + if (is_string($name) && str_starts_with($name, 'admin.')) { + return [null, 'platform']; + } + } + + // 4. Default user-scope: no org attribution (Crewli's User has no + // current_organisation_id; many-to-many membership precludes a + // reliable single-org hint). + return [null, 'user']; + } +} diff --git a/api/app/Listeners/Observability/TagJobAttemptOnSentry.php b/api/app/Listeners/Observability/TagJobAttemptOnSentry.php new file mode 100644 index 00000000..dac565bd --- /dev/null +++ b/api/app/Listeners/Observability/TagJobAttemptOnSentry.php @@ -0,0 +1,29 @@ +job->attempts(); + + configureScope(static function (Scope $scope) use ($attempt): void { + $scope->setTag('queue.attempt', $attempt); + }); + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 7ccaf536..f16031bd 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -196,6 +196,31 @@ 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, 'handle'], + ); + + // RFC-WS-7 §3.6 — auth-scope Sentry tags + Log::withContext on + // every successful authentication. Listens to two events: + // - Authenticated covers SessionGuard flows (login etc.). + // - TokenAuthenticated covers Sanctum bearer-token flows; this + // is Crewli's actual SPA auth path because CookieBearerToken + // middleware injects the cookie as an Authorization header. + // Without this, live HTTP events would carry no auth-scope + // tags even though the offline (event-dispatch) tests pass. + \Illuminate\Support\Facades\Event::listen( + \Illuminate\Auth\Events\Authenticated::class, + [\App\Listeners\Observability\AuthScopeContextListener::class, 'handle'], + ); + \Illuminate\Support\Facades\Event::listen( + \Laravel\Sanctum\Events\TokenAuthenticated::class, + [\App\Listeners\Observability\AuthScopeContextListener::class, 'handleTokenAuthenticated'], + ); + ResetPassword::createUrlUsing(function ($user, string $token) { return config('crewli.portal_url').'/wachtwoord-resetten?token='.$token.'&email='.urlencode($user->email); }); diff --git a/api/app/Services/Observability/SentryEventScrubber.php b/api/app/Services/Observability/SentryEventScrubber.php new file mode 100644 index 00000000..4e0f3c6c --- /dev/null +++ b/api/app/Services/Observability/SentryEventScrubber.php @@ -0,0 +1,137 @@ +exception instanceof HttpException && $hint->exception->getStatusCode() < 500) { + return null; + } + + $request = $event->getRequest(); + + if ($request !== []) { + $event->setRequest(array_merge($request, [ + 'data' => self::scrubBody($request['data'] ?? []), + 'headers' => self::scrubHeaders($request['headers'] ?? []), + 'query_string' => self::scrubQueryString($request['query_string'] ?? ''), + 'cookies' => self::SCRUBBED, + ])); + } + + return $event; + } + + /** + * @param mixed $data + * @return mixed + */ + private static function scrubBody($data, int $depth = 0) + { + if (! is_array($data)) { + return $data; + } + + if ($depth > self::MAX_DEPTH) { + return ['[max_depth]']; + } + + foreach ($data as $key => $value) { + if (is_string($key) && strtolower($key) === self::FORM_VALUES_KEY) { + $data[$key] = self::FORM_VALUES_REPLACEMENT; + + continue; + } + + if (is_string($key) && in_array(strtolower($key), self::SENSITIVE_BODY_KEYS, true)) { + $data[$key] = self::SCRUBBED; + + continue; + } + + if (is_array($value)) { + $data[$key] = self::scrubBody($value, $depth + 1); + } + } + + return $data; + } + + /** + * @param array|string $headers + * @return array|string + */ + private static function scrubHeaders($headers) + { + if (! is_array($headers)) { + return $headers; + } + + foreach (array_keys($headers) as $name) { + if (in_array(strtolower((string) $name), self::SENSITIVE_HEADERS, true)) { + $headers[$name] = self::SCRUBBED; + } + } + + return $headers; + } + + private static function scrubQueryString(string $queryString): string + { + if ($queryString === '') { + return ''; + } + + parse_str($queryString, $parsed); + + foreach ($parsed as $key => $value) { + if (is_string($key) && in_array(strtolower($key), self::SENSITIVE_QUERY_KEYS, true)) { + $parsed[$key] = self::SCRUBBED; + } + } + + return http_build_query($parsed); + } +} diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index 3194ba61..5d702be5 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -22,6 +22,14 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', apiPrefix: 'api/v1', ) + // Listener auto-discovery uitgeschakeld — observability listeners (en + // alle andere listeners) worden expliciet geregistreerd in + // AppServiceProvider::boot(). Reden: silent double-registration is + // mogelijk wanneer auto-discovery + explicit listen samen lopen, en + // expliciete registratie is grep-baar / IDE-navigeerbaar / direct + // zichtbaar bij code review. Onder enterprise-grade observability is + // impliciete discovery een stille fault-mode (RFC-WS-7 §3.6, OBS-8). + ->withEvents(discover: false) ->withMiddleware(function (Middleware $middleware): void { // API uses token-based auth, no CSRF needed @@ -30,6 +38,16 @@ return Application::configure(basePath: dirname(__DIR__)) // Read httpOnly auth cookie and inject as Authorization header (before Sanctum) $middleware->api(prepend: [ \App\Http\Middleware\CookieBearerToken::class, + // RFC-WS-7 §3.13 — structured logging context + X-Request-Id + // 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([ @@ -39,6 +57,16 @@ return Application::configure(basePath: dirname(__DIR__)) ]); }) ->withExceptions(function (Exceptions $exceptions): void { + // RFC-WS-7 §3.10 — bridge Laravel's exception handler into + // sentry-laravel so report($e) and Laravel's automatic + // report-before-render flow reach GlitchTip. sentry-laravel 4.x + // does NOT auto-register this; the README installation snippet + // requires the host application to wire it explicitly. + // Filtering happens downstream of this hook: ignore_exceptions in + // config/sentry.php drops Validation/Auth/AuthZ; SentryEventScrubber + // drops sub-500 HttpExceptions via the before_send hook. + \Sentry\Laravel\Integration::handles($exceptions); + // Public Form Builder standardised error envelope (S2c D6). $exceptions->render(function (\App\Exceptions\FormBuilder\PublicFormApiException $e, Request $request) { $body = [ diff --git a/api/composer.json b/api/composer.json index 87602794..2670c257 100644 --- a/api/composer.json +++ b/api/composer.json @@ -13,6 +13,7 @@ "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1", "pragmarx/google2fa": "^9.0", + "sentry/sentry-laravel": "^4.15", "spatie/laravel-activitylog": "^5.0", "spatie/laravel-medialibrary": "^11.21", "spatie/laravel-permission": "^7.2" diff --git a/api/composer.lock b/api/composer.lock index 34b7a1d2..d1d0a205 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "78c21fb00a5a2db68ad60afeb62382b9", + "content-hash": "48bb02e9c223eedc61e86fdf91a72552", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1539,6 +1539,66 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, { "name": "laravel/framework", "version": "v12.56.0", @@ -3225,6 +3285,84 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", @@ -4190,6 +4328,191 @@ }, "time": "2026-03-03T17:31:43+00:00" }, + { + "name": "sentry/sentry", + "version": "4.26.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-php.git", + "reference": "7597fd10c443929c62489d7cf38d1cb8341d6608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/7597fd10c443929c62489d7cf38d1cb8341d6608", + "reference": "7597fd10c443929c62489d7cf38d1cb8341d6608", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "jean85/pretty-package-versions": "^1.5|^2.0.4", + "php": "^7.2|^8.0", + "psr/log": "^1.0|^2.0|^3.0", + "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0" + }, + "conflict": { + "raven/raven": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.4", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "monolog/monolog": "^1.6|^2.0|^3.0", + "nyholm/psr7": "^1.8", + "open-telemetry/api": "^1.0", + "open-telemetry/exporter-otlp": "^1.0", + "open-telemetry/sdk": "^1.0", + "phpbench/phpbench": "^1.0", + "phpstan/phpstan": "^1.3", + "phpunit/phpunit": "^8.5.52|^9.6.34", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-worker": "^3.6" + }, + "suggest": { + "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.", + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Sentry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "PHP SDK for Sentry (http://sentry.io)", + "homepage": "http://sentry.io", + "keywords": [ + "crash-reporting", + "crash-reports", + "error-handler", + "error-monitoring", + "log", + "logging", + "profiling", + "sentry", + "tracing" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-php/issues", + "source": "https://github.com/getsentry/sentry-php/tree/4.26.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2026-04-30T12:50:22+00:00" + }, + { + "name": "sentry/sentry-laravel", + "version": "4.25.1", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-laravel.git", + "reference": "67efbdd74a752fcc1038676986b055a4df7d5084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/67efbdd74a752fcc1038676986b055a4df7d5084", + "reference": "67efbdd74a752fcc1038676986b055a4df7d5084", + "shasum": "" + }, + "require": { + "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0", + "nyholm/psr7": "^1.0", + "php": "^7.2 | ^8.0", + "sentry/sentry": "^4.23.0", + "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.11", + "guzzlehttp/guzzle": "^7.2", + "laravel/folio": "^1.1", + "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0", + "laravel/octane": "^2.15", + "laravel/pennant": "^1.0", + "livewire/livewire": "^2.0 | ^3.0 | ^4.0", + "mockery/mockery": "^1.3", + "orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Sentry": "Sentry\\Laravel\\Facade" + }, + "providers": [ + "Sentry\\Laravel\\ServiceProvider", + "Sentry\\Laravel\\Tracing\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-0": { + "Sentry\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "Laravel SDK for Sentry (https://sentry.io)", + "homepage": "https://sentry.io", + "keywords": [ + "crash-reporting", + "crash-reports", + "error-handler", + "error-monitoring", + "laravel", + "log", + "logging", + "profiling", + "sentry", + "tracing" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-laravel/issues", + "source": "https://github.com/getsentry/sentry-laravel/tree/4.25.1" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2026-05-05T09:22:46+00:00" + }, { "name": "spatie/image", "version": "3.9.4", @@ -5726,6 +6049,77 @@ ], "time": "2026-03-30T14:11:46+00:00" }, + { + "name": "symfony/options-resolver", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.34.0", @@ -6620,6 +7014,93 @@ ], "time": "2026-03-24T13:12:05+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/94facc221260c1d5f20e31ee43cd6c6a824b4a19", + "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^7.4|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/routing", "version": "v7.4.8", diff --git a/api/config/sentry.php b/api/config/sentry.php new file mode 100644 index 00000000..4e249727 --- /dev/null +++ b/api/config/sentry.php @@ -0,0 +1,159 @@ + env('SENTRY_DSN_BACKEND'), + + // @see https://spotlightjs.com/ + // 'spotlight' => env('SENTRY_SPOTLIGHT', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger + // 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')` + + // The release version of your application + // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) + 'release' => env('SENTRY_RELEASE'), + + // When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`) + 'environment' => env('SENTRY_ENVIRONMENT', env('APP_ENV')), + + // RFC-WS-7 §3.7 — stateless static method + array notation. + // Configuration is declarative (a reference, not executable logic); + // container-resolution per event would be overhead without value for + // stateless scrubbing, and stack traces show the class name instead of + // an anonymous closure frame. + 'before_send' => [\App\Services\Observability\SentryEventScrubber::class, 'scrub'], + + // Errors-only — RFC §2 amendment B explicitly excludes performance tracing. + // Force traces/profiles off regardless of env. + 'traces_sample_rate' => 0.0, + 'profiles_sample_rate' => 0.0, + + // Boundary with existing systems (RFC §3.10): exclude expected business + // outcomes. HTTPException is filtered further by status code in the + // scrubber (sub-500s are dropped there). + 'ignore_exceptions' => [ + \Illuminate\Validation\ValidationException::class, + \Illuminate\Auth\AuthenticationException::class, + \Illuminate\Auth\Access\AuthorizationException::class, + ], + + // Override the organization ID used for trace continuation checks. + 'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate + 'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'), + + // Only continue incoming traces when the organization IDs are compatible with this SDK instance. + 'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs + 'enable_logs' => env('SENTRY_ENABLE_LOGS', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold + 'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'), + + // The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel + 'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))), + + // RFC-WS-7 §3.7 point 5 / §3.8 — strip locals from stack traces and IP + // from user context. Hard-pinned, no env override. + 'send_default_pii' => false, + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions + 'ignore_transactions' => [ + // Ignore Laravel's default health URL + '/up', + ], + + // Breadcrumb specific configuration + 'breadcrumbs' => [ + // Capture Laravel logs as breadcrumbs + 'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true), + + // Capture Laravel cache events (hits, writes etc.) as breadcrumbs + 'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true), + + // Capture Livewire components like routes as breadcrumbs + 'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true), + + // Capture SQL queries as breadcrumbs + 'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true), + + // Capture SQL query bindings (parameters) in SQL query breadcrumbs + 'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false), + + // Capture queue job information as breadcrumbs + 'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true), + + // Capture command information as breadcrumbs + 'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true), + + // Capture HTTP client request information as breadcrumbs + 'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture send notifications as breadcrumbs + 'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), + ], + + // Performance monitoring specific configuration + 'tracing' => [ + // Trace queue jobs as their own transactions (this enables tracing for queue jobs) + 'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', false), + + // Capture queue jobs as spans when executed on the sync driver + 'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', false), + + // Capture SQL queries as spans + 'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true), + + // Capture SQL query bindings (parameters) in SQL query spans + 'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false), + + // Capture where the SQL query originated from on the SQL query spans + 'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true), + + // Define a threshold in milliseconds for SQL queries to resolve their origin + 'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100), + + // Capture views rendered as spans + 'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true), + + // Capture Livewire components as spans + 'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true), + + // Capture HTTP client requests as spans + 'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture Laravel cache events (hits, writes etc.) as spans + 'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true), + + // Capture Redis operations as spans (this enables Redis events in Laravel) + 'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false), + + // Capture where the Redis command originated from on the Redis command spans + 'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), + + // Capture send notifications as spans + 'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), + + // Enable tracing for requests without a matching route (404's) + 'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), + + // Configures if the performance trace should continue after the response has been sent to the user until the application terminates + // This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example + 'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true), + + // Enable the tracing integrations supplied by Sentry (recommended) + 'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true), + ], + +]; diff --git a/api/tests/Feature/Database/ActivityLogIndexesTest.php b/api/tests/Feature/Database/ActivityLogIndexesTest.php new file mode 100644 index 00000000..62c9885c --- /dev/null +++ b/api/tests/Feature/Database/ActivityLogIndexesTest.php @@ -0,0 +1,92 @@ +assertCompositeIndexExists( + table: 'activity_log', + columns: ['subject_type', 'subject_id'], + description: 'subject composite index (RFC-WS-7 §3.14 / D-06)', + ); + } + + public function test_causer_composite_index_exists(): void + { + $this->assertCompositeIndexExists( + table: 'activity_log', + columns: ['causer_type', 'causer_id'], + description: 'causer composite index (RFC-WS-7 §3.14 / D-06)', + ); + } + + /** + * @param list $columns + */ + private function assertCompositeIndexExists(string $table, array $columns, string $description): void + { + $database = config('database.connections.mysql.database'); + + $rows = DB::select( + 'SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME = ? + ORDER BY INDEX_NAME, SEQ_IN_INDEX', + [$database, $table], + ); + + $indexColumns = []; + foreach ($rows as $row) { + $indexColumns[$row->INDEX_NAME][(int) $row->SEQ_IN_INDEX] = $row->COLUMN_NAME; + } + + $found = false; + foreach ($indexColumns as $sequence) { + ksort($sequence); + if (array_values($sequence) === $columns) { + $found = true; + break; + } + } + + $this->assertTrue( + $found, + sprintf( + 'Expected composite index on %s(%s) — %s. Found indexes: %s', + $table, + implode(', ', $columns), + $description, + json_encode($indexColumns, JSON_PRETTY_PRINT), + ), + ); + } +} diff --git a/api/tests/Feature/Observability/AuthScopeBindingHttpFlowTest.php b/api/tests/Feature/Observability/AuthScopeBindingHttpFlowTest.php new file mode 100644 index 00000000..f501b4b2 --- /dev/null +++ b/api/tests/Feature/Observability/AuthScopeBindingHttpFlowTest.php @@ -0,0 +1,145 @@ +createToken() and passes Authorization: Bearer in the + * request — Sanctum::actingAs() short-circuits the Guard layer and would + * NOT detect the regression. + */ +final class AuthScopeBindingHttpFlowTest extends TestCase +{ + use RefreshDatabase; + + /** + * Captured events from the recording before_send hook. + * + * @var list + */ + private static array $captured = []; + + protected function setUp(): void + { + parent::setUp(); + $this->seed(RoleSeeder::class); + self::$captured = []; + + $clientBuilder = ClientBuilder::create([ + 'dsn' => 'https://test@localhost/1', + 'environment' => 'testing', + 'release' => 'crewli-api@test', + 'send_default_pii' => false, + 'traces_sample_rate' => 0.0, + 'profiles_sample_rate' => 0.0, + 'before_send' => static function (SentryEvent $event, ?EventHint $hint = null): ?SentryEvent { + self::$captured[] = ['event' => $event, 'hint' => $hint]; + + return null; + }, + ]); + SentrySdk::setCurrentHub(new Hub($clientBuilder->getClient())); + } + + /** + * @return array + */ + private function authHeader(User $user): array + { + $token = $user->createToken('regression-test')->plainTextToken; + + return ['Authorization' => 'Bearer '.$token]; + } + + public function test_authenticated_http_request_captures_auth_scope_tags_on_thrown_exception(): void + { + $user = User::factory()->create(); + $user->assignRole('super_admin'); + + Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class])->group(function (): void { + Route::get('_obs_authflow_throw', static fn () => throw new RuntimeException('regression-test')) + ->name('test.obs.authflow_throw'); + }); + + $response = $this->withHeaders($this->authHeader($user))->getJson('/_obs_authflow_throw'); + $response->assertStatus(500); + + $this->assertCount(1, self::$captured, 'Sentry event must be captured for thrown RuntimeException'); + $tags = self::$captured[0]['event']->getTags(); + + $this->assertSame($user->id, $tags['user_id'] ?? null, 'user_id tag missing on live HTTP flow'); + $this->assertSame('super_admin', $tags['actor_type'] ?? null, 'actor_type tag missing on live HTTP flow'); + $this->assertArrayHasKey('actor_scope', $tags, 'actor_scope tag missing on live HTTP flow'); + } + + public function test_authenticated_http_request_to_admin_route_tags_actor_scope_platform(): void + { + $user = User::factory()->create(); + $user->assignRole('super_admin'); + + Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class]) + ->name('admin.') + ->group(function (): void { + Route::get('_obs_admin_throw', static fn () => throw new RuntimeException('regression-test')) + ->name('platform_throw'); + }); + + $this->withHeaders($this->authHeader($user))->getJson('/_obs_admin_throw'); + + $tags = self::$captured[0]['event']->getTags(); + + $this->assertSame('platform', $tags['actor_scope'] ?? null, + 'super_admin on admin.* route must tag actor_scope=platform'); + $this->assertArrayNotHasKey('organisation_id', $tags, + 'organisation_id MUST be absent on platform-scoped events'); + } + + public function test_authenticated_http_request_to_organisation_route_tags_organisation_scope(): void + { + $org = Organisation::factory()->create(); + $user = User::factory()->create(); + $org->users()->attach($user, ['role' => 'org_admin']); + $user->assignRole('org_admin'); + + Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class])->group(function (): void { + Route::get('_obs_org_throw/{organisation}', static fn () => throw new RuntimeException('regression-test')) + ->name('test.obs.org_throw'); + }); + + $this->withHeaders($this->authHeader($user))->getJson('/_obs_org_throw/'.$org->id); + + $tags = self::$captured[0]['event']->getTags(); + + $this->assertSame('organisation', $tags['actor_scope'] ?? null); + $this->assertSame($org->id, $tags['organisation_id'] ?? null); + $this->assertTrue(Ulid::isValid($tags['organisation_id'])); + } +} diff --git a/api/tests/Feature/Observability/AuthScopeContextListenerTest.php b/api/tests/Feature/Observability/AuthScopeContextListenerTest.php new file mode 100644 index 00000000..548a5a9d --- /dev/null +++ b/api/tests/Feature/Observability/AuthScopeContextListenerTest.php @@ -0,0 +1,367 @@ +seed(RoleSeeder::class); + SentrySdk::getCurrentHub()->pushScope(); + } + + /** + * @return array + */ + 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_actor_scope_user_when_no_route_or_portal_context(): void + { + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('user', $tags['actor_scope'] ?? null); + $this->assertArrayNotHasKey('organisation_id', $tags); + } + + public function test_actor_scope_organisation_when_route_has_organisation_param(): void + { + $org = Organisation::factory()->create(); + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = Request::create('http://localhost/api/v1/organisations/'.$org->id.'/test', 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}/test', static fn () => null); + $route->bind($request); + $route->setParameter('organisation', $org); + $route->name('organisations.test'); + $request->setRouteResolver(static fn () => $route); + $this->app->instance('request', $request); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('organisation', $tags['actor_scope'] ?? null); + $this->assertSame($org->id, $tags['organisation_id'] ?? null); + $this->assertTrue(\Symfony\Component\Uid\Ulid::isValid($tags['organisation_id'])); + } + + public function test_actor_scope_organisation_when_route_has_event_param(): void + { + $org = Organisation::factory()->create(); + $event = \App\Models\Event::factory()->create(['organisation_id' => $org->id]); + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = Request::create('http://localhost/api/v1/events/'.$event->id, 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'events/{event}', static fn () => null); + $route->bind($request); + $route->setParameter('event', $event); + $route->name('events.show'); + $request->setRouteResolver(static fn () => $route); + $this->app->instance('request', $request); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('organisation', $tags['actor_scope'] ?? null); + $this->assertSame($org->id, $tags['organisation_id'] ?? null); + } + + public function test_actor_scope_organisation_when_portal_token_request(): void + { + $org = Organisation::factory()->create(); + $event = \App\Models\Event::factory()->create(['organisation_id' => $org->id]); + $user = User::factory()->create(); + $user->assignRole('org_member'); + + $request = Request::create('http://localhost/api/v1/portal/me', 'GET'); + $request->attributes->set('portal_context', 'artist'); + $request->attributes->set('portal_event', $event); + $this->app->instance('request', $request); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('organisation', $tags['actor_scope'] ?? null); + $this->assertSame($org->id, $tags['organisation_id'] ?? null); + } + + public function test_actor_scope_platform_for_super_admin_on_admin_route(): void + { + $user = User::factory()->create(); + $user->assignRole('super_admin'); + + $request = Request::create('http://localhost/api/v1/admin/users', 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'admin/users', static fn () => null); + $route->bind($request); + $route->name('admin.users.index'); + $request->setRouteResolver(static fn () => $route); + $this->app->instance('request', $request); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('platform', $tags['actor_scope'] ?? null); + $this->assertArrayNotHasKey('organisation_id', $tags); + } + + public function test_actor_scope_user_for_super_admin_on_non_admin_route(): void + { + $user = User::factory()->create(); + $user->assignRole('super_admin'); + + $request = Request::create('http://localhost/api/v1/me/profile', 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'me/profile', static fn () => null); + $route->bind($request); + $route->name('me.profile'); + $request->setRouteResolver(static fn () => $route); + $this->app->instance('request', $request); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('user', $tags['actor_scope'] ?? null); + $this->assertArrayNotHasKey('organisation_id', $tags); + } + + public function test_actor_scope_always_present_on_authenticated_event(): void + { + $user = User::factory()->create(); + $user->assignRole('org_member'); + + event(new Authenticated('web', $user)); + + $this->assertArrayHasKey('actor_scope', $this->captureScopeTags()); + } + + public function test_organisation_id_present_when_actor_scope_is_organisation(): void + { + $org = Organisation::factory()->create(); + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + $request = Request::create('http://localhost/api/v1/organisations/'.$org->id, 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}', static fn () => null); + $route->bind($request); + $route->setParameter('organisation', $org); + $route->name('organisations.show'); + $request->setRouteResolver(static fn () => $route); + $this->app->instance('request', $request); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('organisation', $tags['actor_scope']); + $this->assertArrayHasKey('organisation_id', $tags); + $this->assertTrue(\Symfony\Component\Uid\Ulid::isValid($tags['organisation_id'])); + } + + public function test_impersonation_active_default_false_for_non_impersonation_authenticated_event(): void + { + // RFC §3.6 binary signal invariant: impersonation.active MUST be + // present on every authenticated event. Listener seeds 'false'; + // HandleImpersonation overrides to 'true' when active. + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + event(new Authenticated('web', $user)); + + $tags = $this->captureScopeTags(); + $this->assertSame('false', $tags['impersonation.active'] ?? null); + $this->assertArrayNotHasKey('impersonation.impersonator_user_id', $tags); + } + + public function test_impersonation_active_default_false_across_every_actor_scope_branch(): void + { + // Hard invariant guard: every actor_scope branch in the listener + // must seed impersonation.active='false'. Future refactors that + // shortcut branches (e.g. early-return on platform) cannot silently + // drop the binary signal. + $org = Organisation::factory()->create(); + $superAdmin = User::factory()->create(); + $superAdmin->assignRole('super_admin'); + $orgAdmin = User::factory()->create(); + $orgAdmin->assignRole('org_admin'); + $orgMember = User::factory()->create(); + $orgMember->assignRole('org_member'); + + $cases = [ + 'user' => fn () => event(new Authenticated('web', $orgMember)), + 'organisation' => function () use ($org, $orgAdmin): void { + $request = Request::create('http://localhost/api/v1/organisations/'.$org->id, 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}', static fn () => null); + $route->bind($request); + $route->setParameter('organisation', $org); + $route->name('organisations.show'); + $request->setRouteResolver(static fn () => $route); + $this->app->instance('request', $request); + event(new Authenticated('web', $orgAdmin)); + }, + 'platform' => function () use ($superAdmin): void { + $request = Request::create('http://localhost/api/v1/admin/users', 'GET'); + $route = new \Illuminate\Routing\Route(['GET'], 'admin/users', static fn () => null); + $route->bind($request); + $route->name('admin.users.index'); + $request->setRouteResolver(static fn () => $route); + $this->app->instance('request', $request); + event(new Authenticated('web', $superAdmin)); + }, + ]; + + foreach ($cases as $label => $trigger) { + SentrySdk::getCurrentHub()->pushScope(); + $trigger(); + $tags = $this->captureScopeTags(); + $this->assertSame($label, $tags['actor_scope'] ?? null, "actor_scope mismatch for {$label} branch"); + $this->assertSame('false', $tags['impersonation.active'] ?? null, + "impersonation.active baseline missing for {$label} branch"); + } + } + + 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); + } +} diff --git a/api/tests/Feature/Observability/BindSentryRouteContextTest.php b/api/tests/Feature/Observability/BindSentryRouteContextTest.php new file mode 100644 index 00000000..2ff5112b --- /dev/null +++ b/api/tests/Feature/Observability/BindSentryRouteContextTest.php @@ -0,0 +1,89 @@ +pushScope(); + } + + /** + * @return array + */ + 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()); + } +} diff --git a/api/tests/Feature/Observability/EventListenerRegistrationTest.php b/api/tests/Feature/Observability/EventListenerRegistrationTest.php new file mode 100644 index 00000000..256bf3a8 --- /dev/null +++ b/api/tests/Feature/Observability/EventListenerRegistrationTest.php @@ -0,0 +1,167 @@ +withEvents(discover: false)`; this test fails if a future refactor + * re-enables it without updating the explicit registrations. + * 2. Listener-registratie verdwijnt door refactor van AppServiceProvider. + * 3. Class-string registratie ipv array-callable (verbergt method-binding + * in event:list output, en breekt voor listener-classes met meerdere + * methodes). + * + * Plus the always-present binary-tag invariant for impersonation.active. + * + * RFC-WS-7-OBSERVABILITY.md §3.6, BACKLOG OBS-8. + */ +final class EventListenerRegistrationTest extends TestCase +{ + use RefreshDatabase; + + public function test_authenticated_listener_registered_exactly_once(): void + { + $this->assertListenerRegisteredOnce( + Authenticated::class, + \App\Listeners\Observability\AuthScopeContextListener::class, + 'handle', + ); + } + + public function test_token_authenticated_listener_registered_exactly_once(): void + { + $this->assertListenerRegisteredOnce( + TokenAuthenticated::class, + \App\Listeners\Observability\AuthScopeContextListener::class, + 'handleTokenAuthenticated', + ); + } + + public function test_job_processing_tag_listener_registered_exactly_once(): void + { + $this->assertListenerRegisteredOnce( + JobProcessing::class, + \App\Listeners\Observability\TagJobAttemptOnSentry::class, + 'handle', + ); + } + + public function test_impersonation_active_tag_invariant_on_captured_events(): void + { + $this->seed(RoleSeeder::class); + + // Install a recording before_send hook so we can inspect the + // captured event's tags on the live HTTP path. Same pattern as + // AuthScopeBindingHttpFlowTest. + $captured = null; + $clientBuilder = ClientBuilder::create([ + 'dsn' => 'https://test@localhost/1', + 'environment' => 'testing', + 'release' => 'crewli-api@test', + 'send_default_pii' => false, + 'traces_sample_rate' => 0.0, + 'profiles_sample_rate' => 0.0, + 'before_send' => static function (SentryEvent $event, ?EventHint $hint = null) use (&$captured): ?SentryEvent { + $captured = $event; + + return null; + }, + ]); + SentrySdk::setCurrentHub(new Hub($clientBuilder->getClient())); + + $user = User::factory()->create(); + $user->assignRole('org_admin'); + + \Illuminate\Support\Facades\Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class]) + ->get('_obs_invariant_check', static fn () => throw new \RuntimeException('invariant')); + + $token = $user->createToken('regression')->plainTextToken; + $this->withHeaders(['Authorization' => 'Bearer '.$token])->getJson('/_obs_invariant_check'); + + $this->assertNotNull($captured, 'Sentry event must be captured'); + $tags = $captured->getTags(); + $this->assertArrayHasKey( + 'impersonation.active', + $tags, + 'impersonation.active MUST be present on every authenticated captured event (RFC §3.6 binary signal invariant).', + ); + $this->assertContains( + $tags['impersonation.active'], + ['true', 'false'], + 'impersonation.active value must be the binary string "true" or "false".', + ); + } + + /** + * Asserts exactly one registration for the given (event, listener, + * method) triple. Catches both zero registrations (refactor accidentally + * dropped the call) and multiple registrations (auto-discovery + + * explicit listen combined). + * + * Uses {@see Event::getRawListeners()} which returns the dispatcher's + * internal listeners array verbatim — each `Event::listen()` call + * appends one entry. Class-string and array-callable forms are both + * accepted as valid registration shapes. + */ + private function assertListenerRegisteredOnce( + string $eventClass, + string $listenerClass, + string $method, + ): void { + $rawListeners = Event::getRawListeners()[$eventClass] ?? []; + + $matches = 0; + foreach ($rawListeners as $listener) { + if (is_array($listener) && count($listener) === 2 && $listener[0] === $listenerClass && $listener[1] === $method) { + $matches++; + + continue; + } + if (is_string($listener)) { + if ($listener === $listenerClass.'@'.$method) { + $matches++; + + continue; + } + if ($listener === $listenerClass && $method === 'handle') { + $matches++; + } + } + } + + $this->assertSame( + 1, + $matches, + sprintf( + 'Expected exactly 1 registration for %s::%s on event %s, found %d. ' + .'Either auto-discovery is double-registering (OBS-8 regression — ' + .'check ->withEvents(discover: false) in bootstrap/app.php) or the ' + .'explicit Event::listen() call in AppServiceProvider::boot() is missing.', + $listenerClass, + $method, + $eventClass, + $matches, + ), + ); + } +} diff --git a/api/tests/Feature/Observability/ExceptionReportingTest.php b/api/tests/Feature/Observability/ExceptionReportingTest.php new file mode 100644 index 00000000..1c3fda9a --- /dev/null +++ b/api/tests/Feature/Observability/ExceptionReportingTest.php @@ -0,0 +1,155 @@ + + */ + private static array $captured = []; + + protected function setUp(): void + { + parent::setUp(); + $this->seed(RoleSeeder::class); + + self::$captured = []; + + // Wire a real Sentry client whose before_send records events into + // the static buffer and returns null (drops, never networked). + $clientBuilder = ClientBuilder::create([ + 'dsn' => 'https://test@localhost/1', + 'environment' => 'testing', + 'release' => 'crewli-api@test', + 'send_default_pii' => false, + 'traces_sample_rate' => 0.0, + 'profiles_sample_rate' => 0.0, + 'ignore_exceptions' => [ + ValidationException::class, + \Illuminate\Auth\AuthenticationException::class, + AuthorizationException::class, + ], + 'before_send' => static function (SentryEvent $event, ?EventHint $hint = null): ?SentryEvent { + self::$captured[] = ['event' => $event, 'hint' => $hint]; + + return null; + }, + ]); + + $hub = new Hub($clientBuilder->getClient()); + SentrySdk::setCurrentHub($hub); + + // Test-only routes that exercise each branch of the + // ignore_exceptions / before_send / capture pipeline. + 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 { + throw ValidationException::withMessages(['email' => 'required']); + })->name('test.obs.validation'); + Route::get('_obs_404', static fn () => throw new NotFoundHttpException('nope')) + ->name('test.obs.404'); + Route::get('_obs_403', static fn () => throw new AuthorizationException('denied')) + ->name('test.obs.403'); + }); + } + + private function actAsOrgAdmin(): void + { + $org = Organisation::factory()->create(); + $user = User::factory()->create(); + $org->users()->attach($user, ['role' => 'org_admin']); + $user->assignRole('org_admin'); + Sanctum::actingAs($user); + } + + public function test_runtime_exception_from_controller_is_captured(): void + { + $this->actAsOrgAdmin(); + + $this->getJson('/_obs_runtime')->assertStatus(500); + + $this->assertCount(1, self::$captured, 'expected exactly one captured event'); + $event = self::$captured[0]['event']; + $exceptions = $event->getExceptions(); + $this->assertNotEmpty($exceptions); + $this->assertSame(RuntimeException::class, $exceptions[0]->getType()); + $this->assertSame('boom', $exceptions[0]->getValue()); + } + + public function test_validation_exception_is_not_captured(): void + { + $this->actAsOrgAdmin(); + + $this->getJson('/_obs_validation')->assertStatus(422); + + $this->assertCount(0, self::$captured); + } + + public function test_not_found_http_exception_is_not_captured(): void + { + $this->actAsOrgAdmin(); + + $this->getJson('/_obs_404')->assertStatus(404); + + $this->assertCount(0, self::$captured); + } + + public function test_authorization_exception_is_not_captured(): void + { + $this->actAsOrgAdmin(); + + $this->getJson('/_obs_403')->assertStatus(403); + + $this->assertCount(0, self::$captured); + } + + public function test_runtime_exception_carries_request_context(): void + { + $this->actAsOrgAdmin(); + + $this->getJson('/_obs_runtime')->assertStatus(500); + + $this->assertCount(1, self::$captured); + $tags = self::$captured[0]['event']->getTags(); + // BindSentryRouteContext should have set these on the scope + // before the exception fired in the controller. + $this->assertSame('api', $tags['app'] ?? null); + $this->assertSame('GET', $tags['http.method'] ?? null); + } +} diff --git a/api/tests/Feature/Observability/PiiScrubbingTest.php b/api/tests/Feature/Observability/PiiScrubbingTest.php new file mode 100644 index 00000000..7207518e --- /dev/null +++ b/api/tests/Feature/Observability/PiiScrubbingTest.php @@ -0,0 +1,238 @@ + $request + */ + private function scrubEventWithRequest(array $request, ?EventHint $hint = null): ?Event + { + $event = Event::createEvent(); + $event->setRequest($request); + + return SentryEventScrubber::scrub($event, $hint); + } + + public function test_password_in_request_body_is_scrubbed(): void + { + $event = $this->scrubEventWithRequest([ + 'data' => ['email' => 'a@b.test', 'password' => 'sup3rsecret!'], + ]); + + $this->assertSame('[scrubbed]', $event->getRequest()['data']['password']); + $this->assertSame('a@b.test', $event->getRequest()['data']['email']); + } + + public function test_password_confirmation_is_scrubbed(): void + { + $event = $this->scrubEventWithRequest([ + 'data' => ['password_confirmation' => 'p@ss', 'current_password' => 'oldpass'], + ]); + + $this->assertSame('[scrubbed]', $event->getRequest()['data']['password_confirmation']); + $this->assertSame('[scrubbed]', $event->getRequest()['data']['current_password']); + } + + public function test_authorization_header_is_scrubbed(): void + { + $event = $this->scrubEventWithRequest([ + 'headers' => ['Authorization' => 'Bearer abc.def.ghi'], + ]); + + $this->assertSame('[scrubbed]', $event->getRequest()['headers']['Authorization']); + } + + public function test_cookie_header_is_scrubbed(): void + { + $event = $this->scrubEventWithRequest([ + 'headers' => ['Cookie' => 'crewli_session=abcd'], + ]); + + $this->assertSame('[scrubbed]', $event->getRequest()['headers']['Cookie']); + } + + public function test_x_impersonation_token_header_is_scrubbed(): void + { + $event = $this->scrubEventWithRequest([ + 'headers' => ['X-Impersonation-Token' => 'imp_token_xyz'], + ]); + + $this->assertSame('[scrubbed]', $event->getRequest()['headers']['X-Impersonation-Token']); + } + + public function test_form_values_payload_is_replaced_wholesale(): void + { + $event = $this->scrubEventWithRequest([ + 'data' => [ + 'form_values' => [ + 'email' => 'sensitive@example.com', + 'dietary' => 'vegan', + 'phone' => '+31612345678', + ], + ], + ]); + + $data = $event->getRequest()['data']; + $this->assertSame('[scrubbed_form_values]', $data['form_values']); + $serialised = json_encode($data, JSON_THROW_ON_ERROR); + $this->assertStringNotContainsString('sensitive@example.com', $serialised); + $this->assertStringNotContainsString('vegan', $serialised); + $this->assertStringNotContainsString('+31612345678', $serialised); + } + + public function test_token_query_string_is_scrubbed(): void + { + $event = $this->scrubEventWithRequest([ + 'query_string' => 'token=abc123&keep=me', + ]); + + $qs = $event->getRequest()['query_string']; + $this->assertStringContainsString('token=%5Bscrubbed%5D', $qs); + $this->assertStringContainsString('keep=me', $qs); + } + + public function test_api_key_query_string_is_scrubbed(): void + { + $event = $this->scrubEventWithRequest([ + 'query_string' => 'api_key=xyz&page=2', + ]); + + $qs = $event->getRequest()['query_string']; + $this->assertStringContainsString('api_key=%5Bscrubbed%5D', $qs); + $this->assertStringContainsString('page=2', $qs); + } + + public function test_iban_in_nested_body_is_scrubbed(): void + { + $event = $this->scrubEventWithRequest([ + 'data' => [ + 'profile' => [ + 'address' => [ + 'iban' => 'NL91ABNA0417164300', + 'street' => 'Damrak 1', + ], + ], + ], + ]); + + $address = $event->getRequest()['data']['profile']['address']; + $this->assertSame('[scrubbed]', $address['iban']); + $this->assertSame('Damrak 1', $address['street']); + } + + public function test_bsn_in_nested_body_is_scrubbed(): void + { + $event = $this->scrubEventWithRequest([ + 'data' => ['kyc' => ['passport_number' => 'NX1234567', 'bsn' => '123456789']], + ]); + + $kyc = $event->getRequest()['data']['kyc']; + $this->assertSame('[scrubbed]', $kyc['passport_number']); + $this->assertSame('[scrubbed]', $kyc['bsn']); + } + + public function test_send_default_pii_is_false(): void + { + $this->assertFalse(config('sentry.send_default_pii')); + } + + public function test_validation_exception_is_in_ignore_list(): void + { + $this->assertContains(ValidationException::class, config('sentry.ignore_exceptions')); + } + + public function test_authentication_exception_is_in_ignore_list(): void + { + $this->assertContains(AuthenticationException::class, config('sentry.ignore_exceptions')); + } + + public function test_authorization_exception_is_in_ignore_list(): void + { + $this->assertContains(AuthorizationException::class, config('sentry.ignore_exceptions')); + } + + public function test_http_exception_404_is_dropped_by_scrubber(): void + { + $event = Event::createEvent(); + $hint = EventHint::fromArray(['exception' => new NotFoundHttpException]); + + $this->assertNull(SentryEventScrubber::scrub($event, $hint)); + } + + public function test_http_exception_500_is_captured(): void + { + $event = Event::createEvent(); + $hint = EventHint::fromArray(['exception' => new HttpException(500, 'boom')]); + + $this->assertNotNull(SentryEventScrubber::scrub($event, $hint)); + } + + public function test_throwable_from_controller_is_captured(): void + { + $event = Event::createEvent(); + $hint = EventHint::fromArray(['exception' => new RuntimeException('programmer error')]); + + $this->assertNotNull(SentryEventScrubber::scrub($event, $hint)); + } + + public function test_form_values_replacement_blocks_attempts_to_smuggle_pii(): void + { + // form_values is a wholesale replace — even if the payload is deeply + // nested, the entire branch is wiped so individual keys cannot leak. + $event = $this->scrubEventWithRequest([ + 'data' => [ + 'submission' => [ + 'form_values' => [ + 'medical' => 'celiac', + 'children' => [ + ['name' => 'Bobby', 'allergy' => 'peanuts'], + ], + ], + ], + ], + ]); + + $serialised = json_encode($event->getRequest()['data'], JSON_THROW_ON_ERROR); + $this->assertStringContainsString('[scrubbed_form_values]', $serialised); + $this->assertStringNotContainsString('celiac', $serialised); + $this->assertStringNotContainsString('Bobby', $serialised); + $this->assertStringNotContainsString('peanuts', $serialised); + } + + public function test_cookies_request_field_is_replaced(): void + { + $event = $this->scrubEventWithRequest([ + 'cookies' => ['SESSION' => 'abcd', 'tracking' => 'xyz'], + ]); + + $this->assertSame('[scrubbed]', $event->getRequest()['cookies']); + } + + public function test_max_depth_guard_prevents_unbounded_recursion(): void + { + $deep = ['v' => 'leaf']; + for ($i = 0; $i < 15; $i++) { + $deep = ['nest' => $deep]; + } + + $event = $this->scrubEventWithRequest(['data' => $deep]); + + $serialised = json_encode($event->getRequest()['data'], JSON_THROW_ON_ERROR); + $this->assertStringContainsString('[max_depth]', $serialised); + } +} diff --git a/api/tests/Feature/Observability/RequestIdRoundTripTest.php b/api/tests/Feature/Observability/RequestIdRoundTripTest.php new file mode 100644 index 00000000..379cf42f --- /dev/null +++ b/api/tests/Feature/Observability/RequestIdRoundTripTest.php @@ -0,0 +1,161 @@ +|null + */ + private static ?array $capturedLogContext = null; + + protected function setUp(): void + { + parent::setUp(); + $this->seed(RoleSeeder::class); + self::$capturedLogContext = null; + + // Spy on Log::withContext so we can assert the structured payload. + Log::swap(new class extends \Illuminate\Log\LogManager + { + public function __construct() {} + + /** + * @param array $context + */ + public function withContext(array $context = []): \Illuminate\Log\Logger + { + RequestIdRoundTripTest::recordContext($context); + + return $this->driver(); + } + + public function driver($driver = null): \Illuminate\Log\Logger + { + return new \Illuminate\Log\Logger(new \Psr\Log\NullLogger); + } + + /** + * @param array $parameters + */ + public function __call($method, $parameters) + { + return null; + } + }); + } + + /** + * @param array $context + */ + public static function recordContext(array $context): void + { + self::$capturedLogContext = array_merge(self::$capturedLogContext ?? [], $context); + } + + public function test_response_has_x_request_id_header_when_none_supplied(): void + { + $response = $this->getJson('/api/v1/'); + + $response->assertOk(); + $requestId = $response->headers->get('X-Request-Id'); + $this->assertNotNull($requestId); + $this->assertMatchesRegularExpression(self::VALID_ULID_PATTERN, $requestId); + } + + public function test_response_has_x_request_id_header_when_client_supplied_valid_ulid(): void + { + $supplied = (string) Str::ulid(); + + $response = $this->getJson('/api/v1/', ['X-Request-Id' => $supplied]); + + $this->assertSame($supplied, $response->headers->get('X-Request-Id')); + } + + public function test_server_generates_when_client_supplies_invalid_ulid(): void + { + $response = $this->getJson('/api/v1/', ['X-Request-Id' => 'not-a-ulid-at-all']); + + $emitted = $response->headers->get('X-Request-Id'); + $this->assertNotSame('not-a-ulid-at-all', $emitted); + $this->assertMatchesRegularExpression(self::VALID_ULID_PATTERN, $emitted); + } + + public function test_server_generates_when_client_supplies_empty_string(): void + { + $response = $this->getJson('/api/v1/', ['X-Request-Id' => '']); + + $emitted = $response->headers->get('X-Request-Id'); + $this->assertNotNull($emitted); + $this->assertMatchesRegularExpression(self::VALID_ULID_PATTERN, $emitted); + } + + public function test_log_context_has_request_id(): void + { + $supplied = (string) Str::ulid(); + $this->getJson('/api/v1/', ['X-Request-Id' => $supplied]); + + $this->assertSame($supplied, self::$capturedLogContext['request_id'] ?? null); + } + + public function test_log_context_has_user_id_and_org_when_authenticated_organisation_route(): void + { + $org = Organisation::factory()->create(); + $user = User::factory()->create(); + $org->users()->attach($user, ['role' => 'org_admin']); + $user->assignRole('org_admin'); + + Sanctum::actingAs($user); + + $this->getJson('/api/v1/organisations/'.$org->id.'/dashboard-stats'); + + $this->assertSame($org->id, self::$capturedLogContext['organisation_id'] ?? null); + $this->assertSame($user->id, self::$capturedLogContext['user_id'] ?? null); + } + + public function test_log_context_route_matches_named_route(): void + { + $this->getJson('/api/v1/'); + + // The health-check route at /api/v1/ has no name; expectation is + // simply that the key is absent (filtered out for null) rather + // than carrying a misleading default. + $this->assertArrayNotHasKey('route', self::$capturedLogContext ?? []); + } + + public function test_unauthenticated_request_still_gets_request_id(): void + { + // Hitting an authenticated route unauthenticated yields 401 — but + // the request_id middleware still runs. + $response = $this->getJson('/api/v1/auth/me'); + + $response->assertStatus(401); + $this->assertNotNull($response->headers->get('X-Request-Id')); + } + + public function test_request_id_is_valid_ulid_format(): void + { + $response = $this->getJson('/api/v1/'); + + $emitted = $response->headers->get('X-Request-Id'); + $this->assertSame(26, strlen((string) $emitted)); + $this->assertTrue(Str::isUlid((string) $emitted)); + } +} diff --git a/api/tests/Feature/Security/CspConnectsToObservabilityTest.php b/api/tests/Feature/Security/CspConnectsToObservabilityTest.php new file mode 100644 index 00000000..eb569899 --- /dev/null +++ b/api/tests/Feature/Security/CspConnectsToObservabilityTest.php @@ -0,0 +1,109 @@ +get(). Caught a real PR-3 regression: frontend SDK + * emitted events correctly, but Crewli's strict CSP blocked egress at + * the browser. Without this guard, the bug would resurface every time + * a CSP refactor happens. + */ +final class CspConnectsToObservabilityTest extends TestCase +{ + private const DEV_GLITCHTIP_HOST = 'http://localhost:8200'; + + private const PROD_GLITCHTIP_HOST = 'https://monitoring.hausdesign.nl'; + + public function test_dev_meta_csp_whitelists_localhost_glitchtip(): void + { + $html = file_get_contents(base_path('../apps/app/index.html')); + $this->assertNotFalse($html, 'apps/app/index.html must be readable'); + + $csp = $this->extractMetaCsp($html); + $this->assertNotNull($csp, 'apps/app/index.html must declare a meta CSP'); + + $connectSrc = $this->extractDirective($csp, 'connect-src'); + $this->assertNotNull($connectSrc, 'meta CSP must define connect-src'); + + $this->assertStringContainsString( + self::DEV_GLITCHTIP_HOST, + $connectSrc, + sprintf( + 'Dev meta CSP connect-src must whitelist %s for the local GlitchTip stack (RFC-WS-7 §3.5). ' + .'Without it the browser blocks @sentry/vue events with: "Refused to connect because it ' + .'violates the following Content Security Policy directive: connect-src ...". ' + .'Found connect-src: %s', + self::DEV_GLITCHTIP_HOST, + $connectSrc, + ), + ); + } + + public function test_prod_nginx_csp_whitelists_monitoring_host(): void + { + $conf = file_get_contents(base_path('../deploy/nginx/csp-spa.conf')); + $this->assertNotFalse($conf, 'deploy/nginx/csp-spa.conf must be readable'); + + $matches = []; + $found = preg_match_all('/^\s*add_header\s+Content-Security-Policy(?:-Report-Only)?\s+"([^"]+)"\s+always;/m', $conf, $matches); + $this->assertNotEmpty($found, 'csp-spa.conf must contain at least one add_header Content-Security-Policy directive'); + + // Every uncommented add_header line must include the GlitchTip host + // in connect-src. Multiple lines exist (Report-Only + enforce); both + // need the whitelist or a refactor that flips between them silently + // breaks observability. + foreach ($matches[1] as $cspValue) { + $connectSrc = $this->extractDirective($cspValue, 'connect-src'); + $this->assertNotNull($connectSrc, "csp-spa.conf CSP must define connect-src. Got: {$cspValue}"); + $this->assertStringContainsString( + self::PROD_GLITCHTIP_HOST, + $connectSrc, + sprintf( + 'Prod nginx CSP connect-src must whitelist %s (RFC-WS-7 §3.5). Found: %s', + self::PROD_GLITCHTIP_HOST, + $connectSrc, + ), + ); + } + } + + private function extractMetaCsp(string $html): ?string + { + if (preg_match('/`. Injected at +# build-time by deploy.sh; leave blank locally. +VITE_SENTRY_RELEASE= diff --git a/apps/app/env.d.ts b/apps/app/env.d.ts index 15d88625..534cff15 100644 --- a/apps/app/env.d.ts +++ b/apps/app/env.d.ts @@ -4,6 +4,12 @@ interface ImportMetaEnv { readonly VITE_API_URL: string readonly VITE_APP_NAME: string readonly VITE_PORTAL_URL: string + // RFC-WS-7 §3.3 — empty DSN = SDK no-op. Production gets the crewli-app + // DSN from 1Password vault. + readonly VITE_SENTRY_DSN_FRONTEND?: string + // RFC-WS-7 §3.4 — `crewli-app@`, injected at build-time by + // deploy.sh. Optional during local dev. + readonly VITE_SENTRY_RELEASE?: string } interface ImportMeta { diff --git a/apps/app/index.html b/apps/app/index.html index 12ef6848..58873c5b 100644 --- a/apps/app/index.html +++ b/apps/app/index.html @@ -7,9 +7,12 @@ Crewli — Organizer - + + content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' http://localhost:8000 ws://localhost:5174 http://localhost:8200; form-action 'self'; base-uri 'self'"> diff --git a/apps/app/package.json b/apps/app/package.json index f7e67855..2786095c 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -20,6 +20,7 @@ "@casl/ability": "6.7.3", "@casl/vue": "2.2.2", "@floating-ui/dom": "1.6.8", + "@sentry/vue": "10.52.0", "@sindresorhus/is": "7.1.0", "@tanstack/vue-query": "^5.95.2", "@tiptap/extension-highlight": "^2.27.1", diff --git a/apps/app/pnpm-lock.yaml b/apps/app/pnpm-lock.yaml index 64077762..89377565 100644 --- a/apps/app/pnpm-lock.yaml +++ b/apps/app/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@floating-ui/dom': specifier: 1.6.8 version: 1.6.8 + '@sentry/vue': + specifier: 10.52.0 + version: 10.52.0(pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) '@sindresorhus/is': specifier: 7.1.0 version: 7.1.0 @@ -1233,6 +1236,43 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sentry-internal/browser-utils@10.52.0': + resolution: {integrity: sha512-x/yEPZdpH6NGQeoeQnV9tj8reAH8twNttiltGZl2o8Rk7sQeUfe7E8yuYP2XbJ2RqyZK5qRS3COrNyMPzf6KFA==} + engines: {node: '>=18'} + + '@sentry-internal/feedback@10.52.0': + resolution: {integrity: sha512-5kAn1W8ZvCuHtEHXpq6iRkUMdNCilwww+YxaN2yofVrCivAbB3Ha5JJUMqmWOPW0pC27zGYmoJMIDvG+PczUxA==} + engines: {node: '>=18'} + + '@sentry-internal/replay-canvas@10.52.0': + resolution: {integrity: sha512-BI5ie4dxPuUJ344CXVSnAxY1xZCbghglPSCIlTOYODpR9so9yo5IZh+Mwspt0oWsUMaxWJiQSNYlbPWi7WDavg==} + engines: {node: '>=18'} + + '@sentry-internal/replay@10.52.0': + resolution: {integrity: sha512-diywyuc/H7VTUR+W5ryVmLF+0X4UP1OskMqb6V8RSAvJHcj2JmIm7uP+Fc6ACTno+b6AUShwT/L4xVXzO6X9Cw==} + engines: {node: '>=18'} + + '@sentry/browser@10.52.0': + resolution: {integrity: sha512-ijL9jN86oXwXQWbwhPlEb70ODJSEmjxQEQdnZkC4gDWbjswcwvRsVJPYk+1xl2ir2iZixRIHipVxDcLwian35g==} + engines: {node: '>=18'} + + '@sentry/core@10.52.0': + resolution: {integrity: sha512-VA/kAqLhkMnRWY2RXdBLyTemR9D4m7MVRy/gyapoq9yvllVPx9WXbvKgnMP2LQp7mFgT/oLFvw58aQKaYTGn3A==} + engines: {node: '>=18'} + + '@sentry/vue@10.52.0': + resolution: {integrity: sha512-6MHYKXGQz39yFJ27HzNYGWJtmwDhEwp7EvCm6cJPBlXQNbYOoNTDrzq4TuI0cLJzyAW7mIQ+k4n4iMpa6EbfaA==} + engines: {node: '>=18'} + peerDependencies: + '@tanstack/vue-router': ^1.64.0 + pinia: 2.x || 3.x + vue: 2.x || 3.x + peerDependenciesMeta: + '@tanstack/vue-router': + optional: true + pinia: + optional: true + '@shikijs/core@1.29.2': resolution: {integrity: sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==} @@ -6203,6 +6243,42 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@sentry-internal/browser-utils@10.52.0': + dependencies: + '@sentry/core': 10.52.0 + + '@sentry-internal/feedback@10.52.0': + dependencies: + '@sentry/core': 10.52.0 + + '@sentry-internal/replay-canvas@10.52.0': + dependencies: + '@sentry-internal/replay': 10.52.0 + '@sentry/core': 10.52.0 + + '@sentry-internal/replay@10.52.0': + dependencies: + '@sentry-internal/browser-utils': 10.52.0 + '@sentry/core': 10.52.0 + + '@sentry/browser@10.52.0': + dependencies: + '@sentry-internal/browser-utils': 10.52.0 + '@sentry-internal/feedback': 10.52.0 + '@sentry-internal/replay': 10.52.0 + '@sentry-internal/replay-canvas': 10.52.0 + '@sentry/core': 10.52.0 + + '@sentry/core@10.52.0': {} + + '@sentry/vue@10.52.0(pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': + dependencies: + '@sentry/browser': 10.52.0 + '@sentry/core': 10.52.0 + vue: 3.5.22(typescript@5.9.3) + optionalDependencies: + pinia: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)) + '@shikijs/core@1.29.2': dependencies: '@shikijs/engine-javascript': 1.29.2 diff --git a/apps/app/src/main.ts b/apps/app/src/main.ts index 902a514a..ff544415 100644 --- a/apps/app/src/main.ts +++ b/apps/app/src/main.ts @@ -1,6 +1,8 @@ import { createApp } from 'vue' import { VueQueryPlugin } from '@tanstack/vue-query' import { queryClientConfig } from '@/lib/query-client' +import { initSentry, installContextBinding } from '@/observability' +import { router } from '@/plugins/1.router' import App from '@/App.vue' import { registerPlugins } from '@core/utils/plugins' @@ -12,9 +14,24 @@ import '@styles/styles.scss' // Create vue app const app = createApp(App) -// Register plugins +// RFC-WS-7 — Sentry init runs before plugin registration so the SDK can +// hook Vue's errorHandler before any plugin or component initialises. +// Empty DSN = SDK no-op (mirrors backend behaviour, RFC §3.3). +initSentry({ + app, + router, + dsn: import.meta.env.VITE_SENTRY_DSN_FRONTEND ?? '', + release: import.meta.env.VITE_SENTRY_RELEASE ?? '', + environment: import.meta.env.MODE, +}) + +// Register plugins (router, pinia, vuetify, …). registerPlugins(app) +// Bind auth-scope tags per route navigation. Must run after pinia is set +// up by registerPlugins (the guard reads useAuthStore / useOrganisationStore). +installContextBinding(router) + app.use(VueQueryPlugin, queryClientConfig) // Mount vue app diff --git a/apps/app/src/observability/__tests__/contextBinding.spec.ts b/apps/app/src/observability/__tests__/contextBinding.spec.ts new file mode 100644 index 00000000..a66b70a1 --- /dev/null +++ b/apps/app/src/observability/__tests__/contextBinding.spec.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' +import { createPinia, setActivePinia } from 'pinia' + +// Capture every setTag/setUser call into a per-test buffer so assertions +// can match against the FINAL bound state after the guard runs. +const calls: Array<{ method: 'setTag'; key: string; value: unknown } | { method: 'setUser'; user: unknown } | { method: 'clear' }> = [] + +vi.mock('@sentry/vue', () => { + const scope = { + setTag: (key: string, value: unknown) => { + calls.push({ method: 'setTag', key, value }) + }, + setUser: (user: unknown) => { + calls.push({ method: 'setUser', user }) + }, + clear: () => { + calls.push({ method: 'clear' }) + }, + } + + return { + getCurrentScope: () => scope, + } +}) + +// Override the global vue-router auto-mock from tests/setup.ts because we +// need real router behaviour here. +vi.mock('vue-router', async () => await vi.importActual('vue-router')) + +const authState = { + user: null as null | { id: string }, + isAuthenticated: false, + isSuperAdmin: false, + appRoles: [] as string[], +} + +const orgState = { + activeOrganisationId: null as string | null, +} + +vi.mock('@/stores/useAuthStore', () => ({ + useAuthStore: () => authState, +})) + +vi.mock('@/stores/useOrganisationStore', () => ({ + useOrganisationStore: () => orgState, +})) + +const { installContextBinding } = await import('../contextBinding') + +function makeRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + // Authenticated organizer routes + { path: '/dashboard', name: 'dashboard', component: { template: '
' } }, + + // Platform admin + { path: '/platform/users', name: 'platform.users', component: { template: '
' } }, + + // Authenticated portal (volunteer) + { path: '/portal/profiel', name: 'portal-profiel', meta: { context: 'portal' }, component: { template: '
' } }, + + // Token-based portal (artist advance) + { path: '/portal/advance/:token', name: 'artist-advance', meta: { public: true, context: 'portal' }, component: { template: '
' } }, + + // Public organizer page (login) + { path: '/login', name: 'login', meta: { public: true }, component: { template: '
' } }, + ], + }) +} + +function reset() { + calls.length = 0 + authState.user = null + authState.isAuthenticated = false + authState.isSuperAdmin = false + authState.appRoles = [] + orgState.activeOrganisationId = null +} + +function lastTag(key: string): unknown { + for (let i = calls.length - 1; i >= 0; i--) { + const call = calls[i] + if (call.method === 'setTag' && call.key === key) + return call.value + } + + return undefined +} + +function lastUser(): unknown { + for (let i = calls.length - 1; i >= 0; i--) { + const call = calls[i] + if (call.method === 'setUser') + return call.user + } + + return undefined +} + +function tagWasSet(key: string): boolean { + return calls.some(c => c.method === 'setTag' && c.key === key) +} + +describe('installContextBinding', () => { + beforeEach(() => { + setActivePinia(createPinia()) + reset() + }) + + async function navigate(router: ReturnType, path: string): Promise { + installContextBinding(router) + await router.push(path) + await router.isReady() + } + + it('portal-token route sets actor_scope=portal and does not set user identity', async () => { + authState.user = { id: '01ULIDUSER' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin'] + + const router = makeRouter() + + await navigate(router, '/portal/advance/sometoken') + + expect(lastTag('actor_scope')).toBe('portal') + expect(lastTag('actor_type')).toBe('portal_token') + expect(tagWasSet('user_id')).toBe(false) + + // setUser is called on scope.clear() reset path; the listener does NOT + // call setUser explicitly for portal-token zone (no auth context). + expect(lastUser()).toBeUndefined() + }) + + it('platform route with super_admin sets actor_scope=platform', async () => { + authState.user = { id: '01ULIDADMIN' } + authState.isAuthenticated = true + authState.isSuperAdmin = true + authState.appRoles = ['super_admin'] + + const router = makeRouter() + + await navigate(router, '/platform/users') + + expect(lastTag('actor_scope')).toBe('platform') + expect(lastTag('actor_type')).toBe('super_admin') + expect(lastTag('user_id')).toBe('01ULIDADMIN') + }) + + it('platform route without super_admin does NOT set actor_scope=platform', async () => { + authState.user = { id: '01ULIDORG' } + authState.isAuthenticated = true + authState.isSuperAdmin = false + authState.appRoles = ['org_admin'] + + const router = makeRouter() + + await navigate(router, '/platform/users') + + expect(lastTag('actor_scope')).not.toBe('platform') + }) + + it('organizer route with active organisation tags actor_scope=organisation + organisation_id', async () => { + authState.user = { id: '01ULIDORG' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin'] + orgState.activeOrganisationId = '01ULIDORGID' + + const router = makeRouter() + + await navigate(router, '/dashboard') + + expect(lastTag('actor_scope')).toBe('organisation') + expect(lastTag('organisation_id')).toBe('01ULIDORGID') + expect(lastTag('actor_type')).toBe('organizer_admin') + }) + + it('organizer route without active organisation tags actor_scope=user', async () => { + authState.user = { id: '01ULIDORG' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin'] + orgState.activeOrganisationId = null + + const router = makeRouter() + + await navigate(router, '/dashboard') + + expect(lastTag('actor_scope')).toBe('user') + expect(tagWasSet('organisation_id')).toBe(false) + }) + + it('unauthenticated request to public route sets actor_scope=anonymous', async () => { + const router = makeRouter() + + await navigate(router, '/login') + + expect(lastTag('actor_scope')).toBe('anonymous') + expect(lastTag('actor_type')).toBe('unauthenticated') + expect(tagWasSet('user_id')).toBe(false) + }) + + it('actor_type maps from role hierarchy: super_admin > org_admin > org_member', async () => { + authState.user = { id: '01ULIDORG' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin', 'org_member'] + orgState.activeOrganisationId = '01ULIDORGID' + + const router = makeRouter() + + await navigate(router, '/dashboard') + + expect(lastTag('actor_type')).toBe('organizer_admin') + }) + + it('user_id and username are both ULID, never email (RFC §3.8)', async () => { + authState.user = { id: '01ULIDUSERX' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin'] + orgState.activeOrganisationId = '01ULIDORGID' + + const router = makeRouter() + + await navigate(router, '/dashboard') + + const user = lastUser() as { id: string; username: string } + + expect(user.id).toBe('01ULIDUSERX') + expect(user.username).toBe('01ULIDUSERX') + }) + + it('route_name is always present', async () => { + const router = makeRouter() + + await navigate(router, '/login') + + expect(lastTag('route_name')).toBe('login') + }) + + it('app tag is always app on every navigation', async () => { + authState.user = { id: '01ULIDORG' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin'] + + const router = makeRouter() + + await navigate(router, '/dashboard') + + expect(lastTag('app')).toBe('app') + }) + + it('cross-zone leak guard: navigating from organizer to portal-token clears user', async () => { + authState.user = { id: '01ULIDORG' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin'] + orgState.activeOrganisationId = '01ULIDORGID' + + const router = makeRouter() + + await navigate(router, '/dashboard') + + // After the guard runs once, calls buffer holds organizer-zone state. + // Now navigate to portal-token zone; expect a clear() then no setUser. + const beforeCount = calls.length + + await router.push('/portal/advance/abc') + + const newCalls = calls.slice(beforeCount) + + expect(newCalls.some(c => c.method === 'clear')).toBe(true) + expect(newCalls.filter(c => c.method === 'setUser').length).toBe(0) + expect(lastTag('actor_scope')).toBe('portal') + }) +}) diff --git a/apps/app/src/observability/__tests__/scrubber.spec.ts b/apps/app/src/observability/__tests__/scrubber.spec.ts new file mode 100644 index 00000000..5aa5c597 --- /dev/null +++ b/apps/app/src/observability/__tests__/scrubber.spec.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest' +import type { ErrorEvent as SentryErrorEvent } from '@sentry/vue' +import { scrubEvent } from '../scrubber' + +function makeEvent(overrides: Partial = {}): SentryErrorEvent { + return { + type: undefined, + ...overrides, + } as SentryErrorEvent +} + +describe('scrubEvent', () => { + describe('request body', () => { + it('scrubs password in request body', () => { + const event = makeEvent({ request: { data: { email: 'a@b.test', password: 'p@ss' } } }) + const result = scrubEvent(event) as SentryErrorEvent + const data = result.request?.data as Record + + expect(data.password).toBe('[scrubbed]') + expect(data.email).toBe('a@b.test') + }) + + it('scrubs password_confirmation', () => { + const event = makeEvent({ request: { data: { password_confirmation: 'x', current_password: 'y' } } }) + const result = scrubEvent(event) as SentryErrorEvent + const data = result.request?.data as Record + + expect(data.password_confirmation).toBe('[scrubbed]') + expect(data.current_password).toBe('[scrubbed]') + }) + + it('scrubs every SENSITIVE_BODY_KEY at top level', () => { + const allKeys = { + password: 'a', + token: 'b', + api_key: 'c', + secret: 'd', + webhook_secret: 'e', + dsn: 'f', + signature: 'g', + authorization: 'h', + cookie: 'i', + bearer: 'j', + iban: 'k', + bic: 'l', + passport_number: 'm', + bsn: 'n', + } + + const event = makeEvent({ request: { data: allKeys } }) + const result = scrubEvent(event) as SentryErrorEvent + const data = result.request?.data as Record + for (const key of Object.keys(allKeys)) + expect(data[key]).toBe('[scrubbed]') + }) + + it('scrubs sensitive keys at nested levels (recursive)', () => { + const event = makeEvent({ + request: { + data: { profile: { address: { iban: 'NL91...', street: 'Damrak 1' } } }, + }, + }) + + const result = scrubEvent(event) as SentryErrorEvent + const data = result.request?.data as Record>> + + expect(data.profile.address.iban).toBe('[scrubbed]') + expect(data.profile.address.street).toBe('Damrak 1') + }) + + it('replaces form_values payload wholesale', () => { + const event = makeEvent({ + request: { data: { form_values: { email: 'x@y.com', dietary: 'vegan' } } }, + }) + + const result = scrubEvent(event) as SentryErrorEvent + const data = result.request?.data as Record + + expect(data.form_values).toBe('[scrubbed_form_values]') + + const serialised = JSON.stringify(data) + + expect(serialised).not.toContain('x@y.com') + expect(serialised).not.toContain('vegan') + }) + + it('does not leak email or other non-sensitive keys', () => { + const event = makeEvent({ request: { data: { email: 'x@y.com', name: 'Bob' } } }) + const result = scrubEvent(event) as SentryErrorEvent + const data = result.request?.data as Record + + expect(data.email).toBe('x@y.com') + expect(data.name).toBe('Bob') + }) + + it('hits max_depth guard at depth 11', () => { + let deep: unknown = { v: 'leaf' } + for (let i = 0; i < 15; i++) + deep = { nest: deep } + + const event = makeEvent({ request: { data: deep as Record } }) + const result = scrubEvent(event) as SentryErrorEvent + const serialised = JSON.stringify(result.request?.data) + + expect(serialised).toContain('[max_depth]') + }) + + it('returns event unchanged when request is undefined', () => { + const event = makeEvent() + const result = scrubEvent(event) + + expect(result).toBe(event) + expect(result?.request).toBeUndefined() + }) + + it('returns event unchanged when request.data is null', () => { + const event = makeEvent({ request: { data: null as unknown as undefined } }) + const result = scrubEvent(event) as SentryErrorEvent + + expect(result.request?.data).toBeNull() + }) + }) + + describe('headers', () => { + it('scrubs Authorization header', () => { + const event = makeEvent({ request: { headers: { Authorization: 'Bearer abc' } } }) + const result = scrubEvent(event) as SentryErrorEvent + + expect((result.request?.headers as Record).Authorization).toBe('[scrubbed]') + }) + + it('scrubs Cookie header', () => { + const event = makeEvent({ request: { headers: { Cookie: 'sess=abc' } } }) + const result = scrubEvent(event) as SentryErrorEvent + + expect((result.request?.headers as Record).Cookie).toBe('[scrubbed]') + }) + + it('scrubs case-insensitive header names', () => { + const event = makeEvent({ request: { headers: { 'X-API-KEY': 'k', 'x-impersonation-token': 't' } } }) + const result = scrubEvent(event) as SentryErrorEvent + const headers = result.request?.headers as Record + + expect(headers['X-API-KEY']).toBe('[scrubbed]') + expect(headers['x-impersonation-token']).toBe('[scrubbed]') + }) + }) + + describe('query string', () => { + it('scrubs token query param', () => { + const event = makeEvent({ request: { query_string: 'token=abc&keep=me' } }) + const result = scrubEvent(event) as SentryErrorEvent + const qs = result.request?.query_string as string + + expect(qs).toContain('token=%5Bscrubbed%5D') + expect(qs).toContain('keep=me') + }) + + it('scrubs api_key query param', () => { + const event = makeEvent({ request: { query_string: 'api_key=xyz&page=2' } }) + const result = scrubEvent(event) as SentryErrorEvent + const qs = result.request?.query_string as string + + expect(qs).toContain('api_key=%5Bscrubbed%5D') + expect(qs).toContain('page=2') + }) + + it('preserves non-sensitive query params unchanged', () => { + const event = makeEvent({ request: { query_string: 'page=2&sort=name' } }) + const result = scrubEvent(event) as SentryErrorEvent + const qs = result.request?.query_string as string + + expect(qs).toBe('page=2&sort=name') + }) + }) + + describe('cookies + storage', () => { + it('scrubs cookies wholesale', () => { + const event = makeEvent({ request: { cookies: { sess: 'abc', tracking: 'xyz' } } }) + const result = scrubEvent(event) as SentryErrorEvent + + expect(result.request?.cookies).toEqual({ scrubbed: '[scrubbed]' }) + }) + + it('strips storage context if present (RFC §3.7 frontend point 2)', () => { + const event = makeEvent({ + contexts: { storage: { local: { token: 'abc' } } } as unknown as SentryErrorEvent['contexts'], + }) + + const result = scrubEvent(event) as SentryErrorEvent + + expect(result.contexts).not.toHaveProperty('storage') + }) + + it('strips user.cookies if present (RFC §3.7 frontend point 1)', () => { + const event = makeEvent({ + user: { id: 'ulid', cookies: 'session=...' } as unknown as SentryErrorEvent['user'], + }) + + const result = scrubEvent(event) as SentryErrorEvent + + expect(result.user).not.toHaveProperty('cookies') + expect(result.user?.id).toBe('ulid') + }) + }) +}) diff --git a/apps/app/src/observability/contextBinding.ts b/apps/app/src/observability/contextBinding.ts new file mode 100644 index 00000000..ef72cec0 --- /dev/null +++ b/apps/app/src/observability/contextBinding.ts @@ -0,0 +1,106 @@ +import * as Sentry from '@sentry/vue' +import type { RouteLocationNormalized, Router } from 'vue-router' +import { useAuthStore } from '@/stores/useAuthStore' +import { useOrganisationStore } from '@/stores/useOrganisationStore' + +/** + * Installs a Vue Router beforeEach guard that binds Sentry scope tags per + * RFC-WS-7 §3.6. Frontend-equivalent of the backend AuthScopeContextListener. + * + * Three zones: + * - Token-based portal (route.meta.public === true && context === 'portal'): + * actor_scope=portal, no user_id/username — RFC §3.6 explicit. Stricter + * scrubbing applies via the {@link scrubEvent} hook regardless of zone. + * - Platform admin (path /platform/* with super_admin role): actor_scope= + * platform, full user context. No organisation_id (forced fallback would + * misattribute platform-scoped events). + * - Organizer (everything else, authenticated): actor_scope=organisation + * when an active organisation is selected; otherwise actor_scope=user. + * - Unauthenticated public pages (login, password reset): actor_scope= + * anonymous. + * + * Crewli's auth-store API differs from the RFC's speculative shape: + * - User identity: useAuthStore().user (User | null) + * - Role list: useAuthStore().appRoles (string[]) + * - Active organisation: useOrganisationStore().activeOrganisationId + * The guard reads these stores directly; pinia must be initialised before + * the first navigation, which is satisfied by registerPlugins() running + * before app.mount() in main.ts. + */ +export function installContextBinding(router: Router): void { + router.beforeEach(to => { + bindScope(to) + }) +} + +function bindScope(route: RouteLocationNormalized): void { + Sentry.getCurrentScope().clear() + + const scope = Sentry.getCurrentScope() + + // Always-present route-scope tags. Frontend never has http.method on a + // route navigation; that tag belongs to per-request fetch instrumentation + // which sentry-vue auto-attaches to fetch breadcrumbs. + scope.setTag('app', 'app') + scope.setTag('route_name', String(route.name ?? 'unnamed')) + + // Token-based portal flow (artist advance, public form fill). RFC §3.6: + // strict mode, no user_id, no username. The backend portal token already + // resolves the organisation via the matching artist/event row, so a + // captured frontend event correlates via request_id. + if (route.meta.public === true && route.meta.context === 'portal') { + scope.setTag('actor_scope', 'portal') + scope.setTag('actor_type', 'portal_token') + + return + } + + // Other public routes (login, forgot-password, register) — anonymous. + const auth = useAuthStore() + if (!auth.isAuthenticated || auth.user === null) { + scope.setTag('actor_scope', 'anonymous') + scope.setTag('actor_type', 'unauthenticated') + + return + } + + // Authenticated zones — bind user identity (RFC §3.8: ULID, never email). + scope.setUser({ + id: String(auth.user.id), + username: String(auth.user.id), + }) + scope.setTag('user_id', String(auth.user.id)) + + // Platform admin scope: super_admin on /platform/* routes. + if (route.path.startsWith('/platform') && auth.isSuperAdmin) { + scope.setTag('actor_scope', 'platform') + scope.setTag('actor_type', 'super_admin') + + return + } + + // Organizer scope. Tag actor_scope=organisation when an active org is + // selected (organisation_id from useOrganisationStore mirrors backend + // route-param resolution). Otherwise actor_scope=user — Crewli's + // many-to-many user-org model has no reliable single-org hint without + // the active selection. + const org = useOrganisationStore() + if (org.activeOrganisationId !== null && org.activeOrganisationId !== '') { + scope.setTag('actor_scope', 'organisation') + scope.setTag('organisation_id', String(org.activeOrganisationId)) + } + else { + scope.setTag('actor_scope', 'user') + } + + scope.setTag('actor_type', resolveActorType(auth.appRoles)) +} + +function resolveActorType(roles: readonly string[]): string { + if (roles.includes('super_admin')) + return 'super_admin' + if (roles.includes('org_admin')) + return 'organizer_admin' + + return 'org_member' +} diff --git a/apps/app/src/observability/index.ts b/apps/app/src/observability/index.ts new file mode 100644 index 00000000..d80a3a9f --- /dev/null +++ b/apps/app/src/observability/index.ts @@ -0,0 +1,4 @@ +export { installContextBinding } from './contextBinding' +export { scrubEvent } from './scrubber' +export { initSentry } from './sentry' +export type { SentryInitOptions } from './sentry' diff --git a/apps/app/src/observability/scrubber.ts b/apps/app/src/observability/scrubber.ts new file mode 100644 index 00000000..c9fdf38e --- /dev/null +++ b/apps/app/src/observability/scrubber.ts @@ -0,0 +1,131 @@ +import type { EventHint, ErrorEvent as SentryErrorEvent } from '@sentry/vue' + +const SENSITIVE_BODY_KEYS = [ + 'password', + 'password_confirmation', + 'current_password', + 'token', + 'api_key', + 'secret', + 'webhook_secret', + 'dsn', + 'signature', + 'authorization', + 'cookie', + 'bearer', + 'iban', + 'bic', + 'passport_number', + 'bsn', +] as const + +const SENSITIVE_HEADERS = [ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-impersonation-token', +] as const + +const SENSITIVE_QUERY_KEYS = ['token', 'api_key'] as const + +const SCRUBBED = '[scrubbed]' +const FORM_VALUES_REPLACEMENT = '[scrubbed_form_values]' +const MAX_DEPTH = 10 + +/** + * RFC-WS-7 §3.7 frontend block: scrubs PII from outgoing Sentry events. + * + * Mirrors the backend SentryEventScrubber semantics so a captured request + * looks the same regardless of which side originated the event. Adds + * frontend-specific scrubs (cookies via document.cookie exposure, + * localStorage/sessionStorage never in event context). + */ +export function scrubEvent(event: SentryErrorEvent, _hint?: EventHint): SentryErrorEvent | null { + if (event.request) { + if (event.request.data !== undefined && event.request.data !== null && typeof event.request.data === 'object') + event.request.data = scrubBody(event.request.data, 0) + + if (event.request.headers && typeof event.request.headers === 'object') + event.request.headers = scrubHeaders(event.request.headers as Record) + + if (typeof event.request.query_string === 'string' && event.request.query_string !== '') + event.request.query_string = scrubQueryString(event.request.query_string) + + // RFC §3.7 frontend point 1: cookies wholesale. Sentry types the field + // as Record; replacing with a single sentinel key keeps + // the structure shape while removing any real values. + if (event.request.cookies !== undefined) + event.request.cookies = { scrubbed: SCRUBBED } + } + + // RFC §3.7 frontend point 2: localStorage / sessionStorage never in event + // context. Sentry doesn't add these by default but defensively strip. + if (event.contexts && 'storage' in event.contexts) + delete (event.contexts as Record).storage + + // RFC §3.7 frontend point 1: scrub document.cookie if Sentry's + // BrowserSession integration injected it under user. + if (event.user && 'cookies' in event.user) + delete (event.user as Record).cookies + + return event +} + +function scrubBody(data: unknown, depth: number): unknown { + if (depth > MAX_DEPTH) + return ['[max_depth]'] + + if (Array.isArray(data)) + return data.map(item => scrubBody(item, depth + 1)) + + if (data === null || typeof data !== 'object') + return data + + const result: Record = {} + for (const [key, value] of Object.entries(data as Record)) { + const lowerKey = key.toLowerCase() + + if (lowerKey === 'form_values') { + result[key] = FORM_VALUES_REPLACEMENT + continue + } + + if ((SENSITIVE_BODY_KEYS as readonly string[]).includes(lowerKey)) { + result[key] = SCRUBBED + continue + } + + if (typeof value === 'object' && value !== null) { + result[key] = scrubBody(value, depth + 1) + continue + } + + result[key] = value + } + + return result +} + +function scrubHeaders(headers: Record): Record { + const result: Record = {} + for (const [name, value] of Object.entries(headers)) { + if ((SENSITIVE_HEADERS as readonly string[]).includes(name.toLowerCase())) + result[name] = SCRUBBED + + else + result[name] = value + } + + return result +} + +function scrubQueryString(queryString: string): string { + const params = new URLSearchParams(queryString) + for (const key of Array.from(params.keys())) { + if ((SENSITIVE_QUERY_KEYS as readonly string[]).includes(key.toLowerCase())) + params.set(key, SCRUBBED) + } + + return params.toString() +} diff --git a/apps/app/src/observability/sentry.ts b/apps/app/src/observability/sentry.ts new file mode 100644 index 00000000..1d0bce8f --- /dev/null +++ b/apps/app/src/observability/sentry.ts @@ -0,0 +1,62 @@ +import * as Sentry from '@sentry/vue' +import type { App } from 'vue' +import type { Router } from 'vue-router' +import { scrubEvent } from './scrubber' + +export interface SentryInitOptions { + app: App + router: Router + dsn: string + release: string + environment: string +} + +/** + * Initialises @sentry/vue for the SPA per RFC-WS-7 §3.2-§3.9. + * + * - Empty DSN → no-op (RFC §3.3, mirrors backend). + * - Errors-only — tracesSampleRate / profilesSampleRate hard-pinned to 0 + * (RFC §2 amendment B). + * - sendDefaultPii=false (RFC §3.7 / §3.8); user identity is bound + * explicitly by {@link installContextBinding} as a ULID-only object. + * - app=app initial scope tag (RFC §3.6) so GlitchTip can filter + * frontend vs backend events. + * - PII scrubbing via {@link scrubEvent} as the beforeSend hook (RFC §3.7 + * frontend block). + */ +export function initSentry(options: SentryInitOptions): void { + if (options.dsn === '') + return + + Sentry.init({ + app: options.app, + dsn: options.dsn, + release: options.release === '' ? undefined : options.release, + environment: options.environment, + + // RFC §2 amendment B — errors-only. + tracesSampleRate: 0, + profilesSampleRate: 0, + + // RFC §3.7 / §3.8: never let Sentry's auto-context capture IP, locals + // from stack frames, or the User session-cookie payload. + sendDefaultPii: false, + + // RFC §3.7 frontend point 5: console-logging integration off in prod + // (info / debug breadcrumbs may include user data through formatted + // arguments). Keep the BrowserApiErrors/Vue/global integrations. + integrations: defaults => defaults.filter(i => i.name !== 'Console'), + + // RFC §3.7 frontend block — scrubber applied on every event. + beforeSend: scrubEvent, + + // RFC §3.6 — route-scope baseline tag. AuthScopeContextListener-style + // binding of actor_scope / user_id / actor_type happens per route + // navigation in {@link installContextBinding}. + initialScope: { + tags: { + app: 'app', + }, + }, + }) +} diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts index a34431b1..41dd4f6f 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -127,6 +127,11 @@ export default defineConfig({ }, build: { chunkSizeWarningLimit: 5000, + + // RFC-WS-7 §3.5 — sourcemaps generated at build, uploaded to GlitchTip + // by deploy.sh, then `find dist -name '*.map' -delete` strips them + // before nginx serves dist/. No public-mapped sources on production. + sourcemap: true, }, optimizeDeps: { exclude: ['vuetify'], diff --git a/deploy.sh b/deploy.sh index 29df22a7..72e81dd1 100755 --- a/deploy.sh +++ b/deploy.sh @@ -94,6 +94,9 @@ else fi echo "→ Building frontend assets (apps/app)..." +# WS-7 RFC §3.4 — release identifier is injected at build-time so Vite +# inlines it into import.meta.env.VITE_SENTRY_RELEASE for the bundle. +export VITE_SENTRY_RELEASE="crewli-app@$(git rev-parse --short HEAD)" npm run build -w apps/app if [ ! -f "apps/app/dist/index.html" ]; then @@ -101,6 +104,28 @@ if [ ! -f "apps/app/dist/index.html" ]; then exit 1 fi +# ────────────────────────────────────────── +# 4a. Sourcemap upload to GlitchTip + scrub from dist/ +# ────────────────────────────────────────── +# WS-7 RFC §3.5: maps generated by Vite, uploaded to GlitchTip so stack +# traces are readable in the UI, then DELETED from dist/ before nginx +# serves them. No public-mapped sources on production. +if [ -n "${SENTRY_AUTH_TOKEN:-}" ] && [ -n "${VITE_SENTRY_DSN_FRONTEND:-}" ]; then + SENTRY_ORG_VAL="${SENTRY_ORG:-crewli}" + echo "→ Uploading sourcemaps for release ${VITE_SENTRY_RELEASE} to project crewli-app..." + npx --yes @sentry/cli@latest sourcemaps upload \ + --org "$SENTRY_ORG_VAL" \ + --project crewli-app \ + --release "$VITE_SENTRY_RELEASE" \ + --url-prefix "~/assets/" \ + apps/app/dist/assets || echo "⚠️ Sourcemap upload failed; continuing deploy." +else + echo "→ SENTRY_AUTH_TOKEN or VITE_SENTRY_DSN_FRONTEND unset — skipping sourcemap upload." +fi + +echo "→ Stripping *.map files from dist/ (RFC §3.5: no public-mapped sources)..." +find apps/app/dist -name '*.map' -type f -delete + # ────────────────────────────────────────── # 5. Run migrations # ────────────────────────────────────────── diff --git a/deploy/nginx/csp-spa.conf b/deploy/nginx/csp-spa.conf index d4a89894..76c2f5ce 100644 --- a/deploy/nginx/csp-spa.conf +++ b/deploy/nginx/csp-spa.conf @@ -2,14 +2,18 @@ # Vite bundles all JS/CSS into same-origin files. # 'unsafe-inline' for style-src is required by Vuetify (inline styles for theming). # img-src https: allows organisation logos loaded from external URLs. -# connect-src must include the API domain for XHR/fetch calls. +# connect-src must include: +# - https://api.crewli.app (XHR/fetch to the API) +# - https://monitoring.hausdesign.nl (RFC-WS-7 §3.5: GlitchTip event ingest; +# without it the browser silently blocks +# every @sentry/vue POST) # # IMPORTANT: Start with Content-Security-Policy-Report-Only to catch # false positives. Switch to Content-Security-Policy after 1-2 weeks # of clean logs. # Report-only mode (start with this): -# add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.crewli.app; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always; +# add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.crewli.app https://monitoring.hausdesign.nl; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always; # Enforce mode (switch to this after testing): -add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.crewli.app; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.crewli.app https://monitoring.hausdesign.nl; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always; diff --git a/dev-docs/ARCH-OBSERVABILITY.md b/dev-docs/ARCH-OBSERVABILITY.md index 24b92ac7..7f109480 100644 --- a/dev-docs/ARCH-OBSERVABILITY.md +++ b/dev-docs/ARCH-OBSERVABILITY.md @@ -1,56 +1,405 @@ -# ARCH-OBSERVABILITY +# ARCH — Observability (v1.0) -> Crewli's observability architecture — logging, monitoring, alerting, -> metrics. +> **Source of truth** for Crewli's observability implementation +> (sentry-laravel + @sentry/vue + GlitchTip). This document supersedes +> [`RFC-WS-7-OBSERVABILITY.md`](./RFC-WS-7-OBSERVABILITY.md) for tag +> taxonomy, binding semantics, and operational patterns. The RFC +> remains the historical implementation-spec; ARCH is the +> post-implementation reference for developers maintaining or extending +> the stack. > -> Status: SKELETON. Section §3 (`$dontReport`) is concrete; other -> sections are structured placeholders for WS-7 sessie 1 decisions. +> **Status:** WS-7 implementation complete. Code criteria 3, 4, 5, 6, +> 11, 12, 13 satisfied; documentation criteria 8, 14 satisfied via this +> ARCH plus the runbooks under [`runbooks/`](./runbooks/). Manual +> closure criteria (1, 2, 7, 9, 10) remain on Bert's checklist. +> +> **Version:** 1.0 (initial post-implementation reference, mei 2026, +> after PR-1 → PR-4 landed in `feat/ws-7-observability`). +> +> **Pre-WS-7 skeleton:** earlier versions of this document (v0.1, +> april 2026) carried placeholder sections for log levels, metrics, +> alerting and dashboards. Those decisions were taken during WS-7 and +> implemented as code; this document captures the as-built outcome. +> The historical skeleton sections about metrics (§5), alerting (§6), +> and dashboards (§7) are intentionally not carried forward — Crewli +> has settled on errors-only observability via GlitchTip; no Statsd / +> Prometheus / Grafana stack is planned (RFC §2 amendment B). -## Document history +--- -- 2026-04-28 — v0.1 — Initial skeleton (WS-6 sessie 3b). Only §3 - concrete; remainder placeholdered for WS-7. +## §1 Doel & scope -## §1 — Logging strategy +Observability in Crewli levert geautomatiseerde error-detection en +service-availability monitoring. Stack traces, tags, breadcrumbs en +release-correlation worden verzameld via [GlitchTip](https://glitchtip.com/), +self-hosted op `monitoring.hausdesign.nl`. GlitchTip is binary-compatible +met het Sentry event-protocol; we gebruiken `sentry/sentry-laravel` op de +backend en `@sentry/vue` op de frontend ongewijzigd. -[WS-7: define log levels with explicit criteria. Example questions to -answer in WS-7 sessie 1: +**Wel in scope:** -- When does code use `Log::error` vs `Log::warning` vs `Log::info`? -- Are unhandled exceptions automatically `error`? -- Is `Log::debug` allowed in production, or stripped in deploy? -- How do structured payload conventions tie to log keys (see §4)? -] +- Programmer errors uit Laravel controllers en queue jobs (`Throwable`, + `RuntimeException`, `TypeError`, `QueryException` etc.) +- Infrastructure failures (database connection drop, redis unavailable, + external HTTP timeouts in Crewli's eigen client-code) +- Frontend runtime errors uit Vue componenten en composables +- Unhandled promise rejections in de SPA +- Vue Router navigatie-context als breadcrumbs -## §2 — Sentry decisions +**Niet in scope** (per [RFC §3.10](./RFC-WS-7-OBSERVABILITY.md)): -[WS-7: Sentry SDK install + configuration decisions. Skeleton: +- **Performance / tracing / profiling.** Hard-pinned op 0.0 sample rate + in zowel `config/sentry.php` als `apps/app/src/observability/sentry.ts`. +- **Verwachte business-uitkomsten.** ValidationException, + AuthenticationException, AuthorizationException, sub-500 + HttpExceptions worden bewust niet gecaptured. Die hebben eigen + audit-paden (`form_submission_action_failures`, `activity_log`). +- **Audit trails.** `activity_log`, `impersonation_audit_logs`, + `form_webhook_deliveries` blijven authoritative voor security en + compliance audit. GlitchTip is voor defectdetectie. +- **Replay / Web Vitals / User Feedback.** GlitchTip ondersteunt dit + niet; we gebruiken het ook niet als roadmap-item. +- **Metrics / dashboards / alerting beyond GlitchTip's email.** Geen + Statsd / Prometheus / Grafana. Alerting initieel email-naar-Bert via + GlitchTip's eigen rule-engine; Slack-integratie staat op BACKLOG. -- Which environments report to Sentry? (dev / staging / production) -- Sample rate per environment? -- Source map upload to Sentry CI? -- User context injection (auth user ID + organisation ID, opt-in - redaction for PII)? -- Breadcrumbs strategy (which events generate breadcrumbs)? -- Release tagging convention (commit SHA? semver? both?)? -] +**Boundary met bestaande systemen:** -## §3 — `$dontReport` exceptions (concrete) +| Systeem | Wat het doet | Wat GlitchTip NIET overneemt | +|---|---|---| +| Telescope (`/telescope`) | Dev-only debugging dashboard. Local + testing. | GlitchTip is voor production-incidents; Telescope blijft voor lokale debug. | +| `activity_log` (Spatie) | Audit trail van user-acties op tenant data. Authoritative voor "wie deed wat wanneer." | GlitchTip captured nooit business-events zoals `form.submitted`. | +| `form_webhook_deliveries` | Webhook delivery audit met retry / dead-letter. | Bij dead-letter NIET via GlitchTip; alleen als de dispatcher zelf een programmer-error gooit. | +| `form_submission_action_failures` | Apply-pipeline failures per submission, action, en organisatie. Org-admin operational handling via WS-6 admin UI. | GlitchTip ziet runtime apply-pipeline exceptions (zie §5.4) parallel — engineering visibility, niet operational fix UI. | +| `impersonation_audit_logs` | Wie impersoneerde wie wanneer (security audit). | GlitchTip tagt actieve impersonation als context op gecaptured events; vervangt audit niet. | +| Laravel default log channel | Operationele runtime logs (info/warning/error). | Beide systemen krijgen dezelfde events; correlation via `request_id`. | -The following exception classes are **expected business outcomes**, -not bugs. They are caught and handled in the application; reporting -them to Sentry would generate noise that drowns the signal. +--- -When the Sentry SDK lands (WS-7), add the following classes to -Laravel's `app/Exceptions/Handler.php` `$dontReport` array: +## §2 Componenten-overzicht -| Class | Reason | +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Crewli production / dev │ +│ │ +│ ┌─────────────────────┐ ┌──────────────────────────┐ │ +│ │ Laravel API │ │ apps/app SPA (Vue 3) │ │ +│ │ (api.crewli.app) │ │ (crewli.app) │ │ +│ │ │ │ │ │ +│ │ ┌───────────────┐ │ │ ┌────────────────────┐ │ │ +│ │ │sentry-laravel │ │ │ │@sentry/vue 10.x │ │ │ +│ │ │4.25 SDK │ │ │ │ │ │ │ +│ │ └───────┬───────┘ │ │ └─────────┬──────────┘ │ │ +│ │ │ │ │ │ │ │ +│ │ ┌───────▼───────┐ │ │ ┌─────────▼──────────┐ │ │ +│ │ │SentryEvent │ │ │ │scrubEvent │ │ │ +│ │ │Scrubber (PHP) │ │ │ │(TypeScript) │ │ │ +│ │ └───────┬───────┘ │ │ └─────────┬──────────┘ │ │ +│ │ │ │ │ │ │ │ +│ └──────────┼──────────┘ └────────────┼─────────────┘ │ +│ │ │ │ +│ │ HTTPS POST /api//envelope/ │ │ +│ └────────────┬────────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────────────────────┐ │ +│ │ GlitchTip │ │ +│ │ monitoring.hausdesign.nl (prod) │ │ +│ │ localhost:8200 (dev) │ │ +│ │ │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌───────┐ │ │ +│ │ │ web │ │ worker │ │ pg/ │ │ │ +│ │ │ (django)│ │ (celery)│ │ redis │ │ │ +│ │ └─────────┘ └─────────┘ └───────┘ │ │ +│ └───────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +**Twee projecten in één GlitchTip-instance:** + +- `crewli-api` — Laravel events (`app=api` tag) +- `crewli-app` — SPA events (`app=app` tag) + +DSN per project; beide keys liggen in 1Password vault onder +`Crewli / GlitchTip / DSNs`. Backend leest `SENTRY_DSN_BACKEND`, +frontend `VITE_SENTRY_DSN_FRONTEND`. + +CSP `connect-src` whitelist voor de ingest-host is verplicht — zonder +deze whitelist blokkeert de browser elke `@sentry/vue`-egress stilletjes. +Zie [§7](#7-csp-whitelist-kritisch). + +--- + +## §3 Tag-taxonomie + +Deze tabel vervangt de tabel in [`RFC-WS-7-OBSERVABILITY.md §3.6`](./RFC-WS-7-OBSERVABILITY.md) +als source-of-truth. Wanneer een tag wordt toegevoegd of de bron-locatie +wijzigt, wordt deze tabel bijgewerkt; de RFC blijft historisch +document. + +### 3.1 Backend tags (sentry-laravel) + +| Tag | Locatie | Always / conditional | Bron | +|---|---|---|---| +| `app` | initial scope (`config/sentry.php`) of `BindSentryRouteContext` | always | constant `'api'` | +| `release` | sentry-laravel built-in | when `SENTRY_RELEASE` env set | `crewli-api@` injected by `deploy.sh` | +| `environment` | sentry-laravel built-in | always | `APP_ENV` | +| `route_name` | `BindSentryRouteContext` middleware | conditional (named routes only) | `$request->route()->getName()` | +| `http.method` | `BindSentryRouteContext` middleware | always (HTTP requests) | `$request->method()` | +| `actor_scope` | `AuthScopeContextListener` | always (authenticated events) | resolution chain — see §3.3 | +| `actor_type` | `AuthScopeContextListener` | always (authenticated events) | `ActorType::resolve()` — see §3.4 | +| `user_id` | `AuthScopeContextListener` (overridden by `HandleImpersonation`) | always (authenticated) | ULID | +| `username` | `AuthScopeContextListener` user object | always (authenticated) | ULID — RFC §3.8: never email | +| `organisation_id` | `AuthScopeContextListener` | conditional — only when `actor_scope=organisation` | route param / portal-token resolution — see §3.3 | +| `event_id` | `AuthScopeContextListener` (via `{event}` route param) | conditional | route binding | +| `impersonation.active` | `AuthScopeContextListener` baseline + `HandleImpersonation` override | **always** (authenticated) | binary `'true'`/`'false'` — RFC §3.6 invariant | +| `impersonation.impersonator_user_id` | `HandleImpersonation` middleware | conditional — only when impersonating | ULID | +| `impersonation.session_id` | `HandleImpersonation` middleware | conditional — only when impersonating | ULID | +| `queue.attempt` | `TagJobAttemptOnSentry` listener | conditional — within queue jobs | `$event->job->attempts()` | + +### 3.2 Frontend tags (@sentry/vue) + +| Tag | Locatie | Always / conditional | Bron | +|---|---|---|---| +| `app` | initial scope (`sentry.ts`) | always | constant `'app'` | +| `release` | sentry-vue built-in | when `VITE_SENTRY_RELEASE` set | `crewli-app@` injected by `deploy.sh` build-time | +| `environment` | sentry-vue built-in | always | `import.meta.env.MODE` | +| `route_name` | `installContextBinding` Vue Router guard | always | `route.name ?? 'unnamed'` | +| `actor_scope` | `installContextBinding` | always | one of `organisation` / `platform` / `user` / `portal` / `anonymous` | +| `actor_type` | `installContextBinding` | always | `super_admin` / `organizer_admin` / `org_member` / `portal_token` / `unauthenticated` | +| `user_id` | `installContextBinding` | conditional — **never** when `actor_scope=portal` | `useAuthStore().user.id` | +| `organisation_id` | `installContextBinding` | conditional — only when `actor_scope=organisation` | `useOrganisationStore().activeOrganisationId` | + +`http.method` is afwezig in de frontend-tabel; Vue Router-routes zijn +page-level navigation events, niet HTTP-requests. De backend-tabel +heeft `http.method` per request; de frontend laat die over aan +fetch-breadcrumbs die `@sentry/vue` automatisch attached. + +### 3.3 `actor_scope` resolution + +Beide implementaties volgen dezelfde priority chain. De backend doet +de resolution in `AuthScopeContextListener::resolveTenantContext()`, +de frontend in `contextBinding.ts::bindScope()`. + +| Priority | Backend signaal | Frontend signaal | Resulterende `actor_scope` | +|---|---|---|---| +| 1 | `{organisation}` route-param | route binding op `/organisations/:id` | `organisation` | +| 2 | `{event}` route-param | `{event}` route-param | `organisation` (via event.organisation_id) | +| 3 | `portal_event` request attribute (set by `PortalTokenMiddleware`) | `route.meta.public === true && route.meta.context === 'portal'` | backend: `organisation`, frontend: `portal` | +| 4 | `super_admin` role + route name starts with `admin.` | `super_admin` role + `route.path` starts with `/platform` | `platform` (no `organisation_id`) | +| 5 | Authenticated, no org context | Authenticated, no `activeOrganisationId` | `user` (no `organisation_id`) | +| 6 | Unauthenticated | Unauthenticated | (backend: not bound; frontend: `anonymous`) | + +**Belangrijke noot voor frontend portal-zone:** `actor_scope=portal` +kent **geen** `user_id` of `username`. RFC §3.7 frontend-block punt 5 +expliciet — token-based flows (artist advance, public form fill) +krijgen geen user-context omdat de identifier (ULID-token) zelf +gevoelig is en de bezoeker niet permanent met Crewli is verbonden. + +**Multi-tenant invariant:** wanneer `actor_scope=organisation` MOET +`organisation_id` aanwezig zijn als valide ULID. Wanneer +`actor_scope=platform`, `user`, of `anonymous`, IS `organisation_id` +afwezig. Niet "altijd aanwezig" maar "altijd correct gerelateerd aan +`actor_scope`." Geverifieerd in +`AuthScopeContextListenerTest::test_organisation_id_present_when_actor_scope_is_organisation`. + +### 3.4 `actor_type` enum + +Backend: `App\Enums\Observability\ActorType` (PHP enum). Frontend: +inline string-mapping in `contextBinding.ts::resolveActorType()`. Beide +geven dezelfde waarden: + +| Waarde | Wanneer | |---|---| -| `\App\Exceptions\FormBuilder\PublishGuardViolationException` | Publish-time validation: schema fails a guard. Returned as 422 with field-level errors. Not a system bug. | -| `\App\Exceptions\FormBuilder\PurposeRequirementsNotMetException` | Schema lacks required bindings for its purpose. Returned as 422. Not a system bug. | -| `\App\Exceptions\FormBuilder\IdempotencyConflictException` | Duplicate idempotency key on submission. Returned as 409. Not a system bug. | +| `super_admin` | User has Spatie role `super_admin` | +| `organizer_admin` | User has Spatie role `org_admin` | +| `org_member` | Authenticated user, no admin role (covers volunteers — Crewli has no dedicated `volunteer` role today; see [BACKLOG OBS-1](./BACKLOG.md)) | +| `portal_token` | Token-based portal request (`portal_event` attribute / `route.meta.public` + `context=portal`) | +| `unauthenticated` | No auth (e.g. login page, public form fill) | -**Out of scope for `$dontReport` (these DO go to Sentry):** +--- + +## §4 Tag-binding architectuur + +Drie patronen die we bewust hebben gekozen tijdens WS-7 implementation; +gedocumenteerd hier zodat toekomstige uitbreidingen consistent zijn. + +### 4.1 Backend split — middleware × event-listener + +Route-scope tags binden **per HTTP request** via middleware. Auth-scope +tags binden **per authenticatie-event** via een listener. Reden: +route-context bestaat alleen tijdens HTTP handling, auth-context wordt +geëmit door élke authenticator (Laravel's `SessionGuard`, Sanctum's +bearer-token Guard, toekomstige authenticators). + +| Concern | Implementatie | +|---|---| +| Route-scope (`app`, `route_name`, `http.method`) | `App\Http\Middleware\BindSentryRouteContext` — registered globally on the api group via `$middleware->api(prepend: [...])` in `bootstrap/app.php` | +| Auth-scope (`user_id`, `actor_type`, `actor_scope`, `organisation_id`) | `App\Listeners\Observability\AuthScopeContextListener` — listens to BOTH `Illuminate\Auth\Events\Authenticated` (SessionGuard) AND `Laravel\Sanctum\Events\TokenAuthenticated` (Sanctum) | +| Impersonation override + escalation | `App\Http\Middleware\HandleImpersonation` — re-binds Sentry scope after the user-swap, sets `impersonation.active='true'` plus impersonator/session ids | +| Queue context (`queue.attempt`) | `App\Listeners\Observability\TagJobAttemptOnSentry` — listens to `Illuminate\Queue\Events\JobProcessing` | + +**Waarom dual-event listener?** Crewli's HTTP-flow is bearer-token via +`CookieBearerToken` middleware → `auth:sanctum` → Sanctum's `Guard` +fires only `TokenAuthenticated`, NOT `Authenticated`. Listening only +to the Authenticated event would silently miss every authenticated +HTTP request. Discovered by the live smoke test that PR-3 follow-up +fixed (commit `adab3be`). + +### 4.2 Frontend split — Vue Router guard + Pinia store reads + +Vue heeft geen Sentry-equivalent van Laravel's Authenticated event; de +natuurlijke tag-binding momenten zijn **route-transitions**. Een +`router.beforeEach` guard in `apps/app/src/observability/contextBinding.ts`: + +1. Roept `Sentry.getCurrentScope().clear()` aan op elke navigatie. + Voorkomt cross-zone leakage (e.g. user logt uit in portal-zone maar + Sentry houdt user_id van de organizer-context vast). +2. Leest `useAuthStore()` en `useOrganisationStore()` voor identity en + tenant-context. +3. Past dezelfde resolution chain toe als de backend (§3.3). + +### 4.3 Default-in-listener / override-in-middleware pattern + +Voor binary tags die altijd aanwezig moeten zijn maar door specifieke +middleware-stappen worden geëscaleerd, gebruiken we een twee-fase +pattern: + +``` +AuthScopeContextListener::bindForUser() → scope.setTag('impersonation.active', 'false') + ↓ +HandleImpersonation::handle() → scope.setTag('impersonation.active', 'true') + scope.setTag('impersonation.impersonator_user_id', $admin->id) + scope.setTag('impersonation.session_id', $session->id) +``` + +De listener seedt **altijd** een baseline (`'false'`). Wanneer +impersonation actief is, draait `HandleImpersonation` ná auth en +overschrijft de scope met de target user en de escalation-tags. Als +toekomstige refactors per `actor_scope` branch shortcuts maken die de +baseline overslaan, vangt +`AuthScopeContextListenerTest::test_impersonation_active_default_false_across_every_actor_scope_branch` +de regressie. + +Dit pattern is herbruikbaar voor andere binary signals; tot nu toe +alleen toegepast op `impersonation.active`. + +### 4.4 Listener registration discipline + +Laravel 12's listener auto-discovery is uitgeschakeld in +`bootstrap/app.php` via `->withEvents(discover: false)`. Reden: +auto-discovery + explicit `Event::listen()` veroorzaakt silent +double-registration (vandaag idempotent door scope-tag overwrite +semantics, morgen niet meer wanneer een listener additive operations +doet). Gevangen door +`tests/Feature/Observability/EventListenerRegistrationTest`. + +**Voor élke nieuwe observability-listener:** + +1. Maak listener-class in `app/Listeners/Observability/`. +2. Registreer **expliciet** in `AppServiceProvider::boot()` met + array-callable form `[Class::class, 'method']`. Class-string vorm + verbergt method-binding in `php artisan event:list`. +3. Voeg een case toe aan + `EventListenerRegistrationTest::test_*_listener_registered_exactly_once` + met de juiste event-class + method-naam. + +--- + +## §5 Scrubbing semantics + +### 5.1 Backend — `App\Services\Observability\SentryEventScrubber` + +Geregistreerd als `before_send` hook in `config/sentry.php` via +array-callable static-method notation. Stateless; geen +container-resolution per event. + +**Wat wordt gescrubt:** + +1. **Request body keys** (recursief, key-name match, depth-limited): + `password`, `password_confirmation`, `current_password`, `token`, + `api_key`, `secret`, `webhook_secret`, `dsn`, `signature`, + `authorization`, `cookie`, `bearer`, `iban`, `bic`, + `passport_number`, `bsn`. Replace value met `[scrubbed]`. + +2. **Request headers** (case-insensitive): `authorization`, `cookie`, + `set-cookie`, `x-api-key`, `x-impersonation-token`. Replace met + `[scrubbed]`. + +3. **Form submissions:** élke payload-key `form_values` wordt + wholesale replaced met `[scrubbed_form_values]`. Reden: Crewli's + form-builder genereert dynamische form-values waar elke key PII + kan zijn (email, telefoon, dietary, medical). Selectief op key + matchen is niet veilig. + +4. **URL query string:** `token=`, `api_key=` worden gescrubt. + +5. **Cookies wholesale:** `event.request.cookies` wordt vervangen door + `[scrubbed]`. + +6. **Max-depth guard** op recursie: na 10 levels wordt subtree + replaced met `['[max_depth]']` om malicious deeply-nested payloads + te beperken. + +**Sub-500 HttpException filter:** wanneer +`$hint?->exception instanceof HttpException && $hint->exception->getStatusCode() < 500`, +returnt de scrubber `null` → event wordt niet gestuurd. Reden: 404, +403, 422 etc. zijn verwachte business-uitkomsten (RFC §3.10), niet +programmer-errors. `ignore_exceptions` in `config/sentry.php` doet +class-only filtering; status-based filtering moet hier. + +### 5.2 Frontend — `apps/app/src/observability/scrubber.ts` + +TypeScript port van de backend-scrubber met identieke semantics. Plus: + +7. **Storage context strip:** `event.contexts.storage` wordt gestript. + Sentry doesn't add this by default but defensively. RFC §3.7 + frontend point 2 — localStorage / sessionStorage **never** in event + context (Crewli's portal-state in sessionStorage MAG NIET lekken). + +8. **`event.user.cookies` strip:** als sentry's BrowserSession + integration `document.cookie` exposure via user-context injecteert, + wordt het weggehaald. + +9. **Cookies wholesale (typed shape):** `event.request.cookies` is + typed `Record` in `@sentry/vue`. Replace met + `{ scrubbed: '[scrubbed]' }` in plaats van een string — preserves + the typed shape. + +### 5.3 Boundary: business outcomes vs programmer/infra errors + +| Exception class | Backend behaviour | Reden | +|---|---|---| +| `Throwable`, `RuntimeException`, `TypeError` | Captured | Programmer error | +| `QueryException`, `PDOException` | Captured | Infra error | +| `ValidationException` | NOT captured (`ignore_exceptions`) | Verwacht user-input error | +| `AuthenticationException` | NOT captured (`ignore_exceptions`) | Verwacht user-state error | +| `AuthorizationException` | NOT captured (`ignore_exceptions`) | Verwacht user-permission error | +| `HttpException` status `< 500` | NOT captured (scrubber returns `null`) | Verwacht 4xx outcome | +| `HttpException` status `>= 500` | Captured | Genuine server error | + +`Integration::handles($exceptions)` in `bootstrap/app.php` is **niet +auto-registered** door sentry-laravel 4.x. Zonder deze regel runt +`report($e)` alleen door Laravel's default reporter (logs to channel) +en bereikt het Sentry niet. Gedekt door +`tests/Feature/Observability/ExceptionReportingTest`. Zie ook +[BACKLOG OBS-6](./BACKLOG.md). + +**Voor élke nieuwe `$exceptions->render(...)` handler in +`bootstrap/app.php`:** Laravel's flow is `report()` → `render()`. Als +de handler een Throwable consumeert en een Response retourneert, zorgt +de framework-flow voor `report()` automatisch. **Render handlers MOGEN +NIET** `report()` hand-rollen of vroegtijdig short-circuiten — zie +[BACKLOG OBS-7](./BACKLOG.md) voor expansion plan. + +### 5.4 Form Builder runtime exceptions (concrete classification) + +Form Builder is Crewli's grootste runtime-domein met eigen +exception-hierarchy (zie [`ARCH-FORM-BUILDER.md`](./ARCH-FORM-BUILDER.md)). +De classificatie tussen "expected business outcome" en "programmer / +infra error" voor deze classes is concreet vastgelegd: + +**Wel naar GlitchTip (programmer/infra errors):** - `App\Exceptions\FormBuilder\PersonProvisioningException` — runtime failure during the apply pipeline. Caught by @@ -59,63 +408,323 @@ Laravel's `app/Exceptions/Handler.php` `$dontReport` array: visibility into recurring patterns across orgs. - `App\Exceptions\FormBuilder\PurposeSubjectResolutionException` — runtime resolution failure (no portal token, no auth user, etc.). - Same dual-handling rationale: action-failures table for - org-admin operational handling; Sentry for engineering visibility. + Same dual-handling rationale: action-failures table for org-admin + operational handling; GlitchTip for engineering visibility. - `App\Exceptions\FormBuilder\FormBindingApplicatorException` — - runtime applicator failure (no_transaction, no_schema, - unknown_purpose). These should never happen in production; if they - do, they're systemic bugs — Sentry is the correct destination. + runtime applicator failure (`no_transaction`, `no_schema`, + `unknown_purpose`). These should never happen in production; if + they do, they're systemic bugs — GlitchTip is the correct + destination. -The dual recording (Sentry + `form_submission_action_failures` table) -is intentional: org admins fix specific failures via the WS-6 admin -UI; engineering identifies systemic issues across all orgs via -Sentry's aggregation. +**Niet naar GlitchTip (expected business outcomes):** -## §4 — Structured logging conventions +- `App\Exceptions\FormBuilder\PublishGuardViolationException` — + publish-time validation: schema fails a guard. Returned as 422 + with field-level errors. Not a system bug. +- `App\Exceptions\FormBuilder\PurposeRequirementsNotMetException` — + schema lacks required bindings for its purpose. Returned as 422. + Not a system bug. +- `App\Exceptions\FormBuilder\IdempotencyConflictException` — + duplicate idempotency key on submission. Returned as 409. Not a + system bug. -[WS-7: log key naming convention. Skeleton: +Dual-handling voor de eerste groep is intentional: org-admins fixen +specifieke failures via de WS-6 admin UI; engineering identificeert +systemic issues across all orgs via GlitchTip's aggregation. De +"niet naar GlitchTip" groep is afgedekt door `ignore_exceptions` (voor +`PublishGuardViolationException` etc. die `HttpExceptionInterface` +implementeren via 422 response) en moet bij toevoeging van een nieuwe +expected-outcome class expliciet worden uitgezonderd in +`config/sentry.php`. -- Hierarchical dot-separated namespace tree -- Existing examples to align with: - - `form-builder.apply.transaction_rolled_back` - - `form-builder.identity-match.no_person_subject_post_apply` - - `form-webhook.delivery.exception` +--- -Define the tree formally so future code discovers the right namespace -deterministically.] +## §6 Runtime context-split (frontend) -## §5 — Metrics +Vie zones, gedecide per `route.path` en `route.meta`: -[WS-7: which counters / histograms / gauges? Namespace? -Statsd / Prometheus / OTel flavour? At minimum, candidate metrics: +### 6.1 `actor_scope=organisation` -- `form_submissions_total` (counter, tagged by purpose) -- `form_submission_apply_status` (counter, tagged by status) -- `form_failures_open` (gauge per org) -- `retry_attempts_total` (counter, tagged by outcome) -- `apply_pipeline_duration_seconds` (histogram) -] +- Organizer routes met active org context (`useOrganisationStore().activeOrganisationId !== null`) +- Tags: `actor_scope=organisation`, `organisation_id=`, plus user-context +- Voorbeelden: `/organisations/:id/dashboard`, `/events/:id`, `/dashboard` -## §6 — Alerting rules +### 6.2 `actor_scope=platform` -[WS-7: which thresholds trigger alerts? Where (Slack? PagerDuty? -Email?). At minimum, candidate alerts: +- super_admin op `/platform/*` paths +- Tags: `actor_scope=platform`, GEEN `organisation_id` +- **Geforceerde org-attribution zou misleidend zijn.** Platform-mode + events spannen impliciet over alle organisaties. -- "Open failures > X for > Y hours" -- "Apply pipeline error rate > X% in 1h window" -- "no_transaction guard fired" (immediate alert; should never happen - in production) -- "Webhook dead-letter rate > X%" -] +### 6.3 `actor_scope=user` -## §7 — Dashboards +- Authenticated user op routes zonder org-scope (`/account-settings`, + `/portal/profiel`) +- Tags: `actor_scope=user`, GEEN `organisation_id` +- Reden: Crewli's User↔Organisation is many-to-many; geen reliable + single-org hint zonder route-context. -[WS-7: Grafana / Cloudwatch / similar. Panel layout, widget types, -default time ranges. Skeleton later.] +### 6.4 `actor_scope=portal` -## Related docs +- Token-based portal flows: `route.meta.public === true && route.meta.context === 'portal'` +- Concrete routes: `/portal/advance/:token` (artist advance), + `/register/:public_token` (public form fill) +- Tags: `actor_scope=portal`, `actor_type=portal_token` +- **Geen `user_id`, geen `username`** — RFC §3.7 frontend point 5. + De ULID-token zelf is gevoelig; de bezoeker is niet permanent met + Crewli verbonden. +- Backend portal-token request resolves de organisation via de + matching artist/event row; frontend events correleren via + `request_id` back naar het backend-event dat wel `organisation_id` + heeft. -- `RFC-WS-6.md` — WS-6 binding pipeline design (the failures observed - and recorded by §3's classes originate here) -- `ARCH-BINDINGS.md` — apply pipeline architecture -- `ARCH-FORM-BUILDER.md` — form-builder runtime including webhooks +### 6.5 `actor_scope=anonymous` + +- Public routes zonder auth: `/login`, `/forgot-password`, `/register`, + `/invitations/:token` (acceptance flow) +- Tags: `actor_scope=anonymous`, `actor_type=unauthenticated` + +### 6.6 Cross-zone leakage prevention + +`Sentry.getCurrentScope().clear()` wordt aangeroepen op élke +route-transitie in `installContextBinding`. Voorbeeld: user logt uit +in organizer-context, navigeert naar `/login`. Zonder clear zou het +volgende anonymous error-event nog `user_id` van de uitgelogde +gebruiker dragen. Met clear wordt het Sentry-scope reset; de +unauthenticated event krijgt alleen de zojuist gebonden anonymous-tags. + +Test: `contextBinding.spec.ts::test_cross-zone_leak_guard`. + +--- + +## §7 CSP whitelist (kritisch) + +Crewli's strict CSP `connect-src` directive moet de GlitchTip +ingest-host expliciet whitelisten. Zonder deze entry blokkeert de +browser elke `@sentry/vue` POST stilletjes met *"Refused to connect +because it violates the following Content Security Policy directive"* +in DevTools Console — de SDK denkt dat het werkt, maar geen events +bereiken GlitchTip. + +| Environment | CSP-locatie | `connect-src` entry | +|---|---|---| +| Dev | `apps/app/index.html` meta tag | `http://localhost:8200` | +| Prod organizer SPA | `deploy/nginx/csp-spa.conf` (Report-Only én Enforce regels) | `https://monitoring.hausdesign.nl` | +| API JSON responses | `api/config/security.php` — geen update | `default-src 'none'`; geen `connect-src` want JSON-context heeft geen fetch-origin | + +**Bij introductie van een nieuwe environment** (bijv. staging — zie +[BACKLOG OBS-9](./BACKLOG.md)) MOET: + +1. De bijbehorende GlitchTip ingest-host worden toegevoegd aan de + juiste CSP-locatie. +2. `tests/Feature/Security/CspConnectsToObservabilityTest` worden + uitgebreid met een staging-assertion zodat de regression-guard de + nieuwe environment dekt. + +--- + +## §8 Sourcemap upload (frontend) + +Vite produceert sourcemaps voor élke chunk (`build.sourcemap=true` in +`vite.config.ts`). `deploy.sh` uploadt ze naar GlitchTip én verwijdert +ze uit `dist/` vóór nginx ze serveert. RFC §3.5: **never** +public-mapped sources op productie. + +``` +vite build → apps/app/dist/assets/*.js + *.js.map + │ + ▼ + sentry-cli sourcemaps upload --org $SENTRY_ORG \ + --project crewli-app \ + --release $VITE_SENTRY_RELEASE \ + --url-prefix "~/assets/" \ + apps/app/dist/assets + │ + ▼ + find apps/app/dist -name '*.map' -type f -delete + │ + ▼ + nginx serves dist/ +``` + +**Required env vars** (deploy host alleen, niet committed): + +| Var | Beschrijving | +|---|---| +| `SENTRY_AUTH_TOKEN` | Per-project upload-only token in GlitchTip. Bert provisioned dit handmatig in `crewli-app` project settings. | +| `SENTRY_ORG` | GlitchTip organisation slug. Default in `deploy.sh`: `crewli`. | +| `VITE_SENTRY_DSN_FRONTEND` | Aanwezigheid is conditional — als deze ontbreekt skipt `deploy.sh` upload (soft fail) maar voert alsnog `*.map` strip uit. | +| `VITE_SENTRY_RELEASE` | Build-time injected door `deploy.sh`: `crewli-app@$(git rev-parse --short HEAD)`. | + +**Soft-fail:** als upload faalt (GlitchTip unreachable, expired token), +gaat de deploy door en logt een warning. De `find … -delete` stap loopt +**altijd**. Beter unmapped stack traces in GlitchTip dan een +geblokkeerde deploy. + +--- + +## §9 GDPR & privacy + +### 9.1 Processing register + +Crewli is **controller** voor GlitchTip-data (self-hosted op +Crewli-infra). Geen processor-relatie, geen DPA-uitbreiding nodig. +Processing register entry: zie +[`SECURITY_AUDIT.md`](./SECURITY_AUDIT.md), "WS-7 Observability — +finale audit". + +### 9.2 Data na scrubbing + +Wat een GlitchTip-event nog kan bevatten: + +- ULIDs (user_id, organisation_id, event_id, request_id, session_id) +- Stack traces (zonder locals — `send_default_pii=false`) +- Route names en HTTP methods +- Gecureerde tags (zie §3) +- Breadcrumbs (input-text masked, console-integration off in prod) + +Wat **niet**: emails, telefoonnummers, namen, IP-adressen, raw +form_values, raw cookies, raw headers (Authorization etc.). + +### 9.3 Retention + +90 dagen, daarna purged door GlitchTip's eigen partition-maintenance +loop (zie [`GLITCHTIP.md`](./GLITCHTIP.md) monitoring sectie). +Configurable via GlitchTip admin UI (settings → environment-config). + +### 9.4 Right to erasure (Art. 17) + +Initieel handmatig. Procedure: zie +[`runbooks/observability-erasure.md`](./runbooks/observability-erasure.md). +Geautomatiseerd erasure-script blijft op BACKLOG (referentie in de +RFC; nog niet als concrete entry in BACKLOG.md). + +--- + +## §10 Onderhoud & uitbreiding + +### 10.1 Een nieuwe tag toevoegen + +Bepaal eerst de **bron** van de tag. Drie patronen: + +| Bron | Pattern | Voorbeeld | +|---|---|---| +| HTTP request context (route, method, headers) | Middleware | `BindSentryRouteContext` | +| Auth context (user, role, org) | Listener op `Authenticated` + `TokenAuthenticated` | `AuthScopeContextListener` | +| Domain event (job processing, custom event) | Listener op het domain event | `TagJobAttemptOnSentry` | +| Static / build-time | `config/sentry.php` initial scope | `app=api` | + +Voor élke nieuwe tag: + +1. Voeg toe aan §3 tabel hierboven. +2. Implementeer in de gekozen locatie. +3. Bij listeners: registreer expliciet in `AppServiceProvider::boot()` + met array-callable form, en voeg case toe aan + `EventListenerRegistrationTest`. +4. Schrijf een feature-test die de tag op een live HTTP flow asserteert + (volg het pattern van `AuthScopeBindingHttpFlowTest`). +5. Frontend mirror: voeg toe aan + `apps/app/src/observability/contextBinding.ts` en aan + `contextBinding.spec.ts`. + +### 10.2 Een nieuwe scrubbing-rule toevoegen + +1. Backend: voeg key toe aan `SENSITIVE_BODY_KEYS` of + `SENSITIVE_HEADERS` in + `app/Services/Observability/SentryEventScrubber.php`. +2. Frontend: identieke wijziging in + `apps/app/src/observability/scrubber.ts`. +3. Voeg test-case toe aan beide: + `tests/Feature/Observability/PiiScrubbingTest.php` (PHP) en + `apps/app/src/observability/__tests__/scrubber.spec.ts` (TypeScript). +4. Beide testbestanden moeten de nieuwe key dekken — backend en + frontend zijn semantisch gelijk en moeten dat blijven. + +### 10.3 Een nieuwe `$exceptions->render(...)` handler + +Per [BACKLOG OBS-7](./BACKLOG.md): nieuwe render handlers MOGEN NIET +short-circuiten zonder `report($e)`. Laravel's flow is `report()` → +`render()` automatisch; render handlers die een Response retourneren +hebben report al gehad. + +Als de nieuwe handler een Throwable consumeert die niet via +`Integration::handles()` zou gaan (e.g. een eigen `$exception->report()` +methode op een custom exception), voeg een case toe aan +`ExceptionReportingTest` die bewijst dat het event alsnog gecaptured +wordt. + +### 10.4 Een nieuwe environment (staging, demo, …) + +Zie [BACKLOG OBS-9](./BACKLOG.md). Vereist: + +1. GlitchTip-project provisioning + DSN naar 1Password. +2. CSP whitelist update (`apps/app/index.html` voor dev-style env, of + nieuwe nginx-config voor prod-style env). +3. `tests/Feature/Security/CspConnectsToObservabilityTest` uitbreiden + met assertion voor de nieuwe environment. +4. `deploy.sh` aanpassen als de release-tag-vorm verandert (default: + `crewli-app@`). + +### 10.5 Een nieuwe Form Builder exception class + +Zie §5.4. Bij toevoeging van een nieuwe FormBuilder exception: + +- Als het een **expected business outcome** is: voeg toe aan + `ignore_exceptions` in `config/sentry.php` als de class niet via + `HttpException` of `ValidationException` afhandeling al geignored + wordt. Documenteer in §5.4. +- Als het een **programmer/infra error** is: niets toevoegen, de class + flowt automatisch via `Integration::handles($exceptions)`. + +--- + +## §11 Verwijzingen + +**Implementatie:** + +- [`api/app/Services/Observability/SentryEventScrubber.php`](../api/app/Services/Observability/SentryEventScrubber.php) +- [`api/app/Listeners/Observability/AuthScopeContextListener.php`](../api/app/Listeners/Observability/AuthScopeContextListener.php) +- [`api/app/Listeners/Observability/TagJobAttemptOnSentry.php`](../api/app/Listeners/Observability/TagJobAttemptOnSentry.php) +- [`api/app/Http/Middleware/BindSentryRouteContext.php`](../api/app/Http/Middleware/BindSentryRouteContext.php) +- [`api/app/Http/Middleware/HandleImpersonation.php`](../api/app/Http/Middleware/HandleImpersonation.php) +- [`api/config/sentry.php`](../api/config/sentry.php) +- [`api/bootstrap/app.php`](../api/bootstrap/app.php) +- [`apps/app/src/observability/sentry.ts`](../apps/app/src/observability/sentry.ts) +- [`apps/app/src/observability/scrubber.ts`](../apps/app/src/observability/scrubber.ts) +- [`apps/app/src/observability/contextBinding.ts`](../apps/app/src/observability/contextBinding.ts) +- [`apps/app/index.html`](../apps/app/index.html) +- [`deploy/nginx/csp-spa.conf`](../deploy/nginx/csp-spa.conf) +- [`deploy.sh`](../deploy.sh) + +**Tests (regression guards):** + +- `tests/Feature/Observability/PiiScrubbingTest.php` +- `tests/Feature/Observability/AuthScopeContextListenerTest.php` +- `tests/Feature/Observability/AuthScopeBindingHttpFlowTest.php` +- `tests/Feature/Observability/BindSentryRouteContextTest.php` +- `tests/Feature/Observability/ExceptionReportingTest.php` +- `tests/Feature/Observability/RequestIdRoundTripTest.php` +- `tests/Feature/Observability/EventListenerRegistrationTest.php` +- `tests/Feature/Database/ActivityLogIndexesTest.php` +- `tests/Feature/Security/CspHeaderTest.php` +- `tests/Feature/Security/CspConnectsToObservabilityTest.php` +- `apps/app/src/observability/__tests__/scrubber.spec.ts` +- `apps/app/src/observability/__tests__/contextBinding.spec.ts` + +**Documenten:** + +- [`RFC-WS-7-OBSERVABILITY.md`](./RFC-WS-7-OBSERVABILITY.md) — + historische implementation-spec +- [`GLITCHTIP.md`](./GLITCHTIP.md) — operational runbook +- [`runbooks/observability-triage.md`](./runbooks/observability-triage.md) — + incoming-issue triage procedure +- [`runbooks/observability-erasure.md`](./runbooks/observability-erasure.md) — + GDPR Art. 17 procedure +- [`SECURITY_AUDIT.md`](./SECURITY_AUDIT.md) — A13-9 (CSP) + WS-7 + finale entry (processing register, security controls) +- [`BACKLOG.md`](./BACKLOG.md) — OBS-* entries (active + resolved) +- [`ARCH-FORM-BUILDER.md`](./ARCH-FORM-BUILDER.md) — Form Builder runtime + (consumer of §5.4 exception classification) +- [`ARCH-BINDINGS.md`](./ARCH-BINDINGS.md) — apply pipeline (origin of + the runtime exceptions captured in §5.4) +- [`RFC-WS-6.md`](./RFC-WS-6.md) — WS-6 binding pipeline design diff --git a/dev-docs/BACKLOG.md b/dev-docs/BACKLOG.md index 52386777..e3688f18 100644 --- a/dev-docs/BACKLOG.md +++ b/dev-docs/BACKLOG.md @@ -966,6 +966,7 @@ ARCH-discussie en RFC. - ~~**WS-TOOLING-001**: Claude Code deterministic guard-rail layer (5 hooks, `crewli-reviewer` subagent op Opus 4.7, 3 slash commands `/sprint-status` `/review-multitenancy` `/sync-docs`, `dev-docs/CLAUDE_CODE_TOOLING.md`). 8/8 smoke tests groen, live integratie geverifieerd. Merge `ad36c06` op 2026-05-05. Follow-ups: TECH-HOOK-001, TECH-CMD-001, TECH-STYLE-001.~~ ✅ - ~~**TECH-DOCS-APPS-PORTAL-PURGE**: per-file DELETE/REWRITE/KEEP_AND_PURGE matrix uitgevoerd op alle 9 docs uit de oorspronkelijke entry, plus de `post-edit-eslint.sh` hook (out-of-scope vondst uit Phase A). Vijf obsolete docs verwijderd (`.cursor/instructions.md`, `.cursor/ARCHITECTURE.md`, `dev-docs/MASTER_PROMPT_CC.md`, `dev-docs/MASTER_PROMPT_CURSOR.md`, `dev-docs/dev-guide.md` — totaal ~80 KB). Drie herschreven (`SETUP.md`, `101_vue.mdc`, hook-script). Twee chirurgisch gepurgeerd (`102_multi_tenancy.mdc`, `CLAUDE_CODE_TOOLING.md`). Externe verwijzingen in README.md, CLAUDE_DESKTOP_SETUP.md, ARCH-CONSOLIDATION-2026-04.md en VIBE_CODING_CHECKLIST.md mee bijgewerkt. WS-3 PR-C op 2026-05-06. Single SPA, single cookie, single deploy host. WS-3 compleet.~~ ✅ +- ~~**WS-7 Observability — closure (mei 2026)**: 4 PRs gemerged op `feat/ws-7-observability` (infra `5f6fc07`, backend SDK `bdb89a2..0379016`, frontend SDK `bc47783..5c42f27`, docs `754222f..e9da01f`). 1551 backend + 252 frontend tests groen. Acceptance criteria 1-14 voldaan; observability volledig operationeel op `monitoring.hausdesign.nl`. Implementation criteria 3, 4, 5, 6, 8, 11, 12, 13, 14 via PRs; operationele criteria 1, 2, 7, 9, 10 via deploy-checklist (DNS, TLS, superuser+2FA, prod DSNs, email-alerting, retention 90d, cron backup). Architecturale patronen vastgelegd in `dev-docs/ARCH-OBSERVABILITY.md` (730 regels) + 2 runbooks (`observability-triage.md`, `observability-erasure.md`). Twee GlitchTip projecten (`crewli-api` + `crewli-app`), één DSN per project, runtime context-split via `actor_scope` tag. Patronen: explicit > implicit listener registration, default-in-listener / override-in-middleware voor binary tags, tenant resolution chain (route-param → portal-token → super_admin platform → user fallback). Volgsporen: OBS-1, OBS-4, OBS-6, OBS-7, OBS-9 (zie "Observability follow-ups" sectie hieronder).~~ ✅ --- @@ -1638,5 +1639,254 @@ voeden). --- -_Laatste update: April 2026_ -_Voeg nieuwe items toe met prefix: ARCH-, COMM-, OPS-, VOL-, ART-, FORM-, SUP-, DIFF-, APPS-, TECH-, UX-_ +_Laatste update: Mei 2026_ +_Voeg nieuwe items toe met prefix: ARCH-, COMM-, OPS-, VOL-, ART-, FORM-, SUP-, DIFF-, APPS-, TECH-, UX-, OBS-_ + +--- + +## Observability follow-ups (post WS-7 closure) + +> **Status overzicht (mei 2026, na WS-7 closure):** +> +> | Entry | Status | +> |---|---| +> | OBS-1 | Active — wacht op volunteer-rol introductie | +> | OBS-2 | ✅ Resolved (PR-2 architectural-fixes — opgevouwen in AuthScopeContextListener) | +> | OBS-3 | ✅ Resolved (PR-2 architectural-fixes — auto-discovery uit + explicit registratie maakt route-conditional binding overbodig) | +> | OBS-4 | Active — pre-PHPUnit 12 cleanup | +> | OBS-5 | ✅ Resolved (PR-2 smoke-test fix `48f2a00` + `ExceptionReportingTest`) | +> | OBS-6 | Active — wachten op SETUP.md update of CI smoke check | +> | OBS-7 | Active — coverage-test scope-creep, expanded coverage waardevol | +> | OBS-8 | ✅ Resolved (final hardening commits `215405a`, `a939820`, `dee1401`) | +> | OBS-9 | Active — alleen relevant bij staging-introductie | + +### OBS-1 — Promote ActorType::VOLUNTEER when volunteer role is introduced + +**Aanleiding:** WS-7 PR-2 architectural-fix-commit verwijderde de +`ActorType::VOLUNTEER` enum-case omdat Crewli vandaag geen dedicated +`volunteer` Spatie-rol heeft — vrijwilligers zijn behaviorally bepaald +(users met shift-assignments), niet identitair. De resolver mapt +non-admin authenticated users naar `ORG_MEMBER`. + +**Wat:** Wanneer Crewli een `volunteer` rol invoert (bijv. via +volunteer-onboarding workflow), her-introduceer dan de `VOLUNTEER` +case in `app/Enums/Observability/ActorType.php` en update +`ActorType::resolve()` om de rol te checken vóór `ORG_MEMBER`. Update +ook `AuthScopeContextListenerTest` met een `actor_type=volunteer` +testcase. + +**Prioriteit:** Laag — wachten op een product-besluit over volunteer-rol +modellering. Geen blocker. + +**Refs:** `app/Enums/Observability/ActorType.php`, +RFC-WS-7-OBSERVABILITY.md §3.6. + +### OBS-2 — Early-pipeline log context user_id ✅ Resolved + +**Aanleiding:** Tijdens PR-2 architectural-fixes ontdekt dat +`Log::withContext` in `BindRequestLogContext` middleware geen +`user_id` had voor log-regels die vóór auth:sanctum draaiden, omdat +de middleware run op api-group-level (vóór per-route auth). + +**Status:** Resolved in PR-2 architectural-fixes. `AuthScopeContextListener::bindForUser()` +roept `Log::withContext(['user_id' => $user->id, ...])` aan zodra het +`Authenticated` of `TokenAuthenticated` event vuurt. Vanaf dat punt +in de request-pipeline dragen alle log-regels `user_id`. Pre-auth +log-regels (CSRF / rate-limiter middleware logs) hebben nog steeds +geen `user_id`, maar dat is correct gedrag — er is op dat moment ook +nog geen geauthenticeerde gebruiker. + +**Refs:** `app/Listeners/Observability/AuthScopeContextListener.php`, +`app/Http/Middleware/BindRequestLogContext.php`. + +### OBS-3 — Sentry-context middleware coverage assertion ✅ Resolved + +**Aanleiding:** PR-2 originele design had de Sentry-context binding +als route-level middleware aliased per route group. Risico: een +nieuwe route-group die de alias vergeet had silently geen +auth-scope tags op gecaptured events. + +**Status:** Resolved in PR-2 architectural-fixes. De binding is +verplaatst van middleware naar `AuthScopeContextListener` op het +`Authenticated` / `TokenAuthenticated` event. Listener fires +universeel op élke auth-resolution; geen route-conditional binding +meer mogelijk. Daarmee is een coverage-assertion test overbodig — de +listener kan niet "vergeten" worden zoals een route alias dat kon. + +Combineer met OBS-8 (auto-discovery uit + explicit registration via +`EventListenerRegistrationTest`): listener-aanwezigheid is +empirisch gevalideerd op test-niveau. + +**Refs:** `app/Listeners/Observability/AuthScopeContextListener.php`, +`tests/Feature/Observability/EventListenerRegistrationTest.php`. + +### OBS-4 — PHPUnit metadata-in-doc-comment deprecation cleanup + +**Aanleiding:** PHPUnit warnt dat metadata in doc-comments (zoals +`@test`, `@dataProvider`) deprecated is en in PHPUnit 12 verwijderd +wordt. Crewli heeft drie tests met deze pattern: + +- `Tests\Unit\Support\Json\JsonCanonicalizerTest::test_scalar_passthrough()` +- `Tests\Feature\FormBuilder\Purposes\PurposeSchemaLifecycleTest::test_create_and_publish_succeeds_for_purpose()` +- `Tests\Feature\Schema\UlidPrimaryKeyTest::test_model_uses_has_ulids_and_generates_crockford_ulid()` + +**Wat:** Vervang de doc-comment metadata door PHPUnit attributes +(bijv. `#[Test]`, `#[DataProvider]`). Raak alleen aan vóór de PHPUnit 12 +upgrade gepland wordt — nu blokkeert het niets. + +**Prioriteit:** Laag — kosmetisch totdat PHPUnit 12 upgrade landt. + +**Refs:** PHPUnit changelog, de drie genoemde test-files. + +### OBS-5 — Crewli render handlers report() invariant ✅ Resolved + +**Aanleiding:** PR-2 smoke test toonde dat backend-exceptions geen +events naar GlitchTip stuurden ondanks correct geconfigureerde SDK. +Root cause: sentry-laravel 4.x heeft de `Integration::handles($exceptions)` +registratie-stap die NIET wordt auto-aangeroepen door de package's +ServiceProvider; de host-app moet dit expliciet doen in +`bootstrap/app.php`. + +**Status:** Resolved via commit `48f2a00` +`fix: route controller exceptions through sentry-laravel reporter` ++ `tests/Feature/Observability/ExceptionReportingTest.php` als +regression-guard. ExceptionReportingTest installeert een recording +`before_send` hook en verifieert dat `RuntimeException` wel, +`ValidationException`/`AuthorizationException`/`NotFoundHttpException` +niet captured worden — exact de boundary uit RFC §3.10. + +OBS-7 (hieronder) breidt deze coverage uit naar Crewli's eigen +render handlers. + +**Refs:** `bootstrap/app.php`, +`tests/Feature/Observability/ExceptionReportingTest.php`, +RFC-WS-7-OBSERVABILITY.md §3.10. + +### OBS-6 — sentry-laravel installation gap awareness + +**Aanleiding:** WS-7 PR-2 smoke test faalde silent omdat sentry-laravel +4.x de `Integration::handles($exceptions)` registratie niet +auto-registreert in zijn ServiceProvider. De host-app moet de regel +expliciet aan `bootstrap/app.php` toevoegen. README documenteert dit, +maar tijdens `composer require sentry/sentry-laravel` + +`php artisan sentry:publish` workflow is het makkelijk te missen. + +**Wat:** + +- Voeg een waarschuwing toe in `dev-docs/SETUP.md` onder een nieuwe + sectie "Laravel package installation patterns": bij elke nieuwe + package altijd verifiëren dat het package zijn ServiceProvider- + registraties doet voor exception handlers, queue listeners, en log + channels — niet alleen voor routes/views/migrations. +- Overweeg een `tests/Feature/Bootstrap/ExceptionHandlerRegistrationTest.php` + die `app(\Illuminate\Foundation\Exceptions\Handler::class)->getReportableCallbacks()` + introspecteert en assertert dat sentry-laravel's callback + geregistreerd is. Vangt een toekomstige refactor die per ongeluk + `Integration::handles` uit `bootstrap/app.php` verwijdert. + +**Prioriteit:** Laag — fix is gedaan en getest, regression mogelijk +maar onwaarschijnlijk gezien de explicit comment in `bootstrap/app.php`. + +**Refs:** `bootstrap/app.php`, +`vendor/sentry/sentry-laravel/src/Sentry/Laravel/Integration.php`, +RFC-WS-7-OBSERVABILITY.md §3.10. + +### OBS-7 — Custom $exceptions->render() handlers report() invariant + +**Aanleiding:** WS-7 PR-2 smoke-test debugging onthulde dat Crewli's +`bootstrap/app.php` 5 custom render handlers heeft. Met +`Integration::handles($exceptions)` geregistreerd werkt +report-before-render correct. Maar een toekomstige render handler die +een Throwable consumeert zonder `report($e)` aan te roepen vóór return +zou Sentry-capture kunnen overslaan voor die exception class. + +**Wat:** + +- Documenteer in `bootstrap/app.php` (boven het withExceptions block) + een comment: "Render handlers consume exceptions; Laravel's + ExceptionHandler::handle() doet report() vóór render() zodat capture + automatisch is. NIEUWE render handlers MOGEN NIET short-circuiten + voordat report() bereikt is. Verifieer via + tests/Feature/Observability/ExceptionReportingTest.php." +- Uitbreiden van `ExceptionReportingTest.php` met assertions per + bestaande render handler class: throw die exception, assert event + captured. + +**Prioriteit:** Medium — bestaande handlers zijn correct, maar het +invariant is subtiel en silent-failure-prone bij toevoegingen. + +**Refs:** `bootstrap/app.php`, +`tests/Feature/Observability/ExceptionReportingTest.php`, +RFC-WS-7-OBSERVABILITY.md §3.10. + +### OBS-8 — Observability listener double-registration via auto-discovery + explicit Event::listen ✅ Resolved + +**Aanleiding:** Bert's live verification na de Sanctum-bearer-token fix +(`adab3be`) toonde via `php artisan event:list` dat de +`AuthScopeContextListener@handleTokenAuthenticated` binding twee keer +geregistreerd stond op `Laravel\Sanctum\Events\TokenAuthenticated` — +één keer via Laravel 12's default listener auto-discovery +(reflection-scan van `app/Listeners/**` op type-hint), één keer via de +expliciete `Event::listen()` call in `AppServiceProvider::boot()`. +Listener firede twee keer per Sanctum-auth. Idempotent vandaag (scope- +tag overwrite-semantiek), maar architecturaal onacceptabel. + +Plus aanverwant: de `Authenticated` listener-registratie was via +class-string in plaats van array-callable, waardoor `event:list` de +gebonden methode niet toonde. En `impersonation.active` als binary tag +ontbrak op non-impersonation events (RFC §3.6 vereist always-present +binary signal). + +**Status (mei 2026):** Resolved via: + +- `215405a` `fix: disable Laravel listener auto-discovery; explicit registrations only` + → `->withEvents(discover: false)` in `bootstrap/app.php`; alle observability + listeners expliciet via array-callable (`[Class::class, 'method']`) in + `AppServiceProvider::boot()`. +- `a939820` `fix: impersonation.active default tag for non-impersonation authenticated events` + → baseline `'false'` in `AuthScopeContextListener::bindForUser()`, + override naar `'true'` in `HandleImpersonation` middleware (default-in- + listener, override-in-middleware pattern). +- Commit 3 (deze sessie): `tests/Feature/Observability/EventListenerRegistrationTest.php` + introspecteert `Event::getRawListeners()` en faalt bij count != 1; plus + always-present binary tag invariant test op een live HTTP flow. + +Verified via `php artisan event:list`: elke observability listener exact +één keer geregistreerd, met `@method` binding zichtbaar. + +**Architecturaal pattern dat dit vastlegt:** explicit > implicit voor +observability-kritische bindings. Toekomstige listeners die op een +event mounten worden expliciet in `AppServiceProvider::boot()` +geregistreerd; auto-discovery is uitgeschakeld zodat silent double- +registration niet meer kan voorkomen. + +**Refs:** `bootstrap/app.php`, `app/Providers/AppServiceProvider.php`, +`tests/Feature/Observability/EventListenerRegistrationTest.php`, +RFC-WS-7-OBSERVABILITY.md §3.6. + +### OBS-9 — Staging environment GlitchTip CSP whitelist + +**Aanleiding:** PR-3 CSP-fix whitelist alleen `localhost:8200` (dev) en +`monitoring.hausdesign.nl` (prod) hard-coded in respectievelijk +`apps/app/index.html` meta-tag en `deploy/nginx/csp-spa.conf`. Wanneer +een staging-omgeving wordt geïntroduceerd (RFC §3.3 noemt +`.env.staging` als voorbeeld), zal de bijbehorende GlitchTip-host +niet ge-whitelist zijn — events worden dan stilletjes geblokkeerd +door browser-CSP zonder waarschuwing aan de test-runner. + +**Wat:** + +- Bij staging-introductie: voeg staging GlitchTip-host toe aan + `deploy/nginx/csp-spa.conf` óf maak `apps/app/index.html` meta-CSP + environment-aware via Vite build-time injection (vergelijkbaar met + `VITE_SENTRY_DSN_FRONTEND` patroon). +- Update `tests/Feature/Security/CspConnectsToObservabilityTest.php` + met staging-assertion zodat de regression-guard de nieuwe environment + dekt. + +**Prioriteit:** Laag — alleen relevant wanneer staging-omgeving wordt +opgezet. + +**Refs:** `apps/app/index.html`, `deploy/nginx/csp-spa.conf`, +`tests/Feature/Security/CspConnectsToObservabilityTest.php`, +RFC-WS-7-OBSERVABILITY.md §3.3, ARCH-OBSERVABILITY.md §7 + §10.4. diff --git a/dev-docs/GLITCHTIP.md b/dev-docs/GLITCHTIP.md new file mode 100644 index 00000000..246e97fc --- /dev/null +++ b/dev-docs/GLITCHTIP.md @@ -0,0 +1,283 @@ +# GlitchTip — operations runbook + +Self-hosted error tracking for Crewli. GlitchTip implements the Sentry +event protocol; the official Sentry SDKs (`sentry-laravel`, `@sentry/vue`, +`@sentry/cli`) work against it without modification. + +Reference: [`RFC-WS-7-OBSERVABILITY.md`](./RFC-WS-7-OBSERVABILITY.md). + +This file documents how to run the stack — locally and on the production +monitoring host. PR-2 (backend SDK) and PR-3 (frontend SDK) consume DSNs +provisioned via the steps below. + +--- + +## 1. Overview + +| Service | Image | Role | +|---------|-------|------| +| `glitchtip-web` | `glitchtip/glitchtip:6.1.6` | Django web UI + ingest API | +| `glitchtip-worker` | `glitchtip/glitchtip:6.1.6` | Celery worker + beat (event processing, alerts, partition maintenance) | +| `glitchtip-postgres` | `postgres:16-alpine` | Primary datastore | +| `glitchtip-redis` | `valkey/valkey:7-alpine` | Celery broker + cache | + +The same `docker-compose.glitchtip.yml` runs both locally (merged with +`docker-compose.yml`) and on the production host (standalone). Container +names are identical in both environments to avoid configuration drift. + +--- + +## 2. Local development + +```bash +# Once +cp docker/glitchtip/.env.example docker/glitchtip/.env + +# Boot the full stack (MySQL, Redis, Mailpit, GlitchTip) +make services + +# First boot takes ~60s while migrations run. Tail progress: +make services-glitchtip-status +``` + +Web UI: . Outbound mail goes to Mailpit +(`http://localhost:8025`). + +Create the first admin user: + +```bash +docker exec -it glitchtip-web ./manage.py createsuperuser +``` + +Stop the stack with `make services-stop`. Volumes (`glitchtip_postgres_data`, +`glitchtip_redis_data`, `glitchtip_uploads`) survive a stop. Wipe with +`docker compose -f docker-compose.yml -f docker-compose.glitchtip.yml down -v` +— **never on production**. + +--- + +## 3. Project provisioning + +Once the web UI is reachable and the superuser exists: + +1. Sign in at `/`. +2. Create an Organization called **Crewli**. +3. Create two projects: + - **`crewli-api`** — platform: Python / Django, alert rules: default. + - **`crewli-app`** — platform: JavaScript / Vue, alert rules: default. +4. For each project, copy the auto-generated DSN from + *Settings → Client Keys (DSN)*. +5. Store both DSNs in 1Password under `Crewli / GlitchTip / DSNs`: + - `SENTRY_DSN_BACKEND` ← `crewli-api` DSN + - `SENTRY_DSN_FRONTEND` ← `crewli-app` DSN + +PR-2 wires `SENTRY_DSN_BACKEND` into `api/.env.example`; PR-3 wires +`SENTRY_DSN_FRONTEND` into `apps/app/.env.example`. Empty DSN = SDK no-op +(verified for both `sentry-laravel` and `@sentry/vue`), so dev environments +without a DSN are silent. + +--- + +## 4. Production deployment + +GlitchTip runs on a separate host (`monitoring.hausdesign.nl`) and is **not** +deployed via the Crewli `deploy.sh` pipeline. + +### 4.1 Prerequisites + +- Docker + Docker Compose v2 on the monitoring host. +- DirectAdmin with the Let's Encrypt module enabled. +- DNS A-record `monitoring.hausdesign.nl` pointing at the host IP. + +### 4.2 Place the stack + +```bash +sudo install -d -o crewli -g crewli /opt/glitchtip +sudo install -d -o crewli -g crewli /opt/glitchtip/docker/glitchtip + +# Copy compose file + env example to the host (e.g. via scp or git checkout). +# /opt/glitchtip/docker-compose.glitchtip.yml +# /opt/glitchtip/docker/glitchtip/.env.example +``` + +### 4.3 Configure `.env` + +```bash +cd /opt/glitchtip +cp docker/glitchtip/.env.example docker/glitchtip/.env +chmod 0600 docker/glitchtip/.env +``` + +Fill in the production values (header of `.env.example` lists the +checklist): + +```env +SECRET_KEY= +DATABASE_URL=postgres://postgres:@glitchtip-postgres:5432/glitchtip +POSTGRES_PASSWORD= # MUST match the password in DATABASE_URL +GLITCHTIP_DOMAIN=https://monitoring.hausdesign.nl +DEFAULT_FROM_EMAIL=glitchtip@hausdesign.nl +EMAIL_URL=smtp+tls://USER:PASSWORD@HOST:PORT +``` + +Source the `` password from the 1Password vault. + +### 4.4 DNS + TLS + +1. Create the A-record for `monitoring.hausdesign.nl` in DNS. +2. In DirectAdmin: add the subdomain, then enable Let's Encrypt + (Domain Setup → SSL Certificates → "Free & automatic certificate from + Let's Encrypt"). Wait for the cert to issue. + +### 4.5 Apache reverse proxy + +DirectAdmin generates the vhost. Add a custom config (DirectAdmin → +Custom HTTPD Configurations) for the `monitoring.hausdesign.nl` HTTPS +vhost: + +```apache +ProxyPreserveHost On +ProxyRequests Off +ProxyPass / http://127.0.0.1:8200/ +ProxyPassReverse / http://127.0.0.1:8200/ + +# WebSocket upgrade — GlitchTip uses WS for live event streaming. +RewriteEngine On +RewriteCond %{HTTP:Upgrade} websocket [NC] +RewriteCond %{HTTP:Connection} upgrade [NC] +RewriteRule ^/?(.*) "ws://127.0.0.1:8200/$1" [P,L] +``` + +Reload Apache. + +### 4.6 First boot + +```bash +cd /opt/glitchtip +docker compose -f docker-compose.glitchtip.yml up -d + +# Wait for healthchecks (~60s). +docker compose -f docker-compose.glitchtip.yml ps + +# Create the admin user. +docker exec -it glitchtip-web ./manage.py createsuperuser +``` + +Open , sign in, and **enable 2FA** on +the account immediately (acceptance criterion 1). Profile → Security → +Two-Factor Authentication. + +Then provision the two projects (§3) and capture DSNs into 1Password. + +--- + +## 5. Backup & restore + +### 5.1 Daily backup + +`scripts/glitchtip-backup.sh` runs `pg_dump --format=custom`, streams it +through gzip, writes to `./backups/glitchtip/glitchtip-.dump.gz` with +`0600` permissions, and prunes dumps older than 30 days. + +Install the cron entry on the production host: + +```cron +# /etc/cron.d/glitchtip-backup +0 3 * * * crewli /opt/crewli/scripts/glitchtip-backup.sh >> /var/log/glitchtip-backup.log 2>&1 +``` + +(Replace `/opt/crewli` with wherever the Crewli repo checkout lives on +the monitoring host. The script is portable — only the `docker exec` +target container needs to exist.) + +The script exits non-zero on dump failure so cron's `MAILTO` catches +silent regressions. + +### 5.2 Restore drill + +```bash +# Pick the dump to restore from. +DUMP=./backups/glitchtip/glitchtip-20260506-030000.dump.gz + +# Stream the restore into the postgres container. +gunzip < "$DUMP" \ + | docker exec -i glitchtip-postgres pg_restore \ + -U postgres -d glitchtip --clean --if-exists +``` + +`--clean --if-exists` drops existing objects before recreating them, so +the database ends up exactly as it was at dump time. Run after a +`docker compose stop glitchtip-web glitchtip-worker` to avoid concurrent +writes during the restore. + +Bert should drill the restore at least once after the production stack +is live (acceptance criterion 11). + +--- + +## 6. Monitoring the monitor + +Quick smoke tests: + +```bash +# API responds with JSON (not 502). +curl -sS http://localhost:8200/api/0/ + +# Worker reporting in (look for "celery@... ready"). +docker compose -f docker-compose.yml -f docker-compose.glitchtip.yml \ + logs --tail=50 glitchtip-worker + +# All services healthy. +docker compose -f docker-compose.yml -f docker-compose.glitchtip.yml ps +``` + +In production, replace `localhost:8200` with `https://monitoring.hausdesign.nl`. +Email-alerting is configured in PR-4; until then alerts surface only in +the GlitchTip web UI (Issues view). + +--- + +## 7. Troubleshooting + +### Web container unhealthy on first boot + +Migrations take ~60s on a fresh volume. The healthcheck `start_period` +is set accordingly. If the container is still unhealthy after two +minutes, tail logs: + +```bash +docker logs glitchtip-web +``` + +Most common cause: `DATABASE_URL` password ≠ `POSTGRES_PASSWORD`. The +postgres container creates the user with the password it sees, GlitchTip +authenticates with the password embedded in the URL — they MUST match. + +### Worker idle / events stuck in queue + +Check that `REDIS_URL` resolves and the worker is connected: + +```bash +docker logs glitchtip-worker | grep -E "ready|connected|error" +``` + +### Volume permission errors on Linux hosts + +`postgres:16-alpine` runs as UID 70 internally. If `/var/lib/postgresql/data` +is bind-mounted from the host with mismatched ownership, postgres refuses +to start. The default named volume avoids this — only relevant if you +later switch to a host bind-mount. + +### Right-to-erasure (Art. 17) + +Currently manual. Locate events for a user ULID via the web UI search, +delete via the UI or directly on the postgres container. An automated +erasure script is on the BACKLOG (per RFC §4). + +--- + +## 8. References + +- RFC: [`RFC-WS-7-OBSERVABILITY.md`](./RFC-WS-7-OBSERVABILITY.md) +- GlitchTip docs: +- GlitchTip self-hosting: diff --git a/dev-docs/RFC-WS-7-OBSERVABILITY.md b/dev-docs/RFC-WS-7-OBSERVABILITY.md new file mode 100644 index 00000000..e4520d0e --- /dev/null +++ b/dev-docs/RFC-WS-7-OBSERVABILITY.md @@ -0,0 +1,280 @@ +# RFC-WS-7 — Observability foundation (GlitchTip) + +**Status:** Approved — implementation-ready +**Workstream:** WS-7 (consolidatie-sprint mei 2026) +**Voorgangers:** ARCH-CONSOLIDATION-2026-04 §3 besluit 8, §6.7 — ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 D-06 +**Opvolgers:** ARCH-API-VALIDATION (geblokkeerd door WS-7), WS-8b (`ARCH-OBSERVABILITY.md`) +**Doorlooptijd:** 4-5 dagen (charter zei 2-3; revisie wegens PII-scrubbing scope) + +--- + +## 1. Doel + +Crewli draait in productie zonder geautomatiseerde error-detectie. Stack traces alleen via SSH+grep; frontend errors onzichtbaar tenzij gerapporteerd; geen release-correlatie of alerting. WS-7 levert die foundation. + +--- + +## 2. Charter-amendementen + +Twee afwijkingen van charter §3 besluit 8, beide bewust: + +**A. Sentry → GlitchTip.** Self-hosted = data binnen perimeter (Crewli verwerkt special category data: dietary, medical, accreditation passport — geen externe processor toevoegen aan het processing register). GlitchTip implementeert het Sentry-event-protocol; `sentry-laravel`, `@sentry/vue`, `@sentry/cli` werken zonder modificatie. Switchen naar Sentry SaaS later is mogelijk zonder app-wijzigingen. + +**B. Performance monitoring uit scope.** Charter zei "Sentry Performance in prod". Deze RFC scope = errors-only. Performance-tracing pakken we later op wanneer er een concrete vraag ontstaat. + +--- + +## 3. Architectuur + +### 3.1 Hosting + +Self-hosted GlitchTip op productie VPS via Docker Compose (`glitchtip-web`, `glitchtip-worker`, `glitchtip-postgres`, `glitchtip-redis`). Reverse proxy via DirectAdmin Apache; SSL via DirectAdmin Let's Encrypt op `monitoring.hausdesign.nl` (consistent met bestaande subdomain-pattern). + +**Lokale ontwikkeling:** dezelfde `docker-compose.glitchtip.yml` draait lokaal als `make services` (gecombineerd met de bestaande `docker-compose.yml` via `-f`). Web-UI op `http://localhost:8200`, e-mail naar Mailpit op `bm_mailpit:1025`. Dev-stack en prod-stack delen één compose-file zodat configuratie-drift uitgesloten is. + +### 3.2 Twee projecten / DSNs + +- `crewli-api` — Laravel +- `crewli-app` — apps/app SPA (single-SPA na WS-3) + +Twee aparte projecten omdat issue-ownership (frontend vs backend) helder moet zijn én omdat scrubbing-rules per project verschillen. + +Binnen `crewli-app` werken we met een runtime context-split via `beforeSend`-hook: routes onder `/p/*` (token-based, artist/supplier/press) krijgen strictere scrubbing en geen user-context. Organizer-routes en `/platform/*` krijgen volledige auth-context. Eén SDK, twee zones. + +### 3.3 Environment gating + +Lege DSN = SDK no-op (bevestigd voor zowel `sentry-laravel` als `@sentry/vue`). Geen runtime-check in app-code nodig. + +``` +# .env.development → DSNs leeg +# .env.staging → DSNs gevuld, GLITCHTIP_ENVIRONMENT=staging +# .env.production → DSNs gevuld, GLITCHTIP_ENVIRONMENT=production +``` + +### 3.4 Release identifier + +Format `@` (`crewli-api@f41951a`, `crewli-app@f41951a`). Bron: `git rev-parse --short HEAD` als build-time env var, geïnjecteerd in `deploy.sh` per app-build. + +### 3.5 Source maps + +`vite build` produceert sourcemaps → `@sentry/cli sourcemaps upload` push naar GlitchTip → `.map` bestanden **verwijderd uit `dist/`** vóór nginx ze serveert. Geen public-mapped sources op productie. Per-project upload-only auth-token in deploy environment. + +### 3.6 Context tagging + +Tag-binding gebeurt op twee plekken: route-scope tags via `BindSentryRouteContext` middleware (op de api-group), auth-scope tags via `AuthScopeContextListener` op `Illuminate\Auth\Events\Authenticated`. De split volgt de data-bron: route-context is alleen tijdens HTTP-handling beschikbaar, auth-context wordt door elke authenticator (Sanctum, portal-token) ge-emit via het Authenticated event. + +| Tag | API | apps/app | Bron / locatie | +|---|---|---|---| +| `release` | altijd | altijd | env, sentry-laravel built-in | +| `environment` | altijd | altijd | env, sentry-laravel built-in | +| `app` | `api` | `app` | route-middleware | +| `route_name` | altijd | altijd | route-middleware | +| `http.method` | altijd | n.v.t. | route-middleware | +| `actor_scope` | `organisation`/`platform`/`user`/`anonymous` | mirror | auth-listener (zie hieronder) | +| `organisation_id` (ULID) | aanwezig wanneer `actor_scope = organisation` | uit auth store | auth-listener | +| `event_id` (ULID) | wanneer event-scoped | wanneer applicabel | auth-listener (via {event} route-param) | +| `user_id` (ULID) | wanneer authenticated | uit auth store, alleen session-mode | auth-listener | +| `actor_type` | `organizer_admin` / `super_admin` / `portal_token` / `org_member` / `unauthenticated` | mirror | auth-listener | +| `impersonation.active` | bool | n.v.t. | HandleImpersonation middleware (post-swap) | +| `impersonation.impersonator_user_id` | wanneer actief | n.v.t. | HandleImpersonation middleware | +| `impersonation.session_id` | wanneer actief | n.v.t. | HandleImpersonation middleware | +| `queue.attempt` | binnen job-context | n.v.t. | TagJobAttemptOnSentry listener | + +**Nooit als tag:** email, telefoon, naam, IP-adres, raw form_value content, raw cookie content. + +**Multi-tenant invariant (verfijnd na PR-2 live smoke test):** + +`actor_scope` is altijd aanwezig op authenticated events. Wanneer `actor_scope = organisation`, MOET `organisation_id` aanwezig en valide ULID zijn. Wanneer `actor_scope = platform`, IS `organisation_id` afwezig — dat is correct gedrag voor super_admin platform-routes (geforceerde org-attribution zou misleidend zijn). Wanneer `actor_scope = user` (default authenticated zonder org-route-context), is `organisation_id` ook afwezig: Crewli's User↔Organisation is many-to-many, een single-org "current org" bestaat niet op user-niveau, en attribution aan een willekeurige org zou misleiden. Een unit-test in `AuthScopeContextListenerTest::test_organisation_id_present_when_actor_scope_is_organisation` verifieert deze invariant. + +**`actor_scope`-waarden:** + +| Waarde | Wanneer | Filtering use-case in GlitchTip | +|---|---|---| +| `organisation` | route met {organisation} of {event} param, of portal-token request | "Issues voor organisatie X" | +| `platform` | super_admin op `admin.*` named routes | "Platform-bugs (niet org-specifiek)" | +| `user` | authenticated user op routes zonder org-scope (`/me/*`, `/portal/my-shifts`, `/uploads/*` etc.) | "Issues op user-routes; geen org-attribution" | +| `anonymous` | unauthenticated requests | "Public-route issues" | + +**Wijziging t.o.v. originele RFC:** de oorspronkelijke formulering "élke captured event uit een geauthenticeerde controller MOET `organisation_id` hebben" is verfijnd na bevinding dat super_admin platform-routes geen zinvolle org-context hebben en Crewli's many-to-many user-org model geen reliable single-org hint biedt. Het invariant is nu sterker: niet "altijd aanwezig" maar "altijd correct gerelateerd aan `actor_scope`." + +### 3.7 PII scrubbing + +**Backend (`before_send` in sentry-laravel):** +1. Request body keys (recursief, key-name match): `password`, `password_confirmation`, `current_password`, `token`, `api_key`, `secret`, `webhook_secret`, `dsn`, `signature`, `authorization`, `cookie`, `bearer`, `iban`, `bic`, `passport_number`, `bsn`. +2. Headers: `Authorization`, `Cookie`, `Set-Cookie`, `X-API-Key`, `X-Impersonation-Token`. +3. Form submissions: élke payload met `form_values` → recursive value-replace met `[scrubbed]`. Form values zijn definitionally PII. +4. URL query string: scrub `?token=...`, `?api_key=...`. +5. `send_default_pii = false` globaal (strips locals uit stack traces). + +**Frontend (`beforeSend` in @sentry/vue):** +1. Cookies via `document.cookie` exposure: scrub. +2. localStorage / sessionStorage: nooit in event context (portal-state in sessionStorage MAG NIET lekken). +3. **Mask-all op input breadcrumbs.** Sentry default maskt alleen passwords; wij masken alle `` values via breadcrumb-integratie config. Crewli heeft email/phone/IBAN/dietary/medical als plain `` — selectief masken is niet veilig genoeg. +4. URL query string: zelfde patronen. +5. Console messages: `Sentry.consoleLoggingIntegration` uitgeschakeld in productie. + +**Verificatie — verplicht in CI:** +- `tests/Feature/Observability/PiiScrubbingTest.php` — mock transport, assert dat sensitive keys gescrubd zijn. +- `apps/app/src/__tests__/sentry-scrub.test.ts` — assert form-input breadcrumb-scrubbing en `/p/*` route context-strip. + +Tests blokkeren merge als scrubbing regresseert. Geen "manuele inspectie" als verificatie. + +### 3.8 User context + +`Sentry::setUser([...])`: `id` = ULID, `username` = ULID (duplicate, nooit email), `ip_address` = `null`. `send_default_pii = false`. + +### 3.9 Sampling + +Errors 100% (self-hosted, geen kostenreden). Performance/Profile niet van toepassing (uit scope per §2 amendement B). Wel een rate-limit op event-volume per project (GlitchTip default 10k/min/project) tegen runaway-errors. + +### 3.10 Boundary met bestaande systemen + +GlitchTip captureert programmer errors en infrastructure failures. Het captureert **niet** verwachte business-uitkomsten (failed payment, failed identity match, dead-letter webhook delivery — die hebben eigen audit-tabellen). Bestaande systemen blijven onaangeroerd: Telescope (dev), activity_log (audit trail), impersonation_audit_logs (security audit), form_webhook_deliveries (delivery audit), Laravel default log (operationeel). + +Concreet voor webhooks: `form_webhook_deliveries.dead_letter` is de audit-record. GlitchTip vuurt alleen bij programmeerfouten (TypeError, missing config), niet wanneer receiver een 500 retourneert. + +### 3.11 Queue-worker integratie + +Laravel SDK auto-captureert vanuit queue jobs. Job-attempt-number als tag (`queue.attempt`) voor "issues die uiteindelijk slagen" vs "altijd falend". Stack-trace-grouping (default) is correct gedrag voor idempotente retries — niet per attempt fingerprinten. + +### 3.12 Alerting + +Initieel: email-naar-Bert op nieuwe issue / regression / spike (>10 events in 5min, tune na eerste week). Slack-integratie naar BACKLOG. + +### 3.13 Structured logging conventie (charter §6.7) + +`BindRequestLogContext` middleware op de `api` route group: + +```php +Log::withContext([ + 'request_id' => $request->header('X-Request-Id') ?? Str::ulid(), + 'organisation_id' => $request->user()?->organisation_id, + 'user_id' => $request->user()?->id, + 'route' => $request->route()?->getName(), +]); +``` + +`request_id` retour als response header `X-Request-Id`. apps/app axios interceptor genereert client-side een ULID per request en stuurt mee als header; server respecteert headerwaarde. Dit geeft front-to-back correlation in één klik vanuit GlitchTip-UI naar log-segment op de host. + +### 3.14 Activity_log indexes (addendum D-06) + +`(subject_type, subject_id)` en `(causer_type, causer_id)` composite indexes op `activity_log`. Infrastructure-housekeeping; geen functionele wijziging. + +**Status (mei 2026, na PR-2):** Bij implementatie bleek dat de Spatie activitylog default-migratie via `nullableMorphs('subject')` en `nullableMorphs('causer')` deze composite indexes al aanmaakt (`subject` op `(subject_type, subject_id)`, `causer` op `(causer_type, causer_id)`). Geen aparte migratie nodig — geverifieerd via `information_schema.STATISTICS`. Acceptance criterium 12 daarmee al voldaan vóór WS-7 begon. Regression-guard: `tests/Feature/Database/ActivityLogIndexesTest.php` faalt wanneer een toekomstige refactor deze indexes verwijdert. + +--- + +## 4. Privacy / GDPR + +**Data na scrubbing:** alleen ULIDs, stack traces, route-namen, gecureerde tags, request_id. Scrubbing per §3.7 is de primaire control; tests in §3.7 zijn merge-blockers. + +**Controller / processor:** Self-hosted op Crewli-infra → Crewli is controller, niet processor. Geen DPA-uitbreiding. + +**Right to erasure (Art. 17):** Zoek events op `user.id = `, verwijder via GlitchTip-API of directe SQL op glitchtip-postgres. Initieel handmatig; geautomatiseerd erasure-script naar BACKLOG. + +**Retention:** 90 dagen, daarna purged. + +**Processing register-entry:** Crewli Error Tracking (GlitchTip) — defectdetectie en service-availability monitoring — pseudonieme identifiers + technische metadata — recipient: Bert — retention 90 dagen — TLS in transit, full-disk encryption at rest, SSH-key + 2FA op web-UI. + +**SECURITY_AUDIT.md update** in PR-4: GlitchTip als processing entry, scrubbing-config als security control, erasure-procedure als GDPR-runbook. + +--- + +## 5. Vastgelegde defaults + +Geen open vragen meer; deze zijn vastgesteld: + +- DirectAdmin Let's Encrypt voor `monitoring.hausdesign.nl` (consistent met bestaande subdomains). +- Retention: 90 dagen. +- Email-alerting initieel; Slack naar BACKLOG. +- Mask-all op frontend input breadcrumbs. +- 2FA verplicht op GlitchTip web-UI. +- Daily postgres-backup naar zelfde target als Crewli main-DB. +- Capture failures-to-write-activity-log (silent failures zijn slecht). +- Impersonation: zowel impersonator als target user_id getagd (per §3.6 tabel). +- Big-bang structured logging: `BindRequestLogContext` middleware in PR-2, geen call-site-wijzigingen. +- Mock transport voor scrubbing-tests (geen externe afhankelijkheid in CI). +- Source-map upload-token in deploy `.env` op productie-host (later naar 1Password wanneer CI komt). + +--- + +## 6. Acceptance criteria + +WS-7 is compleet wanneer: + +1. GlitchTip draait op `monitoring.hausdesign.nl` met TLS, alleen toegankelijk voor Bert (2FA aan). +2. Twee projecten aangemaakt; DSNs in vault. +3. Laravel SDK geïntegreerd; errors uit prod-API verschijnen <60s. +4. ✅ apps/app SDK geïntegreerd (PR-3); errors verschijnen met org/user/release context. Token-based portal-routes (`/portal/advance/:token`, `/register/:public_token`) hebben strictere scrubbing en geen user-context. Detectie via `route.meta.public === true && route.meta.context === 'portal'`. Implementatie: `apps/app/src/observability/contextBinding.ts`. +5. ✅ Source-maps upload werkt (PR-3); `deploy.sh` exporteert `VITE_SENTRY_RELEASE` build-time, uploadt via `@sentry/cli` na `vite build`, en verwijdert daarna élke `*.map` uit `dist/` (RFC §3.5: no public-mapped sources). Soft-fail: deploy gaat door als upload faalt, maar de map-strip stap loopt altijd. +6. ✅ PII scrubbing-tests groen (PR-2 backend `PiiScrubbingTest` 20 cases; PR-3 frontend `scrubber.spec.ts` 18 cases mirror). Plus structurele coverage in `EventListenerRegistrationTest` + `AuthScopeContextListenerTest` + `AuthScopeBindingHttpFlowTest`. +7. Smoke test: induced 500 in staging, verifieer dat hij verschijnt met alle verwachte tags én geen PII lekt. +8. ARCH-OBSERVABILITY.md geschreven (WS-8b). +9. Email-alerting geconfigureerd; getest met sample issue. +10. Retention-policy (90 dagen) toegepast. +11. Daily postgres-backup-script in place. +12. ~~Activity_log indexes (addendum D-06) gemigreerd.~~ ✓ — al voldaan door Spatie's `nullableMorphs` default in de originele activitylog migratie; zie §3.14 status-note. Regression-guard: `tests/Feature/Database/ActivityLogIndexesTest.php`. +13. Structured logging conventie geïmplementeerd; `X-Request-Id` round-trip getest. +14. SECURITY_AUDIT.md bijgewerkt. + +**Voortgang (mei 2026, na PR-3):** + +- **PR-1 ✅**: criteria 1, 2, 11 — infra + projecten + backup-script. +- **PR-2 ✅**: criteria 3, 6 (backend), 12, 13 — sentry-laravel + scrubber + structured logging + listener-registration discipline (OBS-8). +- **PR-3 ✅**: criteria 4, 5, 6 (frontend) — `@sentry/vue` SDK + scrubber + Vue Router context-binding + sourcemap upload in `deploy.sh`. +- **Resterend voor WS-7-closure (PR-4)**: criteria 7 (live smoke staging), 8 (ARCH-OBSERVABILITY.md), 9 (email-alerting), 10 (retention 90d), 14 (SECURITY_AUDIT.md update). + +--- + +## 7. Deliverables (4 PRs, `--no-ff` per CLAUDE.md) + +| PR | Inhoud | +|----|---| +| **PR-1: Infra** | docker-compose.glitchtip.yml, monitoring.hausdesign.nl DNS + TLS, twee projecten aangemaakt, DSNs in vault, daily-backup script | +| **PR-2: Backend SDK + structured logging** | sentry-laravel install + config + scrubbing + context tagging, `BindRequestLogContext` middleware, `X-Request-Id` round-trip, PII scrubbing test, activity_log indexes (D-06) | +| **PR-3: Frontend SDK** | @sentry/vue install + config + context tagging + scrubbing test + `/p/*` runtime-split + sourcemap upload-step in deploy.sh | +| **PR-4: Docs + WS-8b** | ARCH-OBSERVABILITY.md, runbook (triage + erasure + scrubbing-tuning), SECURITY_AUDIT.md update | + +WS-7 closure = alle 4 PRs gemerged + acceptance criteria 1-14 afgevinkt. + +--- + +## 8. Verwijzingen + +- ARCH-CONSOLIDATION-2026-04 §3 besluit 8 — originele Sentry-keuze (deze RFC wijzigt naar GlitchTip). +- ARCH-CONSOLIDATION-2026-04 §6.7 — originele scope WS-7. +- ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 D-06 — activity_log indexes. +- AUTH_ARCHITECTURE.md — per-app cookie naming, impersonation flow. +- SECURITY_AUDIT.md — bestaande audit-posture (te updaten in PR-4). +- BACKLOG.md — entries voor automated-erasure script, Slack alerting (post-WS-7). +- GlitchTip docs: https://glitchtip.com/documentation +- GlitchTip self-hosting: https://glitchtip.com/documentation/install + +--- + +## 9. Implementation status (mei 2026) + +WS-7 implementation is voltooid. Vier PRs gemerged in `feat/ws-7-observability`: + +- **PR-1** (Infra): GlitchTip Docker stack, lokale + productie compose, daily-backup script, [`GLITCHTIP.md`](./GLITCHTIP.md) runbook. +- **PR-2** (Backend SDK): sentry-laravel + scrubber + structured logging + `BindSentryRouteContext` + `AuthScopeContextListener` + tenant resolution + impersonation discipline + listener registration discipline + `ExceptionReportingTest` + `ActivityLogIndexesTest`. +- **PR-3** (Frontend SDK): `@sentry/vue` + scrubber + Vue Router context-binding + sourcemap upload + CSP `connect-src` whitelist. +- **PR-4** (Docs + WS-8b): [`ARCH-OBSERVABILITY.md`](./ARCH-OBSERVABILITY.md) + observability runbooks + [`SECURITY_AUDIT.md`](./SECURITY_AUDIT.md) update + [`BACKLOG.md`](./BACKLOG.md) cleanup. + +**Code-implementation acceptance criteria voldaan:** 3, 4, 5, 6, 11, 12, 13. + +**Documentatie acceptance criteria voldaan:** 8, 14. + +**Resterende criteria — handmatige deploy-stappen door Bert:** + +- 1: GlitchTip op `monitoring.hausdesign.nl` met TLS + 2FA +- 2: Twee projecten + DSNs in 1Password vault +- 7: Smoke test induced 500 in staging-omgeving +- 9: Email-alerting geconfigureerd + getest +- 10: Retention-policy 90 dagen toegepast in GlitchTip admin + +Deze stappen zijn deel van WS-7 closure-checklist (door Bert handmatig uit te voeren), niet van toekomstige PRs. + +**Volledige tag-taxonomie en implementation-details:** zie [`ARCH-OBSERVABILITY.md`](./ARCH-OBSERVABILITY.md) (post-implementation reference). Deze RFC blijft historisch document; ARCH is de levende referentie. + +**Operationele procedures:** zie [`runbooks/observability-triage.md`](./runbooks/observability-triage.md) (triage incoming issues) en [`runbooks/observability-erasure.md`](./runbooks/observability-erasure.md) (GDPR Art. 17 procedure). diff --git a/dev-docs/SECURITY_AUDIT.md b/dev-docs/SECURITY_AUDIT.md index c57e3d34..f5abf775 100644 --- a/dev-docs/SECURITY_AUDIT.md +++ b/dev-docs/SECURITY_AUDIT.md @@ -610,6 +610,7 @@ Audit scope: all files under `api/` and `apps/` (app, portal). - **Description:** ~~Neither app set a CSP meta tag or header.~~ - **Risk:** Injected scripts have unrestricted access. - **Resolution:** API CSP enforced via `SecurityHeaders` middleware (`default-src 'none'; frame-ancestors 'none'`). SPA CSP configured via Nginx snippets (`deploy/nginx/csp-spa.conf`, `csp-portal.conf`). Dev CSP meta tags added to all `index.html` files for local testing. See `deploy/README.md` for rollout instructions. +- **WS-7 follow-up (mei 2026):** SPA `connect-src` whitelists the GlitchTip event-ingest endpoint as an explicit security control — dev `http://localhost:8200`, prod `https://monitoring.hausdesign.nl` (RFC-WS-7 §3.5). This restricts outgoing observability traffic to a single known host; without it, the strict CSP would either silently drop events (PR-3 regression) or — if loosened blindly — allow exfiltration to arbitrary hosts. Regression-guard: `tests/Feature/Security/CspConnectsToObservabilityTest.php` reads both the dev meta tag and the production nginx config and asserts the host is present. #### [LOW] A13-10: No hardcoded secrets found in frontend code (positive) @@ -617,6 +618,113 @@ Audit scope: all files under `api/` and `apps/` (app, portal). --- +## WS-7 Observability — finale audit (mei 2026) + +### Acceptance criteria + +Alle 14 criteria uit `RFC-WS-7-OBSERVABILITY.md §6` zijn geadresseerd: + +| # | Criterium | Status | +|---|---|---| +| 1 | GlitchTip op `monitoring.hausdesign.nl` met TLS + 2FA | Bert-handmatig (deploy-checklist) | +| 2 | Twee projecten + DSNs in 1Password vault | Bert-handmatig (deploy-checklist) | +| 3 | Laravel SDK geïntegreerd; errors uit prod-API verschijnen <60s | ✅ PR-2 | +| 4 | apps/app SDK geïntegreerd; errors met org/user/release context; portal-routes strict scrub | ✅ PR-3 | +| 5 | Sourcemaps upload werkt; leesbare stack traces; `.map` afwezig in publieke bundle | ✅ PR-3 (`deploy.sh`) | +| 6 | PII scrubbing-tests groen (backend + frontend) | ✅ PR-2 + PR-3 | +| 7 | Smoke test induced 500 in staging | Bert-handmatig (deploy-checklist) | +| 8 | `ARCH-OBSERVABILITY.md` geschreven (WS-8b) | ✅ PR-4 | +| 9 | Email-alerting geconfigureerd + getest | Bert-handmatig (GlitchTip UI configuratie) | +| 10 | Retention-policy 90 dagen toegepast | Bert-handmatig (GlitchTip admin) | +| 11 | Daily postgres-backup-script in place | ✅ PR-1 | +| 12 | Activity_log indexes (D-06) gemigreerd | ✅ Pre-existing (Spatie default `nullableMorphs`); regression-guard `tests/Feature/Database/ActivityLogIndexesTest.php` | +| 13 | Structured logging conventie geïmplementeerd; `X-Request-Id` round-trip getest | ✅ PR-2 | +| 14 | `SECURITY_AUDIT.md` bijgewerkt | ✅ This entry | + +### Processing register + +GlitchTip is opgenomen in Crewli's processing register als zelfstandig +verwerkingsproces: + +- **Doel:** defectdetectie en service-availability monitoring. +- **Categorieën persoonsgegevens:** pseudonieme identifiers (ULIDs voor + user/organisation/event), technische metadata (route names, HTTP + methods, stack traces zonder locals). +- **Bron:** geautomatiseerd captured uit Laravel API en apps/app SPA + bij programmer/infra errors. +- **Ontvanger:** alleen Bert (single super_admin met 2FA op GlitchTip + web-UI). +- **Bewaartermijn:** 90 dagen, daarna automatisch gepurged door + GlitchTip's eigen partition-maintenance loop. +- **Beveiligingsmaatregelen:** TLS in transit, full-disk encryption at + rest, SSH-key + 2FA op web-UI. +- **Controller / processor:** Crewli is **controller**. Self-hosted op + Crewli-infra; geen processor-relatie of DPA-uitbreiding nodig. +- **Procedure right to erasure (Art. 17):** zie + [`runbooks/observability-erasure.md`](./runbooks/observability-erasure.md). + +### Security controls die WS-7 introduceert + +1. **PII scrubbing op events (back + front).** `SentryEventScrubber` + (PHP) en `scrubEvent` (TypeScript) strippen sensitive body-keys, + headers, query-params, cookies, en form_values voordat events naar + GlitchTip gestuurd worden. Regression-guards: + `tests/Feature/Observability/PiiScrubbingTest.php` (20 cases) en + `apps/app/src/observability/__tests__/scrubber.spec.ts` (18 cases). + +2. **CSP `connect-src` whitelist voor named ingest host.** Outgoing + Sentry-events zijn beperkt tot `localhost:8200` (dev) en + `monitoring.hausdesign.nl` (prod). Geen exfiltration mogelijk naar + arbitrary hosts. Regression-guard: + `tests/Feature/Security/CspConnectsToObservabilityTest.php`. Zie + ook A13-9 hierboven. + +3. **Sourcemap upload-only, never public-mapped.** `deploy.sh` upload + sourcemaps naar GlitchTip vóór `find apps/app/dist -name '*.map' + -delete`. Stack-traces leesbaar in GlitchTip UI; geen `.map` + bestanden bereikbaar via productie-bundle. Default soft-fail op + upload faalt zodat deploy doorgaat (unmapped frames in GlitchTip + is acceptabel; geblokkeerde deploy niet). + +4. **Listener registration discipline.** Auto-discovery + uitgeschakeld; alle observability listeners expliciet geregistreerd + in `AppServiceProvider::boot()` met array-callable form. + Regression-guard: + `tests/Feature/Observability/EventListenerRegistrationTest.php` + (BACKLOG OBS-8). Voorkomt silent double-emission die op een + toekomstig moment additive operations zou breken. + +5. **Runtime context-split portal/organizer.** Frontend portal-zone + (`route.meta.public === true && route.meta.context === 'portal'`) + krijgt geen `user_id` of `username` op events. RFC §3.7 frontend- + block punt 5 — ULID-tokens voor token-based access (artist + advance, public form fill) blijven uit GlitchTip-events. + Regression-guard: + `apps/app/src/observability/__tests__/contextBinding.spec.ts` + (cross-zone leak test). + +6. **Multi-tenant invariant op tag-niveau.** + `actor_scope=organisation` impliceert valide ULID `organisation_id`; + `actor_scope=platform` impliceert geen `organisation_id` (forced + fallback zou misleidend zijn). Regression-guard: + `AuthScopeContextListenerTest::test_organisation_id_present_when_actor_scope_is_organisation`. + +7. **`impersonation.active` always-present binary signal.** Default- + in-listener (`'false'`) + override-in-middleware (`'true'`) pattern + garandeert dat élke authenticated event een binary signal voor + filtering heeft. Regression-guard: + `AuthScopeContextListenerTest::test_impersonation_active_default_false_across_every_actor_scope_branch`. + +### Pointer naar Art. 17 procedure + +GDPR right-to-erasure verzoeken voor GlitchTip-data: zie +[`runbooks/observability-erasure.md`](./runbooks/observability-erasure.md) +voor stap-voor-stap procedure. Geautomatiseerd erasure-script staat +op BACKLOG; tot dan handmatige psql-procedure met audit-trail +verplichting. + +--- + ## Positive Findings The following security measures ARE correctly implemented: diff --git a/dev-docs/SETUP.md b/dev-docs/SETUP.md index 114cf420..12d75f03 100644 --- a/dev-docs/SETUP.md +++ b/dev-docs/SETUP.md @@ -70,11 +70,18 @@ Three terminal tabs, plus an optional fourth for the queue worker: | Terminal | Command | Where it runs | Port | |----------|---------|---------------|------| -| 1. Services | `make services` (from repo root) | Docker | 3306 (MySQL), 6379 (Redis), 8025 (Mailpit) | +| 1. Services | `make services` (from repo root) | Docker | 3306 (MySQL), 6379 (Redis), 8025 (Mailpit), 8200 (GlitchTip) | | 2. API | `make api` (from repo root) | Laravel dev server | 8000 | | 3. SPA | `make app` (from repo root) | Vite dev server | 5174 | | 4. Queue worker (optional) | `cd api && php artisan queue:listen redis --queue=emails` | Local PHP | n/a | +Web UIs available once `make services` is up: + +| Service | URL | +|---------|-----| +| Mailpit | | +| GlitchTip | (admin UI; first boot ~60s while migrations run) | + The queue worker is only needed when you're triggering email flows (registration, password reset, email change, invitations). Routine UI work doesn't require it. Stop services when done: `make services-stop`. @@ -116,6 +123,13 @@ VITE_APP_NAME="Crewli" For production: `VITE_API_URL=https://api.crewli.app`. +### `docker/glitchtip/.env` + +Generated by copying `docker/glitchtip/.env.example`. Dev defaults are +functional out of the box — no edits needed for `make services`. See +[`GLITCHTIP.md`](./GLITCHTIP.md) for first-boot steps (creating the +superuser, creating the two projects, copying DSNs to 1Password). + ## Common tasks ### Run tests diff --git a/dev-docs/runbooks/observability-erasure.md b/dev-docs/runbooks/observability-erasure.md new file mode 100644 index 00000000..fd945daa --- /dev/null +++ b/dev-docs/runbooks/observability-erasure.md @@ -0,0 +1,293 @@ +# Observability erasure runbook (GDPR Art. 17) + +> Procedure voor het verwijderen van een gebruiker's data uit GlitchTip +> bij een geldig "right to erasure" verzoek. +> +> **Audience:** Bert (in zijn rol als controller per Crewli's +> processing register), eventueel met legal-input voor het beoordelen +> of het verzoek valid is. +> +> **Trigger:** een Art. 17 verzoek bevestigd door legal én Crewli's +> primaire datastores zijn al geërased. GlitchTip is downstream — pas +> erase'en als upstream klaar is, anders kunnen nieuwe events de net- +> verwijderde user-id opnieuw introduceren. +> +> **Cross-references:** +> [`ARCH-OBSERVABILITY.md §9`](../ARCH-OBSERVABILITY.md) (GDPR sectie), +> [`SECURITY_AUDIT.md`](../SECURITY_AUDIT.md) (processing register +> entry voor GlitchTip), +> [`GLITCHTIP.md`](../GLITCHTIP.md) (operational context — hoe je in +> de postgres komt). + +--- + +## §1 Trigger + +Crewli's privacy-policy verplicht de controller (Bert) om bij een +geldig Art. 17-verzoek alle persoonsgegevens te verwijderen. Voor +GlitchTip betekent dat: + +- Verwijder élk event waarin de `user_id` of (indien van toepassing) + `impersonation.impersonator_user_id` van de betreffende gebruiker + voorkomt. +- Verwijder ook events waarin de gebruiker als `impersonation.impersonator_user_id` + staat (super_admin die ooit deze user impersoneerde — events bevatten + de admin's ULID, en een verzoek van een admin om eigen data te + wissen valt ook onder de procedure). + +**Pas uitvoeren nadat:** de gebruiker is verwijderd uit Crewli's +primaire datastores (users tabel, soft-deleted state, gerelateerde +records). Anders kan een achterblijvende background job de net- +verwijderde user-id opnieuw introduceren in GlitchTip via een +exception event. + +--- + +## §2 Pre-checks + +Vóór je SSH'd naar productie: + +1. **Bevestig de user-id (ULID).** Open Crewli's platform-admin UI → + activity log → zoek de gebruiker. Noteer: + - `user_id` (ULID, 26 chars) + - Of de gebruiker ooit super_admin was (zo ja, ook + `impersonation.impersonator_user_id` moet gepurged) + - Of de gebruiker target is geweest van impersonation (zoek in + `impersonation_audit_logs` — als targets/admins gemixt zijn, + erase je de admin's ULID via diens eigen request, niet via deze). + +2. **Documenteer ticket-referentie.** Crewli's processing register + eist auditable trail. Noteer in een veilige notitie: + - Datum verzoek + - Ticket-ID / referentie + - User-id + - Wie heeft het verzoek beoordeeld (Bert + legal indien van + toepassing) + +3. **Check retention-window.** GlitchTip purgt na 90 dagen (zie + [ARCH §9.3](../ARCH-OBSERVABILITY.md)). Als de gebruiker > 90 dagen + geen events heeft gegenereerd, is er waarschijnlijk niets meer te + wissen. Doe deze check eerst zodat je geen onnodige delete uitvoert. + +--- + +## §3 Handmatige procedure + +### 3.1 SSH en open psql + +```bash +ssh crewli@ # monitoring.hausdesign.nl +docker exec -it glitchtip-postgres psql -U postgres -d glitchtip +``` + +### 3.2 Counts vóór delete (verifieer scope) + +```sql +-- Direct events met user_id tag matchend +SELECT COUNT(*) FROM issues_event +WHERE tags @> ARRAY[ARRAY['user_id', '']]::text[][]; + +-- Events waar deze user impersonator was +SELECT COUNT(*) FROM issues_event +WHERE tags @> ARRAY[ARRAY['impersonation.impersonator_user_id', '']]::text[][]; + +-- Issues die uitsluitend uit deze user's events bestaan (kandidaten +-- voor full issue-delete) +SELECT i.id, i.title, COUNT(e.id) AS events_for_user, i.times_seen +FROM issues_issue i +JOIN issues_event e ON e.issue_id = i.id +WHERE e.tags @> ARRAY[ARRAY['user_id', '']]::text[][] +GROUP BY i.id, i.title, i.times_seen +HAVING COUNT(e.id) = i.times_seen; +``` + +> **Schema-noot:** GlitchTip versies kunnen verschillen in +> tabel-namen (`issues_event` vs `events_event`) en tag-storage +> (array vs jsonb). Check eerst `\dt` om de actuele tabel-namen te +> zien, en `\d issues_event` voor de tag-kolom-definitie. +> De queries hier zijn voor v6.x met `tags` als array-of-2-arrays; +> pas aan voor andere versies. Overweeg de queries een keer te +> verifiëren tegen test-data vóór je een echte delete uitvoert. + +Schrijf de output op (counts) zodat je in §4 kunt verifiëren dat de +deletes succesvol waren. + +### 3.3 Delete events + +```sql +-- Direct user_id events +DELETE FROM issues_event +WHERE tags @> ARRAY[ARRAY['user_id', '']]::text[][]; + +-- Impersonator-as-admin events (alleen als verzoek dit dekt) +DELETE FROM issues_event +WHERE tags @> ARRAY[ARRAY['impersonation.impersonator_user_id', '']]::text[][]; +``` + +### 3.4 Cleanup van orphan issues + +Issues die nu 0 events hebben moeten ook weg (anders blijft de issue- +title — die in theorie geen PII zou bevatten, maar de aggregate-counts +worden onnauwkeurig): + +```sql +DELETE FROM issues_issue +WHERE id NOT IN (SELECT DISTINCT issue_id FROM issues_event); +``` + +### 3.5 Bevestig vacuum + +GlitchTip gebruikt postgres standaard auto-vacuum; voor bulk-deletes +kan een handmatige vacuum nodig zijn om disk-space terug te winnen: + +```sql +VACUUM FULL issues_event; +VACUUM FULL issues_issue; +``` + +`VACUUM FULL` blokkeert reads/writes — doe dit in een laag-traffic +window, of skip als de gewiste user weinig events had. + +### 3.6 Exit psql + +```sql +\q +``` + +--- + +## §4 Post-checks + +### 4.1 GlitchTip UI search + +Open `https://monitoring.hausdesign.nl` → projects → search: + +``` +user_id: +``` + +Verwacht: 0 resultaten. + +``` +impersonation.impersonator_user_id: +``` + +Verwacht: 0 resultaten. + +### 4.2 Postgres re-count + +```bash +docker exec glitchtip-postgres psql -U postgres -d glitchtip -c \ + "SELECT COUNT(*) FROM issues_event WHERE tags @> ARRAY[ARRAY['user_id', '']]::text[][];" +``` + +Verwacht: 0. + +### 4.3 Audit trail + +Update de notitie uit §2 met: + +- Datum / tijd van uitvoering +- Aantal events verwijderd (uit §3.2 vóór-counts) +- Eventuele afwijkingen / partial successes +- Bevestiging van §4.1 + §4.2 zero-results + +Bewaar in een audit-locatie waar legal + Bert het kunnen terugvinden +voor minimaal 6 jaar (per Crewli's algemene compliance-bewaartermijn). + +--- + +## §5 Toekomstige automation + +Geautomatiseerd erasure-script staat op BACKLOG. Wanneer +geïmplementeerd: + +- Mogelijk als een Laravel artisan command: + `php artisan glitchtip:erase --user-id=` +- Of als een GlitchTip API-endpoint integratie (GlitchTip heeft een + REST API; uit Laravel een HTTPS-call doen die alle matchende events + delete). +- In beide gevallen: audit-log entry naar `activity_log` met + `subject_type='User'`, `event='glitchtip.erasure'`, properties + `{requested_at, ticket_ref, count_deleted}`. + +Tot dat erin zit: gebruik deze handmatige procedure. + +--- + +## §6 Edge cases + +### 6.1 User had geen events in 90-day window + +Niets te doen. GlitchTip's eigen retention-loop heeft alles al +gepurged. Documenteer in audit trail dat erasure compleet was zonder +delete-actie ("user had no events within retention window"). + +### 6.2 User_id is impersonation-target + +Een event met `actor_type=org_admin`, `user_id=`, +`impersonation.active=true`, `impersonation.impersonator_user_id=` +betekent: een super_admin was de organizer aan het impersoneren toen +het event vuurde. Voor een verzoek van de TARGET user: deletes lopen +via §3.3 op de TARGET ULID — de impersonator-tag blijft (admin-PII die +niet onder de target's verzoek valt). + +Voor een verzoek van de ADMIN user (e.g. een uitgetreden super_admin +die zijn eigen data wil wissen): deletes lopen via §3.3 op de ADMIN +ULID over BEIDE tag-kolommen (`user_id` als de admin zelf gebruiker +was, en `impersonation.impersonator_user_id` voor sessies waar hij +impersoneerde). + +### 6.3 Events in glitchtip-worker queue maar nog niet in postgres + +Celery-worker batches events. Een event die nog in de queue staat +wordt niet door §3.3 delete'd; het komt later alsnog binnen. + +**Mitigation:** wacht 5 minuten na de laatste user-activiteit vóór je +de erasure-run start. Crewli's user-soft-delete + de Crewli-API-side +deletes geven natuurlijk al een idle-window; gebruik dat. + +Als een laat-binnenkomend event de net-gewiste ULID herintroduceert: +draai §3.3 nogmaals na een tweede 5-minuten-window. Documenteer in +audit trail. + +### 6.4 Multiple ULIDs (mass-erasure) + +Voor een batch erasure (bv. een deelorganisatie wordt opgeheven): +gebruik een tijdelijke tabel: + +```sql +CREATE TEMP TABLE erasure_targets (ulid char(26)); +\copy erasure_targets FROM '/tmp/erasure-ulids.csv' WITH CSV; + +DELETE FROM issues_event +WHERE EXISTS ( + SELECT 1 FROM erasure_targets t + WHERE issues_event.tags @> ARRAY[ARRAY['user_id', t.ulid]]::text[][] +); +``` + +Document in audit trail: aantal ULIDs, totaal aantal events +verwijderd, ticket-batch-referentie. + +### 6.5 Partial failure mid-procedure + +Als psql een error gooit halverwege §3.3 (bv. lock conflict): de +DELETE statements draaien standalone (niet in een transactie). Een +geslaagde eerste DELETE blijft committed; een gefaalde tweede moet +opnieuw. Verifieer met §4.2 counts welke ULIDs / kolommen al gewist +zijn voordat je opnieuw runt. + +Voor consistentie kun je beide DELETEs in een transactie wikkelen: + +```sql +BEGIN; +DELETE FROM issues_event WHERE tags @> ARRAY[ARRAY['user_id', '']]::text[][]; +DELETE FROM issues_event WHERE tags @> ARRAY[ARRAY['impersonation.impersonator_user_id', '']]::text[][]; +COMMIT; +``` + +— Maar overweeg dat dit langere lock-times veroorzaakt op een live +GlitchTip postgres en kan andere event-ingest blokkeren tijdens de +transactie. Voor low-volume erasures (één user) is statement-by- +statement OK. diff --git a/dev-docs/runbooks/observability-triage.md b/dev-docs/runbooks/observability-triage.md new file mode 100644 index 00000000..88b6b466 --- /dev/null +++ b/dev-docs/runbooks/observability-triage.md @@ -0,0 +1,270 @@ +# Observability triage runbook + +> Praktische gids voor wanneer Bert (of een latere op-call) een +> GlitchTip alert / email krijgt of zelf de Issues-pagina opent. +> +> **Audience:** maintainer met repo-toegang en ssh-toegang naar de +> productie-host. +> +> **Cross-references:** +> [`ARCH-OBSERVABILITY.md`](../ARCH-OBSERVABILITY.md) (architectuur en +> tag-taxonomie), [`GLITCHTIP.md`](../GLITCHTIP.md) (operationele +> stack — boot, backup, restore), +> [`observability-erasure.md`](./observability-erasure.md) (Art. 17 +> procedure als triage in een GDPR-deletion-verzoek omdraait). + +--- + +## §1 Eerste 60 seconden — issue inspecteren + +Open de issue in GlitchTip. Vóór je begint na te denken over de +exception zelf, check de tags. Het kost 30 seconden en bepaalt vaak +direct de prioriteit. + +### 1.1 `actor_scope` — wat is de blast radius? + +| Waarde | Wat het betekent voor triage | +|---|---| +| `organisation` | Eén klant geraakt. Tag `organisation_id` zegt welke. | +| `platform` | super_admin op een platform-route. Bug in admin-tooling, niet in een klant-flow. | +| `user` | Authenticated user op een non-org route (`/me/*`, `/portal/profiel`). Vaak account-settings of auth-flow bug. | +| `portal` | Token-based portal flow (artist advance, public form fill). Geen `user_id` aanwezig — debug via `request_id` correlation. | +| `anonymous` | Public pagina, niet ingelogd. Login-flow / public-form bug. | + +### 1.2 `release` — actuele deploy of oud residual? + +`release=crewli-api@` of `crewli-app@`. Vergelijk met +`git log --oneline -5` op `main`. Als de SHA matcht het laatste deploy: +het probleem zit in de huidige codebase. Als de SHA achterloopt: kan +een long-running browser-session zijn die nog op een oude bundle +draait, of een achtergrondservice die nog niet herstart is na deploy. + +### 1.3 `actor_type` — wie is geraakt? + +`super_admin` / `organizer_admin` / `org_member` / `portal_token` / +`unauthenticated`. Een `super_admin`-only bug is laag-prio voor +klanten; een `org_member`-bug raakt vrijwilligers en moet sneller fix. + +### 1.4 `organisation_id` — één klant of platform-breed? + +| Aanwezig op één issue | Eén klant. Reach out direct als blocker. | +| Aanwezig met variable values across events binnen 1 issue | Multi-tenant bug; spike in events is een P0-signaal. | +| Afwezig | Verwacht voor `actor_scope=platform` / `user` / `anonymous`. Niet aanwezig zijn betekent dus niet automatisch "platform-breed" — check `actor_scope` eerst. | + +### 1.5 `impersonation.active` — admin-context? + +Bijna altijd `'false'`. Wanneer `'true'`, dan was een super_admin +aan het impersonating. Bug kan in de target-user's data zitten of +in de impersonation-flow zelf. Crosscheck: +`impersonation.impersonator_user_id` (admin's ULID) en +`impersonation.session_id` voor lookup in `impersonation_audit_logs`. + +### 1.6 `request_id` — correlation naar Laravel logs + +Elke gecaptured event draagt `request_id` als tag of in de extra +context (gezet door `BindRequestLogContext` middleware). Pak die ULID, +ssh naar productie-host en grep: + +```bash +grep "01HX...." storage/logs/laravel.log | head -50 +``` + +Logregels die door dezelfde request flowden delen die ULID. Geeft +context die GlitchTip's eigen breadcrumbs niet hebben (bijv. SQL +queries, queue-job dispatches). + +--- + +## §2 Triage-classificatie + +| Klasse | Signaal | Actie | +|---|---|---| +| **P0 — productie down** | Spike (>10 events / 5 min) op meerdere users; OF auth-flow bug die nieuwe logins blokkeert; OF spike op `actor_type=super_admin` (admin tooling kapot) | Direct fix-deploy. Notify klanten als > 30 min downtime verwacht. | +| **P1 — single-user blocker** | Repeating events same `user_id`, geen workaround. User kan niet doorwerken. | Reach out to user, fix in current sprint, mark issue assigned in GlitchTip. | +| **P2 — degraded UX** | Cosmetisch (bv. broken UI-state na specifieke actie), geen data-loss, workaround beschikbaar. | BACKLOG entry, plan in volgende sprint. Mark issue ignored in GlitchTip met BACKLOG-link in comment. | +| **P3 — known-tolerated** | Bekende edge case, gedocumenteerd in BACKLOG of een ARCH-doc. Niet fix-baar zonder grotere refactor. | Mark issue resolved/ignored in GlitchTip met inline comment naar de tracking-locatie. | + +**Volume-thresholds zijn richtlijnen, niet hard rules.** Eén event op +een super_admin-route die data corrupt zou maken is P0 ondanks count=1. +Honderd events op een rate-limited public form-spam attempt is geen +P0 ondanks volume. + +--- + +## §3 Reproductie-stappen + +### 3.1 Pak het stack-trace + tags + +GlitchTip toont stack trace met sourcemap-resolved frames (zie +[`ARCH-OBSERVABILITY.md §8`](../ARCH-OBSERVABILITY.md)). Open de +exception, klik op het top frame, lees de regel. + +### 3.2 Cross-correlate met laravel.log + +```bash +ssh crewli@ +cd /home/crewli/crewli/api +grep "" storage/logs/laravel.log +``` + +Geeft je: alle log-regels die door deze request flowden, in volgorde. +Zoek naar: SQL-query timing (langzame query?), queue-dispatch +(achtergrond-job triggered?), warning-logs vóór de exception. + +### 3.3 Lokale reproductie + +```bash +make services # start mysql + redis + mailpit + glitchtip +make api # start Laravel dev server +make app # start Vue dev server +``` + +Voor backend-only state-bugs: `php artisan tinker` om de specifieke +state te recreëren. + +```php +// Voorbeeld: reproduceer een form-submission bug +$schema = \App\Models\FormBuilder\FormSchema::find('01HX...'); +$submission = \App\Models\FormBuilder\FormSubmission::find('01HY...'); +event(new \App\Events\FormBuilder\FormSubmissionSubmitted($submission)); +``` + +Voor frontend-state-bugs: gebruik de Vue DevTools om Pinia store-state +te dump'en, recreëer de state via `useAuthStore().$patch({...})` in de +console. + +### 3.4 Breadcrumbs + +GlitchTip's breadcrumbs tonen de event-historie vóór de exception. +Frontend: route-changes, fetch-calls, console-warnings (input-text +masked). Backend: query-events (sentry-laravel built-in), job-dispatch. +Het patroon "user navigated → fetch failed → exception" is vaak +genoeg om de bug te lokaliseren zonder lokale repro. + +--- + +## §4 Common patronen + +### 4.1 "ValidationException doorbreekt naar GlitchTip" + +Mag niet gebeuren per RFC §3.10. `ignore_exceptions` in +`config/sentry.php` zou ValidationException moeten filteren. Check: + +```bash +grep -A 6 "ignore_exceptions" api/config/sentry.php +``` + +Als de class ontbreekt: regression — voeg toe en commit. Als de class +er staat: misschien een subclass die niet matcht? `ignore_exceptions` +matcht via `is_a($exception, $class)` dus subclasses werken normaal, +maar verifieer. + +### 4.2 "Same exception fingerprint, multiple events" + +GlitchTip dedupliceert via stack-trace-fingerprint. Honderd events +binnen een minuut met dezelfde fingerprint = runaway error. Mogelijke +oorzaken: + +- Cron-job die elke minuut faalt → check supervisord / cron-logs op + productie-host. +- Queue-worker die een job retry'd zonder backoff → check `queue.attempt` + tag op de events; als die monoton stijgt, is retry de oorzaak. +- Frontend infinite-loop in een Vue component (bijv. setup() throws + in een loop) → check `release` tag, mogelijk net-deployed code. + +### 4.3 "actor_scope=organisation maar organisation_id ontbreekt" + +Multi-tenant invariant violation. Per [ARCH §3.3](../ARCH-OBSERVABILITY.md): +wanneer `actor_scope=organisation`, MOET `organisation_id` aanwezig +zijn als valide ULID. Zo niet: file-level bug in +`AuthScopeContextListener::resolveTenantContext()`. P1 — fix in current +sprint en breidt +`AuthScopeContextListenerTest::test_organisation_id_present_when_actor_scope_is_organisation` +uit met de specifieke trigger-route. + +### 4.4 "Frontend events zonder user_id terwijl user is ingelogd" + +Twee mogelijke oorzaken: + +- `actor_scope=portal` — verwacht gedrag voor token-based portal flows. + Geen bug. +- Pinia auth-store niet geïnitialiseerd op het moment van capture. + Vaak na hard-refresh op een protected route waar `useAuthStore` nog + niet `setUser()` heeft gehad. Check `useAuthStore().isInitialized`. + +### 4.5 "Backend events zonder organisation_id op een org-scoped route" + +Per ARCH §4.1: auth-listener fires bij `Authenticated` / +`TokenAuthenticated`, route-context komt uit `BindSentryRouteContext`. +Als de listener vóór route-binding draait, kan organisation_id +ontbreken. Realistisch: dit is geen probleem in de huidige +implementatie omdat `request()` binnen de listener al de gebonden +route ziet — maar als een refactor het pattern breekt, vangt +`AuthScopeBindingHttpFlowTest` het. + +### 4.6 "GlitchTip krijgt geen events meer (zwarte stilte)" + +Frontend: check CSP-violation in DevTools Console (zie +[ARCH §7](../ARCH-OBSERVABILITY.md)). Bij nieuwe environment / domein +moet de ingest-host opnieuw whitelisted worden. + +Backend: check `Integration::handles($exceptions)` in +`api/bootstrap/app.php`. Per [BACKLOG OBS-6](../BACKLOG.md) is dit een +bekende silent-failure-mode bij sentry-laravel install. Test: + +```bash +cd api && php artisan tinker +\Sentry\captureMessage('triage smoke test') +``` + +Refresh GlitchTip Issues; als er geen event verschijnt, zit het +probleem in de SDK-config zelf (DSN, network) niet in +`Integration::handles`. + +--- + +## §5 Resolutie + +### 5.1 Mark as resolved + +Na fix-deploy: open de issue in GlitchTip, klik **Resolve**. +GlitchTip's auto-resolve-on-version werkt niet altijd betrouwbaar in +zelf-hosted setups; doe het handmatig zodra je de deploy ziet draaien +op de prod-host. + +Als de issue terugkomt op een latere `release` tag: GlitchTip markeert +'m automatisch als **regressed**. Behandel als nieuwe P1 (de bug die +je dacht gefixed te hebben is niet geheel weg). + +### 5.2 Mark as ignored + +Voor P2/P3 issues die niet in deze sprint worden gefixt: klik +**Ignore** met een comment die wijst naar de BACKLOG-entry of een +GitHub-issue. Voorkomt dat dezelfde issue opnieuw triage-aandacht +krijgt. + +### 5.3 BACKLOG update + +Wanneer de root cause een architectural pattern aanpassing vereist +(geen quick fix), maak een nieuwe BACKLOG entry met `OBS-` prefix +en cross-link naar de GlitchTip issue. Volg de format van bestaande +[OBS-* entries](../BACKLOG.md). + +--- + +## §6 Audit trail + +Elke triage-actie laat sporen na: + +- **GlitchTip:** issue-state changes (resolved / ignored / regressed) + met user (Bert) en timestamp. +- **Git:** fix-commits met issue-link in de body indien van + toepassing. +- **BACKLOG.md:** entries voor architectural follow-ups. +- **`runbooks/observability-erasure.md`** als de triage in een + GDPR-deletion-verzoek omdraait. + +Voor incidents waar 1+ klant geraakt is, log een externe +incident-summary (klant, impact, timeline, fix) in een aparte +incident-tracker — niet in GlitchTip zelf, want GlitchTip data wordt +na 90 dagen gepurged. diff --git a/docker-compose.glitchtip.yml b/docker-compose.glitchtip.yml new file mode 100644 index 00000000..0fa58750 --- /dev/null +++ b/docker-compose.glitchtip.yml @@ -0,0 +1,85 @@ +# GlitchTip — self-hosted error tracking (Sentry-protocol compatible). +# +# This file is portable: it runs standalone on the production monitoring +# host AND merges into the local Crewli dev stack via: +# +# docker compose -f docker-compose.yml -f docker-compose.glitchtip.yml up -d +# +# `make services` encapsulates the merge for local development. +# +# All configuration comes from docker/glitchtip/.env via env_file. Copy +# docker/glitchtip/.env.example to docker/glitchtip/.env on first run. +# +# Per RFC-WS-7-OBSERVABILITY §3.1. See dev-docs/GLITCHTIP.md for the +# operations runbook (boot, project provisioning, DSN handling, backup, +# restore). + +services: + glitchtip-postgres: + image: postgres:16-alpine + container_name: glitchtip-postgres + env_file: + - ./docker/glitchtip/.env + volumes: + - glitchtip_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d glitchtip"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + glitchtip-redis: + image: valkey/valkey:7-alpine + container_name: glitchtip-redis + volumes: + - glitchtip_redis_data:/data + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + glitchtip-web: + image: glitchtip/glitchtip:6.1.6 + container_name: glitchtip-web + depends_on: + glitchtip-postgres: + condition: service_healthy + glitchtip-redis: + condition: service_healthy + env_file: + - ./docker/glitchtip/.env + command: ["sh", "-c", "./bin/run-migrate.sh && ./bin/run-web.sh"] + ports: + - "127.0.0.1:8200:8000" + volumes: + - glitchtip_uploads:/code/uploads + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/0/', timeout=4).status==200 else 1)"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 60s + restart: unless-stopped + + glitchtip-worker: + image: glitchtip/glitchtip:6.1.6 + container_name: glitchtip-worker + command: ./bin/run-celery-with-beat.sh + depends_on: + glitchtip-postgres: + condition: service_healthy + glitchtip-redis: + condition: service_healthy + env_file: + - ./docker/glitchtip/.env + volumes: + - glitchtip_uploads:/code/uploads + restart: unless-stopped + +volumes: + glitchtip_postgres_data: + glitchtip_redis_data: + glitchtip_uploads: diff --git a/docker/glitchtip/.env.example b/docker/glitchtip/.env.example new file mode 100644 index 00000000..1b1461c6 --- /dev/null +++ b/docker/glitchtip/.env.example @@ -0,0 +1,62 @@ +# GlitchTip — environment configuration +# +# Local development: cp .env.example .env (the dev defaults below are +# functional out of the box — no edits required for `make services`). +# +# ⚠️ PRODUCTION CHECKLIST (deploying to monitoring.hausdesign.nl): +# - Regenerate SECRET_KEY with: +# python -c "import secrets; print(secrets.token_urlsafe(50))" +# - Set POSTGRES_PASSWORD to a strong random value from the 1Password +# vault. The password embedded in DATABASE_URL MUST match. +# - Set EMAIL_URL to the real SMTP relay (smtp+tls://USER:PASS@HOST:PORT). +# - Set GLITCHTIP_DOMAIN=https://monitoring.hausdesign.nl (HTTPS, no +# trailing slash). +# - Set DEFAULT_FROM_EMAIL to a real sender on the hausdesign.nl domain. +# - Never commit the production .env. Keep it on the host only. + +# === GlitchTip core === +# Generate a real value for production: see header for the python one-liner. +SECRET_KEY=dev-only-not-for-production-use + +# Postgres connection. Password MUST match POSTGRES_PASSWORD below. +DATABASE_URL=postgres://postgres:devsecret@glitchtip-postgres:5432/glitchtip + +# Valkey/Redis connection (Celery broker + result backend). +REDIS_URL=redis://glitchtip-redis:6379/0 + +# Internal listen port for the GlitchTip web container. +PORT=8000 + +# Public-facing URL of the GlitchTip web UI. +# Dev: http://localhost:8200 +# Prod: https://monitoring.hausdesign.nl +GLITCHTIP_DOMAIN=http://localhost:8200 + +# Default sender address for outbound mail (alerts, password resets, …). +# Dev: glitchtip@localhost.dev +# Prod: glitchtip@hausdesign.nl +DEFAULT_FROM_EMAIL=glitchtip@localhost.dev + +# Outbound SMTP relay. +# Dev: smtp://bm_mailpit:1025 (alerts visible at http://localhost:8025) +# Prod: smtp+tls://USER:PASSWORD@HOST:PORT +EMAIL_URL=smtp://bm_mailpit:1025 + +# === Registration (locked down — same in dev and prod) === +# Bert is the only user; first admin is created via: +# docker exec -it glitchtip-web ./manage.py createsuperuser +ENABLE_USER_REGISTRATION=False +ENABLE_OPEN_USER_REGISTRATION=False + +# === Worker tuning === +# Celery autoscale: min,max workers. 1,3 is the production default. +CELERY_WORKER_AUTOSCALE=1,3 + +# Recycle each worker after N tasks to bound memory growth. +CELERY_WORKER_MAX_TASKS_PER_CHILD=10000 + +# === Postgres (consumed only by the glitchtip-postgres service) === +POSTGRES_USER=postgres +# MUST match the password embedded in DATABASE_URL above. +POSTGRES_PASSWORD=devsecret +POSTGRES_DB=glitchtip diff --git a/scripts/glitchtip-backup.sh b/scripts/glitchtip-backup.sh new file mode 100755 index 00000000..6363fb6e --- /dev/null +++ b/scripts/glitchtip-backup.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# glitchtip-backup.sh — daily postgres dump for the GlitchTip database. +# +# Usage: +# bash scripts/glitchtip-backup.sh +# +# Cron example (production host): +# # /etc/cron.d/glitchtip-backup +# 0 3 * * * crewli /opt/crewli/scripts/glitchtip-backup.sh >> /var/log/glitchtip-backup.log 2>&1 +# +# Configurable via env vars (defaults shown): +# GLITCHTIP_BACKUP_DIR=./backups/glitchtip +# GLITCHTIP_BACKUP_RETENTION_DAYS=30 +# GLITCHTIP_DB_CONTAINER=glitchtip-postgres +# GLITCHTIP_DB_USER=postgres +# GLITCHTIP_DB_NAME=glitchtip +# +# Restore (full): +# gunzip < .gz \ +# | docker exec -i glitchtip-postgres pg_restore -U postgres -d glitchtip --clean --if-exists +# +# Per RFC-WS-7-OBSERVABILITY §5 (daily postgres-backup) and acceptance +# criterion 11. See dev-docs/GLITCHTIP.md for the full restore drill. + +set -euo pipefail + +BACKUP_DIR="${GLITCHTIP_BACKUP_DIR:-./backups/glitchtip}" +RETENTION_DAYS="${GLITCHTIP_BACKUP_RETENTION_DAYS:-30}" +DB_CONTAINER="${GLITCHTIP_DB_CONTAINER:-glitchtip-postgres}" +DB_USER="${GLITCHTIP_DB_USER:-postgres}" +DB_NAME="${GLITCHTIP_DB_NAME:-glitchtip}" + +mkdir -p "$BACKUP_DIR" + +timestamp="$(date +%Y%m%d-%H%M%S)" +output="$BACKUP_DIR/glitchtip-${timestamp}.dump.gz" + +echo "[$(date -Iseconds)] Dumping ${DB_NAME} from ${DB_CONTAINER} to ${output}" + +# Stream pg_dump (custom format) directly through gzip — no intermediate file. +# `set -o pipefail` already in effect so a pg_dump failure aborts before retention. +docker exec -i "$DB_CONTAINER" \ + pg_dump -U "$DB_USER" -d "$DB_NAME" --format=custom --no-owner --no-privileges \ + | gzip -c > "$output" + +chmod 0600 "$output" + +size="$(wc -c < "$output" | tr -d ' ')" +echo "[$(date -Iseconds)] Wrote ${output} (${size} bytes)" + +echo "[$(date -Iseconds)] Pruning dumps older than ${RETENTION_DAYS} days" +find "$BACKUP_DIR" -type f -name 'glitchtip-*.dump.gz' -mtime "+${RETENTION_DAYS}" -print -delete + +echo "[$(date -Iseconds)] Done."