ADR-0009: RLS Enforcement via a Session-Scoped Authorized Scope Set¶
- Status: accepted
- Date: 2026-06-17
- Deciders: Nicolas Burri
- Refined by: ADR-0027 (content-plane vs control-plane RLS boundary)
Context¶
ADR-0002 mandates mandator/project separation enforced by PostgreSQL Row-Level Security, independent of application code (REQ-SEP-004), with an automated proof in CI (REQ-SEP-007, RISK-001). RLS needs a context: how does a database session know which scopes the current user is authorized for, so policies can filter rows?
Options Considered¶
Option A: Session variable carrying the authorized scope set (chosen)¶
The application computes the user's authorized scope IDs (eventually from role
assignments) and sets them once per connection/request in the session variable
lqms.authorized_scope_ids. Policies filter rows to that set.
- Pros: simple, fast (no per-query joins); policies are trivial and uniform across all content tables; works now, before the user/role-assignment tables exist; RLS remains a hard backstop against query-level bugs regardless of any WHERE clause.
- Cons: the app role can in principle set its own scope set, so the guarantee is "given the session's declared scopes, no query escapes them" — it protects against query bugs and injection that manipulate data queries, not against compromised connection-setup code. Hardening path below.
Option B: Session variable carrying the authenticated user id; policies join role assignments¶
Policies derive authorized scopes in-database from the user id.
- Pros: the database itself derives authorization; the session cannot claim scopes it has no assignment for.
- Cons: needs the role-assignment table (not yet built — catalog module); more complex policies; a join cost on every row check.
Option C: One database role per scope/mandator¶
Rejected: does not scale, and reintroduces the per-tenant fragmentation ADR-0002 avoided.
Decision¶
Option A. RLS context is the session variable lqms.authorized_scope_ids
(comma-separated UUIDs), read by lqms_authorized_scope_ids(). Policies grant a session
its authorized scopes plus the shared global base scope for reading (REQ-SEP-005).
Enforcement prerequisites baked into V002:
- A dedicated role
lqms_appholds DML on tenant tables and is subject to RLS. ENABLE+FORCE ROW LEVEL SECURITYon every tenant table.- The runtime application must connect as a member of
lqms_app— never as a superuser, the table owner, or aBYPASSRLSrole — or RLS is silently void. - The migration/admin/backup role has
BYPASSRLS.
Planned hardening (toward Option B): once the role-assignment table exists, the scope
set will be populated by a SECURITY DEFINER function that takes an authenticated user
id and derives the scopes in-database, so the session cannot assert scopes it lacks
assignments for. The policy shape stays the same; only how the variable is filled changes.
Consequences¶
- Every content-bearing table follows the same pattern: scope key column,
ENABLE/FORCERLS, a*_tenant_isolationpolicy keyed onlqms_authorized_scope_ids(). - The separation test suite (
RlsSeparationTest, REQ-SEP-007) seeds cross-scope data as the owner, then proveslqms_appcannot see/modify another mandator's rows even with an unfiltered query; it is negative-tested (weakening a policy makes it fail). - Open runtime gap (RISK-001): the API currently uses Quarkus Dev Services
(superuser), which bypasses RLS — RLS is proven at the DB/test level but not yet
enforced for the running app. Wiring the runtime datasource to
lqms_appand setting the session variable per authenticated request is a prerequisite before any real multi-tenant deployment, and depends on authentication (not yet built).