Files
crewli/dev-docs/runbooks/observability-erasure.md
bert.hausmans bf89090850 docs: observability triage + erasure runbooks
PR-4 commit 2. Both runbooks live under dev-docs/runbooks/ as the first
entries in that directory.

- observability-triage.md (270 lines): incoming-issue procedure. Tags
  inspectie (actor_scope, release, actor_type, organisation_id,
  impersonation), triage classes (P0–P3), reproductie via request_id
  correlation naar laravel.log, common patterns (validation leakage,
  runaway errors, multi-tenant invariant violations, CSP black-silence),
  resolution + audit trail.
- observability-erasure.md (293 lines): GDPR Art. 17 procedure.
  Trigger voorwaarden (upstream eerst), pre-checks, handmatige
  psql-procedure met counts vóór delete, post-checks, automation
  BACKLOG verwijzing, edge cases (no-events-in-window,
  impersonation-target, queued events, mass-erasure batch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:46:49 +02:00

9.3 KiB

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 (GDPR sectie), SECURITY_AUDIT.md (processing register entry voor GlitchTip), 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). 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

ssh crewli@<monitoring-host>     # monitoring.hausdesign.nl
docker exec -it glitchtip-postgres psql -U postgres -d glitchtip

3.2 Counts vóór delete (verifieer scope)

-- Direct events met user_id tag matchend
SELECT COUNT(*) FROM issues_event
WHERE tags @> ARRAY[ARRAY['user_id', '<ULID>']]::text[][];

-- Events waar deze user impersonator was
SELECT COUNT(*) FROM issues_event
WHERE tags @> ARRAY[ARRAY['impersonation.impersonator_user_id', '<ULID>']]::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', '<ULID>']]::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

-- Direct user_id events
DELETE FROM issues_event
WHERE tags @> ARRAY[ARRAY['user_id', '<ULID>']]::text[][];

-- Impersonator-as-admin events (alleen als verzoek dit dekt)
DELETE FROM issues_event
WHERE tags @> ARRAY[ARRAY['impersonation.impersonator_user_id', '<ULID>']]::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):

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:

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

\q

§4 Post-checks

Open https://monitoring.hausdesign.nl → projects → search:

user_id:<ULID>

Verwacht: 0 resultaten.

impersonation.impersonator_user_id:<ULID>

Verwacht: 0 resultaten.

4.2 Postgres re-count

docker exec glitchtip-postgres psql -U postgres -d glitchtip -c \
  "SELECT COUNT(*) FROM issues_event WHERE tags @> ARRAY[ARRAY['user_id', '<ULID>']]::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=<ULID>
  • 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=<TARGET-ULID>, impersonation.active=true, impersonation.impersonator_user_id=<ADMIN-ULID> 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:

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:

BEGIN;
DELETE FROM issues_event WHERE tags @> ARRAY[ARRAY['user_id', '<ULID>']]::text[][];
DELETE FROM issues_event WHERE tags @> ARRAY[ARRAY['impersonation.impersonator_user_id', '<ULID>']]::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.