ADR-0027: Row-level security under the scoping¶
Status: Accepted Date: 2026-10-12 Deciders: Alex Nodeland
Context¶
Every read and write of SqlLedger names its workspace, and so its tenant, and every primary key starts with the tenant. Lattice RFC-0001 adds PostgreSQL's row-level security under that scoping, as a second line of defence, as artifactr's, reflexr's and grantr's adapters have it. One of the ledger's reads spans tenants: followed, the workspaces a follower has held, which the followers start from.
Decision¶
SqlLedger(engine, row_level_security=True, across_tenants=owner_engine). Each session is aTenantSessionwith its workspace's tenant in itsinfo, and setslattice.tenant_idwithset_config(..., true)as its transaction begins, so a pooled connection never carries a tenant on; one that names none can't begin a transaction. The option is off by default, and does nothing to set on SQLite.relayr.sql.tenancyis the same code as the libraries' and grantr's.followedis the one read onacross_tenants, an engine as the tables' owner (grantr ADR-0026).ACROSS_TENANTSnames it, and a test checks that nothing else in the module uses that engine.- Migration 0004 creates the policies on PostgreSQL only, enabled and not forced, on its three tables, and
create_schemathe same ones: a transaction reads, changes, deletes and adds only the rows of the tenant it names, can't move a row to another, and sees nothing when it names none. migrate(engine, application_role=...)grants a role that owns nothing the tables, their sequences and the schema. The role must exist, so it is created before the first migration; granting again changes nothing.- A transaction that names no tenant, or an empty one, sees nothing, rather than failing as lattice RFC-0001 has it, as in the libraries and grantr: the policies read
current_setting('lattice.tenant_id', true), which is null or empty then, and no row's tenant is either.
Options considered¶
| Option | For | Against |
|---|---|---|
followed on the owner's engine (chosen) |
The followers start in every tenant, and nothing else reads across them | Two engines to configure |
| The followers start per tenant, from grantr's list | No read across tenants | relayr would need the list of tenants, which it doesn't depend on |
Consequences¶
- Easier: the ledger's suite runs again as a role the policies hold, so a query that isn't scoped to its tenant fails in tests.
- Harder: an application that turns the option on gives the ledger two engines, as it does the libraries.