0019 — Security posture and threat model
- Status: accepted
- Date: 2026-05-11
- Deciders: Derek
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:
- Nation-state attackers. Determined attackers with unlimited resources can compromise any infrastructure — npm supply chains, CI/CD systems, third-party libraries, the cloud provider’s own admin surface. We accept that defending against this class of threat is uneconomical at our scale. We mitigate the most likely vector (dependency vulnerabilities) by keeping the dependency surface small and reviewing additions deliberately.
- Tenants with regulatory data-residency requirements (HIPAA, GDPR data-localization, government classified). Multi-tenant shared infrastructure is the wrong shape for these. The right answer is the planned self-hostable mode (core tenet 6, ADR 0011): the tenant runs their own Supabase project, owns their own database, and the ark codebase is the platform they run on top of it. Stronger isolation than any VPN.
- A network-level isolation tier (VPN, dedicated infrastructure). Industry sometimes offers an “Enterprise Grid” deployment for high-paying customers that demand dedicated clusters. Ark does not currently offer this — our ICP (small arts orgs, NFPs) does not need it, and operating it would compete for engineering time we’d rather spend on product features.
- Email enumeration through timing attacks. The sign-up flow returns a uniform “check your email” message regardless of whether the email is already registered — this commit removes the message difference. Timing differences in the response (the already-registered path may be faster) remain observable. Supabase’s rate-limiting on
/auth/v1/*blunts this; full elimination would require a fixed response delay, which we judge not worth the UX cost at this scale. - Active denial-of-service campaigns. Cloudflare absorbs most volumetric attacks against the public sites. Supabase has its own protections on auth endpoints. A targeted application-layer attack would still degrade service — at our scale, that’s a “page someone” problem, not an engineering one.
What we monitor and audit
- The
org_access_audittable records every cross-tenant operation performed via service-role. The current rollout covers the operational paths (tenant provisioning, auto-provisioning of Cloudflare Pages projects). Extending this to every platform-admin mutation made through the upcoming multi-org switcher is queued as the immediate next item after that work lands. - CI runs the RLS isolation test on every PR. A red test blocks merge with no escape valve (ADR 0009).
- Sign-in attempts hit Supabase Auth’s rate limiter. Repeated failures across a short window throttle the offending IP.
Consequences
Good:
- One file (
packages/db/test/rls-isolation.spec.ts) is the security spec. Adding a new domain table requires extending this test, which forces the author to think about cross-org isolation by default rather than as an afterthought. - The shared-infrastructure shape is operationally cheap and lets ark serve dozens of tenants without per-tenant infrastructure work.
- The planned self-hostable mode gives high-compliance customers an answer when shared infrastructure is the wrong shape — they take ownership of the data layer entirely.
Bad / accepted:
- We rely on Supabase’s correctness for the foundational layers (auth, JWT signing, RLS enforcement). A bug in Supabase becomes a bug in ark. We choose Supabase precisely because the engineering team focuses on these primitives and audits them more thoroughly than we could.
- The service-role key is a single point of catastrophic failure. We mitigate by limiting where it lives, but a compromised CI secret or developer machine is a real risk. Rotation cadence and a key-management audit are listed under future work.
- Email enumeration via timing is not fully eliminated. We accept this and document it rather than implementing a fixed-delay sign-up flow.
Out of scope
- A formal SOC 2 audit. The work is real and the artifact has value when selling to mid-market customers — out of scope until we have a customer asking for it.
- Hardware security keys / WebAuthn for sign-in. Supabase supports OAuth providers; configuring them per-tenant is plausible future work but not a current ICP requirement.
- Bring-your-own-encryption-key for tenant data at rest. Postgres supports column encryption with managed keys; the operational complexity isn’t justified at our scale.
- An automated dependency-scanning pipeline (Dependabot, Snyk). Reasonable to add when the dependency tree grows or when shipping to security-conscious customers. Tracked as a follow-up.
- Per-action audit logging for platform-admin mutations across tenants. Queued behind the multi-org switcher implementation.
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:
- A new tenant has compliance requirements we currently can’t meet.
- We add a feature that introduces a new trust boundary (e.g. third-party integrations that hold credentials on the tenant’s behalf).
- An incident reveals a class of attack the current posture didn’t anticipate.
- The shared-infrastructure shape changes (e.g. self-hostable mode ships).