Technical and Organisational Measures (TOMs)
Satisfies GDPR Art. 32 and revDSG Art. 8 (angemessene technische und organisatorische Massnahmen). dsgvo-data-flow.md §9 states the measures as a short bullet list; this document is the detailed technical backing for that list, pulling the mechanism-level detail from register/facts.md that §9 only gestures at. It does not replace §9 — it is what a partner, investor, or regulator asking "what exactly do you mean by that bullet" gets pointed to.
1. Access control — Row-Level Security
Every table holding personal or business-sensitive data has Postgres Row-Level Security enabled, default-deny. Core self-access pattern: auth.uid() = user_id, applied across ≥23 tables including subscribers, strategy_mandates, signals, signal_deliveries, partner_api_keys, audit_log, waitlist, pilot_kyb_checks, partner_kyb_checks (supabase/migrations/0002_rls.sql and repeated in later migrations).
Deny-by-default on the two most sensitive tables: audit_log and signal_deliveries have RLS enabled with no SELECT policy at all — no authenticated user of any role can read a row directly through the application's normal query path (0002_rls.sql:41,49-50). The only read path is the Supabase service-role key, which structurally bypasses RLS and is gated instead at the application layer via getSupabaseService(), used only in server-side admin code (apps/web/app/(admin)/subscribers/actions.ts:25,60,105). There is no separate Postgres bypass role — the service-role key is the sole privileged path, and every caller of it is a named, reviewable function rather than an ambient capability.
Some tables are intentionally public by design (strategy_mandates, live pilots) — a deliberate classification choice matching the product's published-research posture, not an oversight.
2. Audit-log integrity — append-only, chain-hashed
The audit log is the control most worth naming precisely, since "chain-hashed" alone under-specifies what actually happens:
- Mechanism: each event is a deterministic, key-sorted canonical JSON payload, chained as
SHA-256(prev_hash || canonical(payload)) (apps/web/lib/audit/chain.ts:34-39,60-67). Every row cryptographically commits to the full history before it.
- Verification:
verifyChain()re-walks the table in pages of 1,000 rows and reports the first
point of divergence (chain.ts:101-137) — this is a detective control, run on demand or as part of incident response, not continuous.
- Append-only enforcement is at the database layer, not application convention. A Postgres
trigger function, audit_log_no_mutate(), raises an exception on any BEFORE UPDATE or BEFORE DELETE against audit_log (supabase/migrations/0001_init.sql:124-154). Combined with the RLS deny-all-SELECT policy in §1, this means: no application bug, no compromised subscriber session, and no ordinary database role can silently edit or erase history — only a compromised service-role key could attempt it, and verifyChain() would detect the resulting break.
- A dedicated incident runbook exists for the specific failure mode of chain corruption
(docs/runbooks/db-recovery.md §3.3, "P0 Compliance": forensic snapshot, service-role lockdown, counsel notification within 24h, DPO note re: GDPR Art. 33).
- Honest limit: chain verification detects tampering after the fact; it does not prevent a
compromised service-role key from writing one more valid link. No control here claims otherwise.
3. Authentication — OTP parameters
Web sign-in uses a 6-digit email OTP, delegated to Supabase GoTrue (apps/web/app/sign-in/sign-in-form.tsx:72,106-110), replacing magic links that never once succeeded in production (docs/adr/0002-email-code-auth-instead-of-links.md). The deployed parameters (infra/hetzner/supabase/docker-compose.stratify.override.yml:38-42):
| Parameter | Value | Rationale |
|---|---|---|
GOTRUE_MAILER_OTP_EXP | 600 seconds (10 minutes) | Reduced from GoTrue's 24h default — a 10⁶ keyspace against a per-IP-only verify limiter is brute-forceable over a long window when the target address is known |
GOTRUE_MAILER_OTP_LENGTH | 6 digits | GoTrue default |
GOTRUE_RATE_LIMIT_VERIFY | 30 attempts | Caps brute-force attempts within the 10-minute window |
Password reset and invite use the same code mechanism (apps/web/app/reset-password/reset-password-form.tsx:104). Known, acknowledged gap: email-change is still link-based and still unreachable for the same reason magic-link sign-in was; a password fallback also exists alongside codes, so authentication is not pure passwordless. No MFA exists for any role, including admin/founder/compliance — see register/risks.yaml RISK-005 and POL-03 §3.
4. Network — host firewalling
Production runs on a single Hetzner host (128.140.8.187, region nbg1). The public IP is firewalled to Tailscale-only access for administrative/SSH-equivalent surfaces (docs/runbooks/deploy.md:3-19) — there is no public SSH or admin port exposed; access requires being on the Tailscale mesh network. Application traffic (the public web/API surface) is separately exposed via TLS 1.3 (dsgvo-data-flow.md §9). Deploys are pull-based via a systemd timer (stratify-deploy.timer, polling every 2 minutes) rather than a push-based CI/CD path with standing deploy credentials.
5. Other measures carried from dsgvo-data-flow.md §9
- TLS 1.3 for all endpoints.
- Partner-API-key hashing: SHA-256 with a secret prefix (bcrypt-equivalent).
- Sentry: PII sanitization enabled, server-side only — no client/browser Sentry init exists
(apps/web/instrumentation.ts), which is itself a visibility gap (front-end errors may go uncaptured), not a privacy risk.
- PostHog: no autocapture; explicit
identify()only after sign-up. - Backups: nightly
pg_dumpagainst the Hetznerstratify-dbcontainer, 14-dump retention,
05:30 UTC cron (scripts/hetzner-backup-postgres.sh), stated RPO ≤ 24h / RTO ≤ 60 min. Restore has never been tested — tracked as a real gap, closed by procedures/PRO-07 once written.
6. Gaps this document does not paper over
| Gap | Status | Tracked in |
|---|---|---|
| No MFA for any role | Open | RISK-005, POL-03 §3 |
| No SCA/dependency scanning in CI | Open | PRO-06 (pending) |
| Restore never tested | Open | PRO-07 (pending), RISK-004 |
| No periodic access review | Open | PRO-02 (pending) |
| Front-end error telemetry not captured | Open | Noted here and in facts.md |
7. Review
Reviewed whenever the underlying mechanism changes (RLS policy edits, auth config changes, host migration), and at minimum annually alongside dsgvo-data-flow.md §9.