ADR-0026: Row-level security under the scoping¶
Status: Accepted, amended by ADR-0033 Date: 2026-10-12 Deciders: Alex Nodeland
Amends lattice RFC-0001: the second engine runs as the tables' owner, not a third role with BYPASSRLS; and a transaction that names no tenant sees nothing, rather than failing.
Context¶
Lattice RFC-0001 makes PostgreSQL's row-level security a second line of defence under each SQL adapter's own tenant scoping: each transaction names its tenant in lattice.tenant_id, policies show it only that tenant's rows, and the work that spans tenants runs on a second engine. grantr differs from the libraries in two ways. Every tenant's checks read the deployment's grants, the application's role among them. And three of its reads are asked before the tenant is known.
Decision¶
SqlStorage(engine, row_level_security=True, across_tenants=owner_engine). Each session is aTenantSessionwith its tenant, or~deployment, in itsinfo, and setslattice.tenant_idwithset_config(..., true)as its transaction begins, so a pooled connection never carries a tenant on. A session that names no tenant can't begin a transaction. The option is off by default, and does nothing to set on SQLite.- Migration 0003 creates the policies on PostgreSQL only, enabled and not forced, and
create_schemathe same ones. Each table has one that keeps every command to the transaction's tenant, for reading, changing and adding rows alike, andgrantr_grantsandgrantr_group_membersa second that lets any tenant read the deployment's rows, which its checks need. A deployment transaction names~deployment, so it reads and writes only the deployment's rows. migrate(engine, application_role=...)grants a role that owns nothing the tables, their sequences and the schema, so the application connects as it; the role must exist. Granting again changes nothing.- A transaction that names no tenant, or an empty one, sees nothing. The policies read
current_setting('lattice.tenant_id', true), which is null or empty then, and no row's tenant is either. The RFC had such a transaction fail; a session can't begin one anyway, so the policies need only fail closed. - Three reads span tenants, and only they use
across_tenants:tenant_grants, for a user's memberships;key, to find a key's tenant; andtenants, for operators.ACROSS_TENANTSlists them, and a test checks that no other method uses that engine. - The second engine runs as the tables' owner, which the migrations run as and the unforced policies don't hold, rather than a third role with
BYPASSRLS. The application then has two roles: the owner, for migrations and the reads across tenants, and a role that owns nothing, for everything else.
What a transaction sees and writes under the policies:
| Rows | Read | Changed, deleted or added |
|---|---|---|
| Its tenant's | Yes | Yes, and only to stay its tenant's |
| The deployment's grants and group members | Yes: a check reads the application's and operators' grants | No |
| The deployment's other rows: its audit log, keys, roles and groups | No | No |
| Another tenant's | No | No |
| Any, when the transaction names no tenant | No | No |
Options considered¶
| Option | For | Against |
|---|---|---|
| The owner as the second engine (chosen) | Two roles to create and keep; the owner bypasses its own tables' policies already | The reads across tenants could also change the schema, if one were wrong |
A third role with BYPASSRLS, as the RFC says |
That engine can't change the schema | A third role and password, and BYPASSRLS needs a superuser to grant |
| Policies that let the deployment's transaction read every tenant | No second engine | The deployment's own session would see every tenant, which is what the policies are for |
Consequences¶
- Easier: the contract test runs again as a role the policies hold, so a query that isn't scoped to its tenant fails in tests, not in production.
- Harder: an application that turns the option on connects with two URLs, creates its non-owner role before it migrates, and passes it to
migrate.