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.