Access Control & Identity Management Policy
Access control is the strongest control domain in the stratify ISMS: unlike most of the register, several controls here are already implemented, not merely planned. This policy's job is therefore mostly to state, as policy, the principles a real technical architecture already enforces — and to be equally direct about where that architecture stops short (no MFA, no periodic access review) rather than let the strength of the implemented parts imply a maturity the weak parts don't support.
1. Access-control principle (5.15, 8.3)
Access to stratify data is enforced at the data layer, not only at the application layer. Every table holding personal or business-sensitive data has Row-Level Security (RLS) enabled in Postgres, and the default posture is deny, not allow. This is policy stated in support of an architecture that is already real:
- The core self-access pattern is
auth.uid() = user_id, applied acrosssubscribers,
strategy_mandates, signals, signal_deliveries, partner_api_keys, waitlist, pilot_kyb_checks, partner_kyb_checks, platform_settings, compliance_gate_signoffs, and more than 23 tables in total (register/facts.md, supabase/migrations/0002_rls.sql and repeated across later migrations).
- Deny-by-default for the most sensitive tables:
audit_logandsignal_deliverieshave RLS
enabled with no SELECT policy at all — there is no row any authenticated user, of any role, can read directly. The only read path is the Supabase service-role key, which structurally bypasses RLS and is gated at the application layer instead (§4).
- Some tables are intentionally public by design —
strategy_mandatesand livepilotsare
world-readable, matching the product's non-personalized, published-research posture. Public readability is a deliberate classification choice (POL-02 §4), not an oversight.
Controls 5.15 (Access control) and 8.3 (Information access restriction) are both scored implemented in register/controls.yaml on this basis. This is a genuinely strong control for a pre-seed product; the accurate caveat is that the policy language formalising it did not exist until this document — the implementation came first.
2. Identity management (5.16)
Identity is managed by Supabase Auth (GoTrue), self-hosted alongside the rest of the Supabase stack on the Hetzner host. GoTrue owns the auth.users table and the account lifecycle primitives (creation, session issuance, password/OTP verification). Control 5.16 is scored partial: GoTrue provides the mechanism, but stratify has no written identity-lifecycle policy layered on top of it — no documented rule for when an account should be deactivated versus deleted, no documented process for detecting and cleaning up stale or abandoned accounts, and no distinct onboarding policy for internal (admin/founder/compliance) identities as opposed to subscriber self-signup. The DPO / Privacy Owner (Antonios) is the assigned control owner in register/controls.yaml, reflecting that identity lifecycle for subscribers overlaps materially with the privacy retention questions in dsgvo-data-flow.md §2.
3. Authentication policy (5.17, 8.5)
Web sign-in uses a 6-digit email OTP code, not a magic link, delegated to Supabase GoTrue's built-in flow. This is the standard authentication method today, for every role. The design is deliberately hardened, documented in docs/adr/0002-email-code-auth-instead-of-links.md:
- Magic links were completely broken in production (Traefik returned 503 on
/auth/v1; the
mobile deep link path was also broken) — zero successful magic-link logins were ever recorded. Codes shipped 2026-07-20 as the fix, not as an incremental hardening exercise.
- The OTP expiry was deliberately reduced from GoTrue's 24-hour default to 10 minutes
(GOTRUE_MAILER_OTP_EXP=600), because a 6-digit (10⁶) keyspace against a per-IP-only verify limiter is brute-forceable within a long window when the target address is known. Verify attempts are rate-limited to 30 (GOTRUE_RATE_LIMIT_VERIFY=30).
- A password-based fallback also exists alongside codes — authentication is not pure passwordless.
- A known, acknowledged, unfixed gap: email-change is still link-based and still unreachable for
the same reason magic-link sign-in was (ADR-0002 lines 61-69).
The known gap this policy states plainly: no multi-factor authentication is offered anywhere, for any role, including admin, founder, and compliance — the roles with the broadest data access (GDPR export/erasure, subscriber administration, compliance gate sign-off). This is tracked as register/risks.yaml RISK-005: a compromised OTP channel (most plausibly, email account takeover) grants full admin-panel access with a single factor. The risk register scores this likelihood 2 / impact 4 (inherent score 8) — not scored as low-likelihood, because the elevated roles are exactly the accounts an attacker would prioritize, and not scored as low-impact, because those roles can trigger irreversible actions (GDPR erasure) and read the otherwise-inaccessible audit_log. Controls 5.17 and 8.5 are both scored partial: the OTP mechanism itself is a real, deliberately hardened improvement over what it replaced, but the absence of MFA for elevated roles is a genuine, unresolved gap this policy does not paper over. Closing it (adding a second factor for admin/founder/compliance at minimum) is the concrete near-term commitment this policy makes.
4. Access-rights policy (5.18)
Role-based access control is implemented via subscriber_roles, with the role set subscriber | pilot | compliance | founder | admin (apps/web/lib/auth/roles.ts). Guards are centralized, not scattered ad hoc through the codebase — apps/web/lib/admin/guard.ts defines requireAdminRead (roles: admin, founder, compliance), requireAdminWrite (admin, founder), requireAdminOnly (admin), and requireComplianceActor (compliance, admin, or founder). Every elevated write path is expected to call one of these guards and to record the resulting action via recordAudit().
Control 5.18 is scored partial. The RBAC mechanism and its centralized enforcement are real and implemented; what's missing is a periodic access-rights review process — nothing today confirms on a schedule that a given admin or compliance role assignment is still appropriate. This is the concrete gap procedures/PRO-02 (Access review, quarterly — planned, not yet written) is meant to close. Until PRO-02 exists and runs at least once, this policy commits to the principle (least privilege, role assignment traceable to a business reason) without claiming the review cadence is operating.
5. Privileged access (8.2, 8.18)
The Supabase service-role key is the sole structural bypass of Row-Level Security — there is no separate Postgres superuser or bypass role in use (register/facts.md). It is retrieved via getSupabaseService() and used exclusively in server-side admin code paths (e.g. apps/web/app/(admin)/subscribers/actions.ts), never in client-side or browser-reachable code. This policy states the handling rules that follow from that architecture, some already true in practice and some stated here as forward-looking commitment:
- The service-role key must never be exposed to client-side code, embedded in a browser bundle, or
logged. This matches existing practice in docs/secrets.md's anti-patterns list ("never expose to the browser").
- It is stored as a secret per POL-04 §2 (macOS Keychain in development, Docker environment
variable in production) — never committed, never placed in a .env file that reaches version control.
- On suspected compromise, it should be rotated immediately;
docs/secrets.md's rotation table
already names this trigger ("engineer leaves → rotate every secret they could have read, service role first"), though rotation itself is manual, not automated.
- Every code path that uses the service-role key to bypass RLS should be reviewable as a discrete,
named function (as getSupabaseService() callers already are) rather than an ambient capability available anywhere in the codebase.
Controls 8.2 (Privileged access rights) and 8.18 (Use of privileged utility programs) are both scored partial. The scoping described above is real and narrower than a typical service-role usage pattern — but no formal privileged-access review process exists (the same PRO-02 gap as §4), and there is no broader inventory of privileged-utility-equivalent access beyond the service-role key itself.
6. Endpoint devices (8.1)
The founder's laptop is the sole engineering endpoint with access to source code, production credentials (via Tailscale and the service-role key), and Keychain-stored secrets. No formal endpoint-security baseline is documented — disk encryption status, screen-lock timeout, and whether any EDR/anti-malware tooling runs are not confirmed in writing anywhere in the repo. Control 8.1 is scored partial for this reason: the endpoint exists and is the obvious single point of compromise for everything else in this policy (a compromised laptop reaches the service-role key, Keychain secrets, and Tailscale-gated production access in one step), but no baseline has been written or verified. This is a near-term documentation task, not a technical build: the laptop almost certainly already has disk encryption (standard on modern macOS) — the gap is that this hasn't been confirmed and recorded, not that the control is absent in practice.
7. Control summary
| Control | Status (controls.yaml) | This policy's role |
|---|---|---|
| 5.15 Access control | implemented | RLS as policy principle, not just implementation (§1). |
| 5.16 Identity management | partial | GoTrue provides the mechanism; no lifecycle policy (§2). |
| 5.17 Authentication information | partial | OTP as the hardened standard; no MFA anywhere (§3). |
| 5.18 Access rights | partial | RBAC real and centrally guarded; no periodic review yet (§4). |
| 8.1 User endpoint devices | partial | Laptop is the sole endpoint; no documented baseline (§6). |
| 8.2 Privileged access rights | partial | Service-role key tightly scoped; no formal review process (§5). |
| 8.3 Information access restriction | implemented | RLS deny-by-default on the most sensitive tables (§1). |
| 8.5 Secure authentication | partial | OTP replaced broken magic links; no MFA for elevated roles (§3, RISK-005). |
| 8.18 Use of privileged utility programs | partial | Service-role key is the one privileged path; no broader inventory (§5). |
8. Review
This policy is reviewed on the cadence set at approval, and immediately upon any of: a second engineer or contractor gaining admin/founder/compliance role assignment (making PRO-02 non-optional), a decision on MFA for elevated roles, or any suspected service-role-key compromise (triggering the rotation described in §5 and the incident path in docs/runbooks/db-recovery.md §3.3).