Skip to content

Commit 9973f06

Browse files
docs(adr): 0007-access-control-model — prefix-typed principals, deny-wins, typed predicate (#95)
Closes #66. `verisimdb_access_policies.condition` was unbounded TEXT — anyone with insert access could write arbitrary SQL that would be spliced into a generated view. No model of principal identity, role composition, policy precedence, or interaction with provenance/ temporal views. ADR-0007 pins: - **Principal identity**: prefix-typed strings — `user:<id>`, `role:<name>`, `service:<name>`, or `*`. Future CHECK constraint can validate the prefix. - **Role composition**: flat (no inheritance) in this version. Actor's role set is supplied at query time as a session variable. Multi-role hierarchy is OQ-1. - **Policy precedence**: deny-wins. Any matching deny row blocks access regardless of other allows. Default in the absence of any matching row is deny. - **Interaction with views**: provenance/temporal views must compose the access-policy predicate into their WHERE clause. Leaking provenance about a row the user can't see is itself an information disclosure. V-L1-H2 (to file) does the implementation. - **Typed predicate language**: replace `condition TEXT` with an `AccessPredicate` AST (`Eq`, `In`, `And`, `Or`, `Not`) over typed `Literal`s. Excludes subqueries / function calls / arithmetic — those re-open the injection surface. Stored as JSON; identifiers go through V-L2-G1's `validate_identifier`; literals as bound parameters. V-L1-H3 (to file) does the implementation. Three open questions: role hierarchy, time-bounded policies, and where the actor's identity set lives. Cross-references ADR-0004 (concerns octad), V-L2-G1 (#39) for the validator the typed AST reuses, and V-L2-J1 (#43) for the `access_level` enum CHECK that already aligns with the four values this ADR uses. Doc-only. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2dd989e commit 9973f06

1 file changed

Lines changed: 212 additions & 0 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
= Architecture Decision Record: 0007-access-control-model
2+
<!-- SPDX-License-Identifier: PMPL-1.0-or-later -->
3+
<!-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> -->
4+
5+
# 7. Access control: principals, roles, deny-wins, typed predicate
6+
7+
Date: 2026-05-14
8+
9+
## Status
10+
11+
Accepted
12+
13+
## Context
14+
15+
`verisimdb_access_policies.condition` is currently unbounded `TEXT`.
16+
A user can write `"true OR users.id = (SELECT MAX(...))"` and the
17+
codegen layer will obediently splice it into a generated view. The
18+
table has no model of:
19+
20+
* **Principal identity**: what counts as a principal? A literal
21+
user-id? A role name? An arbitrary string?
22+
* **Role composition**: can `principal = "engineering"` match
23+
someone with role `"engineering"`? Can roles inherit?
24+
* **Policy precedence**: when multiple rows match (e.g. an
25+
`allow` row and a `deny` row for the same `(table, column,
26+
principal)`), which wins?
27+
* **Interaction with views**: provenance/temporal views currently
28+
bypass access policies entirely. Should they?
29+
* **Free-form SQL in `condition`**: V-L2-G1 (#39) shut the
30+
injection door on identifiers, but the `condition` column is
31+
still free-form SQL spliced into view definitions at generation
32+
time. A typed predicate language is needed.
33+
34+
This ADR pins each of these choices and unblocks the typed-AST
35+
follow-up.
36+
37+
## Decision
38+
39+
### 7.1 Principal identity
40+
41+
A principal is a **string with a typed prefix**:
42+
43+
* `user:<id>` — a literal end-user identifier (whatever the
44+
application authenticates).
45+
* `role:<name>` — a role name. Roles are independent of the
46+
identity provider; they are app-level groupings (e.g.
47+
`role:analyst`, `role:auditor`).
48+
* `service:<name>` — a non-human actor (cron job, ingest
49+
service, ETL pipeline).
50+
* `*` — the universal principal. Matches every actor. Useful for
51+
blanket deny-by-default policies.
52+
53+
The `principal` column in `verisimdb_access_policies` stores the
54+
full prefixed string. A future codegen-layer CHECK can validate
55+
the prefix (`principal LIKE 'user:%' OR principal LIKE 'role:%' OR
56+
principal LIKE 'service:%' OR principal = '*'`).
57+
58+
### 7.2 Role composition
59+
60+
Roles are **non-hierarchical** in this version.
61+
62+
* An actor's role set is supplied to verisimiser at query time
63+
via a `SET app.roles = 'role:analyst,role:auditor'` -style
64+
session variable (or its backend-specific equivalent).
65+
* A policy with `principal = "role:analyst"` matches an actor
66+
iff `role:analyst` is in that session's role set.
67+
* Multi-role inheritance (`role:senior_analyst inherits
68+
role:analyst`) is **out of scope**. A future ADR can add it as
69+
a separate dimension if real deployments need it.
70+
71+
This choice keeps the matching algorithm a flat set membership.
72+
Easy to audit, easy to express in a view.
73+
74+
### 7.3 Policy precedence
75+
76+
**Deny-wins.**
77+
78+
The view-generation algorithm:
79+
80+
1. Collect every policy row matching `(table, column, principal ∈
81+
actor's identity set)`, where the identity set is
82+
`{user:<id>, role:..., service:..., *}` for the current actor.
83+
2. If *any* matching row has `access_level = 'deny'`, the actor
84+
does not see the row/column. No further evaluation.
85+
3. Otherwise, the actor sees the row/column iff at least one
86+
matching row has `access_level = 'read'` or `'write'` (per
87+
the requested operation).
88+
4. Absent any matching row, the default is **deny**. (Tier 1's
89+
"safe by default" stance.)
90+
91+
`access_level = 'admin'` is a super-set of `'write'`. It exists
92+
for ergonomic UI badging but does not introduce a fourth tier in
93+
the evaluation order; admin → write → read in the matcher.
94+
95+
### 7.4 Interaction with views
96+
97+
Provenance and temporal views (`verisimdb_<table>_with_provenance`,
98+
`verisimdb_<table>_with_temporal`) currently bypass access
99+
policies. **They must not.**
100+
101+
The codegen layer, when emitting these views, must compose the
102+
access-policy predicate into the view's `WHERE` clause. A user
103+
without read on `posts` must not see provenance for `posts` rows
104+
via the enriched view either.
105+
106+
This is a hard requirement: leaking provenance about a row a user
107+
can't see is itself an information disclosure.
108+
109+
The implementation will follow in V-L1-H2 (to be filed).
110+
111+
### 7.5 Typed predicate language
112+
113+
Replace `condition TEXT` with a typed AST. Initial scope:
114+
115+
[source,rust]
116+
----
117+
pub enum AccessPredicate {
118+
/// Always true (no row-level filter).
119+
True,
120+
/// Column equality: `<column> = <literal>`.
121+
Eq { column: String, value: Literal },
122+
/// Column membership: `<column> IN (<literals>...)`.
123+
In { column: String, values: Vec<Literal> },
124+
/// Conjunction.
125+
And(Vec<AccessPredicate>),
126+
/// Disjunction.
127+
Or(Vec<AccessPredicate>),
128+
/// Negation.
129+
Not(Box<AccessPredicate>),
130+
}
131+
132+
pub enum Literal {
133+
Int(i64),
134+
Float(f64),
135+
Text(String),
136+
Bool(bool),
137+
Null,
138+
}
139+
----
140+
141+
This covers the common cases (`tenant_id = 42`, `region IN
142+
('eu', 'uk')`, `status != 'archived'`). It deliberately excludes
143+
subqueries, function calls, and arithmetic — those open the
144+
injection surface back up.
145+
146+
Serialisation is JSON in the `condition` column (renamed
147+
`condition_ast`), validated at insert time against a JSON Schema.
148+
Codegen emits the SQL by walking the AST; identifiers go through
149+
`crate::codegen::ident::validate_identifier` (V-L2-G1 from #39)
150+
and literals are bound as query parameters, never inlined.
151+
152+
The full AST + JSON Schema + codegen lands in V-L1-H3 (to be
153+
filed). This ADR is binding for that work.
154+
155+
## Consequences
156+
157+
### Positive
158+
159+
* Predicate language is auditable. A reviewer can read every
160+
policy and know exactly what it does.
161+
* No SQL injection surface via `condition`. Identifiers are
162+
validated; literals are bound parameters.
163+
* Deny-wins gives users a clear mental model. Most failures
164+
("why can't I see this?") are answered by "some deny matched".
165+
* Provenance/temporal views no longer leak.
166+
* `*` and prefix-typed principals make blanket policies easy to
167+
express ("deny:role:contractor on column:salary").
168+
169+
### Negative
170+
171+
* Migration: existing `condition TEXT` rows have to be re-encoded
172+
as `condition_ast` JSON. If there's anything in production now
173+
(likely not), V-L1-H3 carries a migration tool.
174+
* "No subqueries" rules out conditions like "owner_id =
175+
current_user". That's an authentication-context concern, not
176+
ACL; the actor's identity set carries it instead.
177+
* Roles flat (no hierarchy) is a Phase-1 simplification.
178+
Deployments that need it will press for an ADR-0008.
179+
180+
### Neutral
181+
182+
* The `verisimdb_access_policies` table already stores `principal`
183+
as TEXT and `access_level` as TEXT. The V-L2-J1 enum CHECK
184+
(#43) constrained `access_level` to
185+
`{read, write, admin, deny}` — exactly the four values this ADR
186+
uses. No DDL change from that side.
187+
* The `target_column TEXT` NULL → whole-row policy convention
188+
stays.
189+
190+
## Open questions
191+
192+
* **OQ-1**: Role hierarchy / inheritance. Deferred to a separate
193+
ADR.
194+
* **OQ-2**: Time-bounded policies (`valid_from`, `valid_to` on a
195+
policy row). Useful for "grant access until end of audit".
196+
Suggested follow-up.
197+
* **OQ-3**: Should the actor's identity set be carried in the
198+
session via SET or in a per-query parameter? Backend-specific;
199+
document the resolution in V-L1-H2.
200+
201+
## Cross-references
202+
203+
* ADR-0004 — concerns octad (AccessControl is the 6th concern).
204+
* V-L2-G1 (#39) — identifier validation (already merged). The
205+
typed predicate AST reuses `validate_identifier` for column
206+
names.
207+
* V-L2-J1 (#43) — `access_level` enum CHECK (already merged).
208+
Same four values used here.
209+
* `src/abi/mod.rs::AccessPolicy` — top-level struct.
210+
* `src/codegen/overlay.rs::generate_access_policy_table` — DDL.
211+
* `tests/integration_test.rs` — once V-L1-H3 lands, tests must
212+
cover deny-wins, prefix-typed principals, and view filtering.

0 commit comments

Comments
 (0)