Three independent layers of tenant isolation
Workspace isolation is enforced three times on the way to the data: the customer_safe allowlist in the middleware, the SQLAlchemy workspace kernel, and row-level security forced in PostgreSQL. They are not redundancy for its own sake — each catches a class of mistake the others structurally cannot see, and one real defect was caught only by the third.
The three layers, in the order a request meets them#
browser -> tenantAdapt rewrites the path for a customer session
-> auth -> customer_safe allowlist -> workspace_scope()
-> router (operator_* for the Owner, tenant_unified_ui for a customer)
-> workspace_console / operator_ui <- shared by both audiences
-> SQLAlchemy, filtered again by workspace_kernel
-> PostgreSQL, filtered a third time by row-level security| Layer | Enforces | Blind to |
|---|---|---|
customer_safe in main.py | Which /api/* paths a customer session may call at all | What a permitted handler then does with an identifier from the request body |
workspace_kernel | That ORM queries are filtered to the workspace in scope | A load that is not a query — a fetch by primary key can be answered from the identity map |
| PostgreSQL row-level security | Every read and write on a scoped table, whatever issued it | A table that has no policy, and a row whose stamp matches no scope |
tenantAdapt in the browser rewrites an operator path into its customer equivalent, and anything it does not recognise becomes a blocked path that answers 403. That is useful and it is not one of the three: code in a browser is a convenience, and the allowlist behind it is the boundary.
What each one caught that the others could not#
Layer one. A customer session calls an operator path that no customer surface exposes.
Result The allowlist refuses it before any handler runs. Neither of the lower layers would object, because the query the handler would have issued is perfectly well-formed and correctly scoped — it is the capability that is wrong, not the row.
Layer three. Four mailbox routes — the connection form, sync, and the OAuth start and disconnect — took a mailbox id from a browser and looked it up with a bare
db.get.Result The kernel's query filter can miss that entirely when the row is already in the identity map, so layer two was not in the path at all. The database's own policy was. Those routes go through
mailbox_console.row_fornow; only the provider webhook that resolves its mailbox before any workspace is known still does not.Neither. Three
prospect_*tables shipped with row-level security off, becausecreate_allmakes a table at boot while the policy comes fromtools/pg_harden.py, an operational script.Result No layer noticed, because the tables were correctly scoped in the ORM and correctly reached through permitted paths. A test does:
test_global_prospect_intelligence_v1fails on any new table that repeats it.
What three layers cost#
- A repair cannot be written from a request. Stamping a stray row is refused by the policy's
WITH CHECK— the row is not in scope to be updated into scope — so an attempt turns a quiet defect into a 500 on somebody's screen. The repair belongs to the schema owner and runs at boot, inmigrate.backfill_canonical_workspace. - Every new scoped table is a two-part change. The model, and the policy in the same release. Forgetting the second half is invisible until a test looks.
- A diagnostic query has to be deliberately read-only. Being able to see a row and being allowed to change it are separate permissions here, and collapsing them is how a diagnostic becomes an outage.
- There is a permitted exception, and it is written down. The provider webhook that resolves a mailbox before any workspace exists cannot be inside a workspace scope; naming it is better than pretending the rule has none.
Questions#
Is row-level security not enough on its own?
It is the strongest of the three and it still has two blind spots: a table with no policy, and a row whose stamp matches no policy. It also cannot express "this session may not call this capability at all", which is what the allowlist is for. Each layer is answering a different question.
Does the browser enforce any of this?
No. tenantAdapt rewrites paths for a customer session and refuses to guess at an unmapped one, which is good behaviour in a client and is not a security boundary. Everything it does is re-decided server-side by the allowlist, the kernel and the database.
How would you know if a layer had been bypassed?
By the shape of the symptom rather than by an alarm. A capability reachable that should not be points at the allowlist; a row visible that should not be points at a query that never met the kernel; a table with no policy is found by enumerating tables, which is what the prospect-intelligence suite does on every run.