ADR-0062: Row-level security under the scoping¶
Status: Accepted Date: 2026-10-12 Deciders: Alex Nodeland
Context¶
Every read and write of SqlStorage names its workspace, and so its tenant, and every primary key starts with the tenant (ADR-0030). Lattice RFC-0001 adds PostgreSQL's row-level security under that scoping, as a second line of defence, as artifactr's and grantr's adapters have it. reflexr differs from artifactr in one way: its dispatcher lists every tenant's workspaces, and its scheduler finds every tenant's due runs, so two of its reads span tenants.
Decision¶
SqlStorage(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. A session that names no tenant can't begin a transaction. The leases, which were written on the engine directly, now go through the same sessions. The option is off by default, and does nothing to set on SQLite.reflexr.sql.tenancyis the same code as artifactr's and grantr's.- Two reads span tenants, and only they use
across_tenants:workspaces, the dispatcher's, anddue_runs, the scheduler's.ACROSS_TENANTSlists them, and a test checks that nothing else in the module uses that engine. It runs as the tables' owner, as grantr's does (grantr ADR-0026). - Migration 0007 creates the policies on PostgreSQL only, enabled and not forced, on every table, and
create_schemathe same ones: a transaction reads, changes, deletes and adds only the rows of the tenant it names, and can't move a row to another. migrate(engine, application_role=...)grants a role that owns nothing the tables, their sequences and the schema; the role must exist. 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 artifactr and grantr.
Options considered¶
| Option | For | Against |
|---|---|---|
| The scheduler's and dispatcher's reads on the owner's engine (chosen) | They see every tenant, and nothing else can | Two engines to configure |
| A deployment tenant the policies let read every row | One engine | The application's own sessions could then read every tenant |
Consequences¶
- Easier: the workspace suite runs again as a role the policies hold, with the scheduler's due runs found in every tenant, so a query that isn't scoped to its tenant fails in tests.
- 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.