Stratify
Legal and compliance

Technical and Organisational Measures (TOMs)

docs/compliance/isms/privacy/PRIV-04-technical-organisational-measures.md

Source updated 03. Aug. 2026

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):

ParameterValueRationale
GOTRUE_MAILER_OTP_EXP600 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_LENGTH6 digitsGoTrue default
GOTRUE_RATE_LIMIT_VERIFY30 attemptsCaps 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_dump against the Hetzner stratify-db container, 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

GapStatusTracked in
No MFA for any roleOpenRISK-005, POL-03 §3
No SCA/dependency scanning in CIOpenPRO-06 (pending)
Restore never testedOpenPRO-07 (pending), RISK-004
No periodic access reviewOpenPRO-02 (pending)
Front-end error telemetry not capturedOpenNoted 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.