Sign in spec

Security

Two claims here are worth more than a page of assurances: what the isolation tests do when we deliberately break the code, and what this design explicitly does not protect you from.

Authorization is layered. Row-level security is the last layer, never the only one.

1.  verified OIDC identity
2.  verified organization membership
3.  application authorization
4.  resource ownership / grant
5.  PostgreSQL row-level security      ← defence in depth
A system that relies on step 5 alone has one bug between a customer and someone else's data.

Tenant context is derived on the server from a verified token. An organization id supplied by a browser is never trusted, and it is not the thing the database is told.

A missing tenant context is an error, not an empty result

This is the subtle one. PostgreSQL's current_setting(name, true) returns NULL when a setting is absent, and a security policy that evaluates to NULL merely hides the row. So the obvious implementation turns "the server forgot to establish tenant context" into "this organization has no data" — a silent wrong answer, in the direction that looks perfectly normal, that no ordinary test will ever catch.

valid context      →  uuid
context missing    →  RAISE
context malformed  →  RAISE
Every policy in the schema goes through one function with this contract. It has no code path that returns nothing.

The exact boundary, because the first version of this claim was too broad

A policy expression is evaluated per row. So a statement the query planner can prove scans nothing never reaches that function and never raises. We measured where the line actually falls, and it is narrower than we first assumed — an empty table still raises, because the scan is still planned. Only a constant-false qualifier removes it:

SELECT ... FROM cd.worlds WHERE false     no raise, returns 0   ← the whole gap
SELECT ... FROM cd.audit_events (empty)   RAISES
SELECT ... FROM cd.worlds LIMIT 0         RAISES
SELECT ... FROM cd.worlds                 RAISES
Asserted as a known limit in the battery, so it cannot be quietly rediscovered later.

So the guarantee the database makes is no row may pass without a valid tenant context — not "every statement raises". The difference does not weaken the isolation, since a statement that returns no rows leaks nothing. It does mean the raise is a loud backstop rather than the primary control, and the application establishes tenant context on entering a transaction rather than relying on a policy to notice later.

Row-level security is enabled and forced on every tenant table, so the table owner is policed too. Every exemption from that is a named, listable policy — there are exactly two, both read-only, both belonging to the login path that resolves an identity before a tenant context can exist. Nothing bypasses invisibly.

The negative control

A test suite that passes is not evidence until you know it can fail. The isolation battery has a sabotage mode that swaps the fail-closed function for the naive version described above, and re-runs unchanged.

./worker/test/tenant-isolation.sh — 196 checks, mostly negative, as of 2026-08-25
RunPassedFailedWhat it demonstrates
Normal 196 0 Cross-tenant reads, writes, updates and deletes all refuse.
Sabotaged 186 10 With the naive function installed, an unscoped query returns 0 rows and reports success. The battery catches it.
Regression proof 76 120 With migrations 0008 + 0009 + 0010 + 0011 + 0014 + 0016 + 0017 + 0018 + 0019 + 0020 + 0021 + 0022 + 0023 + 0024 + 0025 + 0026 + 0027 removed, the checks added for those defects fail — so they are load-bearing, not decorative.
Concurrency 34 0 Two real sessions, hand-stepped to a deliberate interleaving — ./worker/test/concurrency.sh. Simultaneous first logins resolve to one principal; simultaneous quota requests cannot oversubscribe. Serial correctness is not concurrent correctness.
Worker suite 486 0 Token verification, tenant context, credential scoping, provider refinement and storage admission — npm test --prefix worker. No account, no network, no dependencies.
Worker mutations 139 0 Each vector breaks one security decision in a copy of the source and re-runs the unmodified suite. A surviving mutation is an untested decision.

The battery also covers connection re-use, because the production path is transaction-pooled: a context established in one transaction must not survive onto the next request that lands on the same backend.

What this does not solve

Collapsing these two questions is a category error, and calling the first one by the second one's name would be a lie.

Question Answered by
Who are you, and are you entitled? Everything on this page.
Why can the storage provider not read your data? Client-side keys. Nothing on this page touches it.

So: this is authorization, not confidentiality. Nothing here is described as end-to-end encrypted, because nothing here makes it so. Content addressing is actively hostile to access control — if the key is the hash of the content, then knowing the content is knowing the key — and the question of where confidentiality comes from is open and recorded as open rather than quietly assumed away.