Amends 0239 (decision
1: the equality policy is the only permissive policy, and narrowing is a restrictive layer; the
Enforcement audit) by a Note. Reads with 0231 as amended
on 2026-10-24 (policy names),
0210 (every
tenant-owned table has its policy; global models have none) and
0338
(sessions scoped to one legal entity).
Context
0239 gives every business table one row-level-security policy: equality ontenant_id, the same for every table, so the isolation test is one pattern. As
the database package is built, that is one permissive policy per table. New needs want rows narrowed further inside a
tenant: a device session that holds only its legal entity’s records
(0338 decision 3),
and a legal entity as the transfer boundary of a multi-country tenant
(0343 decision 4). Postgres combines policies
two ways: permissive policies are joined by OR, so a second permissive policy widens access; restrictive policies are
joined by AND, so each one can only narrow. The framework build asked on 2026-10-24 how the two layers are used, and
this ADR answers.
Decision
-
Exactly one permissive policy per business table, the tenant equality of 0239. It is named
pol_<table>_tenant_id(0231), it isFOR ALL, and its one allowed extra clause is the audited operator grant of 0239 decision 3. Gen writes it fromBelongsToTenant(0321); nobody writes a second permissive policy, and a migration that creates one fails. -
Every narrowing is a restrictive policy. Anything that limits rows inside a tenant is
AS RESTRICTIVE, so it can only remove rows the tenant policy allows and never add one. Restrictive policies are generated, never hand-written, and each is named after the column it tests,pol_<table>_<column>(0231). -
A model declares the columns a session may be narrowed to. A model lists them as
rowScopes(todaylegalEntityId, the dimension 0338’s device sessions and 0343’s transfer boundary use), and gen emits one restrictive policy per listed column of the shape “the session setting is unset, or the column equals it”:current_setting('app.<column>', true) IS NULL OR <column> = current_setting('app.<column>', true)::<type>. An unscoped session therefore sees the whole tenant, and a scoped session sees its slice. The settings are written by the tenancy context (withTenant) from the authenticated session, asapp.tenant_idalready is, never from a client value. -
What is not a restrictive policy. Record visibility rules
(0333 decision 5) stay in the query layer; the IAM
scope path stays a predicate inside one tenant (0239 decision 4); soft deletes, holds and classes are column
effects, not policies. Read models keep the shape of 0239 decision 2, and a global model
(
Model.global, 0210 as amended on 2026-10-24) has no policy at all. -
Force and audit. Every tenant-owned table has row-level security enabled and forced. The RLS catalogue audit of
0239 checks, per table:
FORCE ROW LEVEL SECURITY, exactly one permissive policy with the tenant shape, every other policy restrictive and named by its column, and no policy that names a column the model does not list inrowScopes. A policy the audit cannot classify fails the gate.
Consequences
- Tenant isolation stays one pattern and one test; narrowing cannot widen it, by the semantics of the layer it lives in.
- A scoped device or user is narrowed in the database, not only in the application, so a bug above the database cannot leak another legal entity’s rows to it.
- Each restrictive policy adds a predicate to every read of its table; a model lists a scope column only when a session is actually narrowed to it.
- The migration generator gains the
AS RESTRICTIVEform and the per-column names, and the frozen-snapshot rule of 0159 applies to them as to every other statement.
Enforcement
- planned — the RLS catalogue audit of 0239, extended as decision 5 says, in the tenant isolation tests.
- planned — gen (STA-340): a second permissive policy, a hand-written
CREATE POLICY, or a restrictive policy on a column outsiderowScopesfails the build; the policy names follow 0231. - planned — a tenant isolation test: a session with
app.legal_entity_idset reads no row of another legal entity in the same tenant, and a session without it reads the whole tenant.
Alternatives
- Widening the tenant policy with more clauses. Not chosen: every added clause is a chance to widen, and the audit can no longer check one shape.
- Narrowing only in the application layer. Not chosen for session scoping: the database is the isolation boundary of 0239 and 0333 decision 5, and a scoped device must be narrowed there.
- One schema per legal entity. Not chosen: 0210 decision 7 keeps the pool with no schema per tenant, and a legal entity is a dimension inside a tenant.