← All decisions

Security posture and threat model

accepted

0019 — Security posture and threat model

Context

Ark is a shared-infrastructure SaaS. One Supabase project, one Cloudflare account, one codebase — many tenant organizations. A natural concern from any prospective tenant (“how do I know my data isn’t visible to other tenants?”) is foundational and deserves an answer in writing, not a hand-wave. This ADR is the canonical answer: what we protect against, how, and what we deliberately do not protect against.

Per ADR 0008, this document is product copy. A non-engineering reader should be able to understand it and feel reassured. Engineers reading it should find concrete commitments they can verify in the code.

Decision

Ark’s security model rests on five layers, none of which is novel and all of which are industry standard for multi-tenant SaaS.

1. Logical isolation between organizations

Every domain table includes organization_id UUID NOT NULL. Every Row-Level Security (RLS) policy on every table filters on it. The keystone test in packages/db/test/rls-isolation.spec.ts boots a real Postgres, applies every migration, creates two organizations with separate users, and proves no query path returns one org’s data to the other. The test failing in CI blocks any merge (ADR 0002, ADR 0009).

This means: even if the admin app had a bug that navigated a user from Org A into a route belonging to Org B, the database returns zero rows because the access token doesn’t claim membership in Org B. The page renders empty; no data leaks.

2. Authentication

Identity is handled by Supabase Auth. Email + password (or magic-link), JWT access tokens signed by the project’s secret, automatic refresh, sign-out clears tokens. The same authentication surface that Slack, Notion, Linear, and most SaaS use. We don’t roll our own crypto, password hashing, or session management — those are well-trodden Supabase concerns.

3. Authorization

Once a user is identified, a Postgres trigger writes their organization memberships into the access token as app_metadata.org_ids (UUIDs) and app_metadata.org_roles (UUID → role) — see migrations 007 and 016. RLS policies read these claims via helpers app.is_org_admin() and app.user_can_write_in_org(). The API additionally checks them in middleware (requireOrgAdmin, isOrgAdminOrPlatformAdmin) so unauthorized requests fail fast with a 403 rather than waiting on the database.

ADR 0014 is explicit: the database is the security gate. The UI gates affordances by role purely as a UX hint — never as a defense. A user with the right session who calls the API directly with curl is still bound by the same RLS policies they’d hit through the UI.

4. Service-role key handling

The Supabase service-role key bypasses RLS entirely — anyone holding it owns every tenant’s data. Per ADR 0017, the service-role key never ships to the browser. It lives in three places only: developer machines (via local .env, gitignored), the API server’s secrets store (Railway), and the build environment that runs Astro for the public site. Code that needs to mutate cross-tenant data (audit-required operations) acquires the client via createServiceRoleClient() and writes an org_access_audit row recording who did what.

5. Public surface hardening

The public site (apps/site) is static SSG output served from Cloudflare Pages. It has no authentication surface, no API, no user input — just pre-rendered HTML containing whatever content the tenant chose to publish (services pages, public team listings, ADRs on the meta-tenant). Hitting it as an outsider is equivalent to hitting any marketing site: you see what the tenant published. Cloudflare provides DDoS protection in front.

The admin application is a separate origin, requires authentication for every action, and routes all data access through the API or directly through Supabase clients that enforce RLS. Sign-up is open (anyone can register an email), but a fresh account belongs to no organization and sees no data. Joining a tenant requires being explicitly invited by an admin (magic-link invitation) or being created by a platform admin.

What we do not protect against

Honesty about gaps is more useful than security theater. The following are out of scope for ark’s current model:

What we monitor and audit

Consequences

Good:

Bad / accepted:

Out of scope

What to do when this changes

The threat model isn’t fixed. Re-open this ADR (write a 0020 superseding it, per ADR 0008 — ADRs are immutable once accepted) when any of the following changes: