Skip to content
Updated Jun 9, 2026

ABAC - Attribute-Based Access Control

Status: draft · Owner: Chakshu · Informs: Phase 3 authz Decides: how subject attributes (from Keycloak) drive row- and field-level access via OPA (PDP) + an RLS floor (PEP), and which rule lives where. At-rest confidentiality is a separate concernData protection.

ABAC roleOlly component
PIP - attributesKeycloak (subject) + row attributes + request context
PDP - decisionOPA - one Rego π → point check and list filter (I1 no-drift), field-level Φ
PEP - enforcementthe service + the RLS floor at the data tier
PAP - policy adminthe Rego policy repo

Block diagram

Sequence - "open claim detail"


1. Model

P   principals (authenticated identities)        attr(p) = {role, org, party, schemes S(p), assigned, ...}
R   resources (rows)                             attr(r) ⊇ {tenant(r), member(r), provider(r)}, fields F(r)
A   actions {read, write, list, export, detok}
C   context {t, purpose, breakglass?, consent}
f   field of r;  cls(f) ∈ {public < internal < pii < sensitive}      (the sensitivity lattice)

Two policy functions, both derived from one Rego policy π:

D : P×A×R×C → {deny < allow}                     point decision     D(p,a,r,C) = π(actor:p, row:r, ctx:C)
Φ : P×A×R×C×F → {deny < τ < allow}               field decision     τ = a transform (mask, year-only, tokenise)
L(p,a,C) = { r ∈ R : D(p,a,r,C) = allow }         the scope (list)

τ (transform) sits strictly between deny and allow: reveal a lossy projection, not the raw value.


2. No-drift invariant (I1)

The list query Q(p,a,C) is generated from π by partial evaluation, not hand-written:

D = eval(π, row known)                            full eval        → boolean
Q = translate( compile(π, row unknown) )          partial eval     → SQL WHERE

I1. eval(Q(p,a,C)) = L(p,a,C) - a row lists ⟺ its point check allows. Single source: π.

translate is sound, fail-closed: an untranslatable residual yields WHERE false, never WHERE true. Soundness (never returns a row π denies) is mandatory; completeness (returns every row π allows) is the property the roles-poc measures empirically (§6).


3. Three layers compose as a meet (I2)

Visibility of field f of row r to p is the greatest-lower-bound across layers:

visible(p,r,f,C) =  ρ(r,p)                         -- L1 RLS:   structural ownership predicate
                 ∧  (Φ(p,read,r,C,f) ≠ deny)        -- L2 OPA:   authoritative row + field decision
                 ∧  (cls(f) ≥ pii ⇒ vault_ok(p,f,C))-- L3 vault: plaintext gated by key custody

I2 (defence in depth). Each layer is a term ⇒ a layer can only remove access. A single layer failing open degrades to meet of the other two - never to full access. Breach requires all three to fail on the same (p,r,f).

Plain-English explanation

Defence in depth, plainly. Three independent gates - the database row-filter (RLS), the policy engine (OPA), and the vault - must all say "yes" for a field to be visible, because they combine with AND. So any one gate can only ever take away access, never grant it. If one gate breaks and starts allowing everything, the other two still protect the data; a real breach needs all three to fail on the very same person, row, and field at once. That's why we keep three cheap-but-independent layers instead of one clever one.

LayerRuns inEnforcesBypassed by
L1 RLSPostgrescoarse tenant/org/scheme ownershipinsider with BYPASSRLS / table-owner / master key
L2 OPAsidecar, called pre-return & pre-promptauthoritative row scope + field/action/contexta service that never calls it; direct DB read
L3 vaultstandalone host, own credsplaintext of cls ≥ pii fieldspossession of the OpenBao-held master key

4. RLS ⟷ OPA boundary (D1)

OPA can own row filtering too (§2), so the split is not "rows vs fields". It is:

RLS  ⊇  { rule | depends only on (row cols, session GUCs) ∧ must survive arbitrary app bugs }   -- coarse, stable
OPA  ⊇  { everything field-level, transform-bearing, action/purpose/consent/time-dependent,
          cross-service context, OR the authoritative list scope (for I1) }

The tenant key is enforced in both. Consistency requirement:

I3. π ⟹ ρ - every OPA allow implies an RLS allow. RLS is a coarsening of π enforced where app bugs cannot reach. If I3 holds, RLS never breaks legitimate access and always backstops a forgotten WHERE. Testable.

Litmus: "true even if every line of service code is wrong?" → RLS. "needs more than who-owns-the-row?" → OPA.


5. Threat model & defeat (D2)

Adversaries as capability sets:

T1 curious staff      : holds valid JWT; may issue any API call.       ¬DB, ¬GUC, ¬vault-key
T2 cross-tenant bug   : T1 + can exploit a missing/wrong WHERE in a service
T3 insider w/ DB      : runs arbitrary SQL as app role; reads all tables. ¬vault master key, ¬privileged vault JWT
ThreatDefeated byArgumentCaveat (must hold)
T2L1ρ is conjoined to every query regardless of its text ⇒ rows ∉ ρ never returnapp role ≠ table owner; FORCE ROW LEVEL SECURITY; no BYPASSRLS; GUCs reset per txn
T1L2Φ denies rows/fields beyond grantdata-access path is the only path to PII and always calls OPA
T3L3tables hold tokens; plaintext recovery needs the vault, whose at-rest store excludes any single adminkey custody per companion doc (Shamir k-of-n, Transit KEK)

Vendor (Anthropic/Langfuse) is not in scope: the LLM wall = L2+L3 applied to prompt assembly. Tokens go into prompts/traces ⇒ raw PII never reaches the trace store.

T1's reach includes the telemetry backends (Grafana/Tempo/Loki/Jaeger/Langfuse on dev-2): span attrs, log fields, and metric labels are read paths to PII. Closed by I5 (§9a).


6. pii-vault - access face (D3)

Tokenisation map V : value → token, injective; V⁻¹ held only by the vault.

op DB stores:   V(x)              token (opaque)
   alongside:   σ(x)              display surrogate, lossy (e.g. "Bob S.")  - list rendering
                β(x) = HMAC_k(x)  blind index, non-invertible               - equality / prefix search
detok(p, tokens, reason):  returns V⁻¹ ∘ τ  iff Φ(p,detok,·)=allow;  appends audit row
  • Same value → distinct token (random nonce) ⇒ no DB-side correlation; equality search uses β, not the token.
  • Vaulted fields: cls ≥ pii across all four tiers - identity (name, DOB, NHS#, NI#, address, phone, email), clinical/triage free-text, financial (account, sort code, card last4, line-item amounts), geo/device.
  • At-rest confidentiality + key custody → companion doc (2026-05-26-pii-at-rest-confidentiality-design.md): envelope encryption, OpenBao Transit KEK, Shamir k-of-n. That doc owns the T3 / "no single admin" defeat.

7. Latency invariant (I4)

list/search/dashboard path :  renders from σ, β only           ⇒ 0 vault round-trips, cardinality-independent
detail path (one record)   :  1 × bulk_detok(k fields)         = 1 RTT + k·AES-GCM(µs) + 1 audit(~1ms sync)
JWT verify                 :  1 local JWKS check per request   = O(1) in k  (not per token)

I4. No code path SHALL detokenise a set whose cardinality scales with a list/search result. Vault cost = O(detail views), never O(rows). Audit write stays synchronous on the detail path (low volume by I4); async only weakens T3 attribution.


8. Audit & break-glass (D4)

  • Every detok and every OPA deny → append-only audit row {who, token|resource, action, reason, t}, on a store readable by compliance role only, not operators.
  • Break-glass = time-boxed elevated grant via C.breakglass, requires logged justification + 4-eyes; emits a high-priority audit event. It widens Φ, never ρ - i.e. break-glass cannot cross a tenant wall, only reveal within-tenant fields.

9. Observability (D5)

Two faces, both governed by the same lattice as everything else.

9a. Telemetry as a leak surface (I5)

Span attributes, log fields, exception payloads, metric label values, and the LLM trace store are field projections ⇒ subject to I2. They are read by T1 via the dev-2 backends.

I5. Telemetry carries only cls ≤ internal: tokens, locators (party/org/scheme), non-PII values. cls ≥ pii is tokenised or dropped before export.

Enforcement (allowlist, fail-closed - same bias as the §2 translate layer):

  • OTel collector: redacting span processor; attrs not on the allowlist are dropped/replaced, not passed.
  • slog handler: scrub cls ≥ pii fields; never log row payloads or error bodies verbatim.
  • metric labels: bounded cardinality, never a PII value (locators ok, names/NHS# never).
  • Langfuse: prompts/traces are token-only by §6 ⇒ I5 holds on the LLM path by construction.

9b. Signals that the walls hold

SignalSourceAlert when
OPA decisions {policy, action, result}, deny rate, eval p99, untranslatable-residual countOPAdeny spike; any untranslatable in prod
no-drift sample, point ≡ list (I1)canary job (the roles-poc check, in prod)mismatch ≠ ∅
I3 assertion π ⟹ ρperiodic jobcounterexample found
vault: detok rate, bulk-size dist, audit-write p99, decrypt errors, key-rotation agepii-vaultdecrypt error > 0; rotation overdue
audit lag (only if ever made async)audit storelag > SLO (async weakens T3 attribution)
break-glass eventsOPA/vaultevery event → compliance, high priority

RLS denials are invisible by construction (the rows simply don't appear) ⇒ the I3 assertion job is the only observation of the backstop. Required canary, not optional.

10. Phasing

PhaseScopeBuild-ready now?
0sensitivity classification on the ontology (cls(f)); append-only audit store; telemetry redaction in OTel collector + slog, fail-closed (I5)yes
1RLS floor: tenant_id/org/scheme cols + policies + per-checkout GUCs across services; I3 assertion canaryyes
2pii-vault MVP: V / detok / bulk_detok / rotate; σ + β; migrate triage free-text + party identity first; Langfuse exporter emits tokens only; vault signals (§9b)yes
3OPA partial-eval row scope (pending roles-poc go/no-go on I1 completeness); field-level Φ; break-glass; OPA + no-drift signals (§9b)strategy only
4+financial + geo tiers into vault; DEK rotation/rewrap job; blind-index search surfacesstrategy only

Dependency: Phase 3 row-scoping is gated on roles-poc FINDINGS (I1 completeness + sargability). RLS (Phase 1) ships regardless

  • it is the backstop, orthogonal to that outcome.

11. Determinations

  • D1 - RLS = coarse ownership backstop that survives app bugs; OPA = authoritative scope + all field/context decisions. Tenant key in both, constrained by I3 (π ⟹ ρ).
  • D2 - Three-actor threat model {T1,T2,T3}; one layer defeats each; vendor exposure out of scope.
  • D3 - Build pii-vault (Postgres + AES-GCM + OpenBao-wrapped DEKs); do not buy HashiCorp Enterprise; do not wait on OpenBao Transform. PDP engine = OPA/Rego.
  • D4 - Compliance-only append-only audit; break-glass widens Φ only, never ρ.
  • D5 - Telemetry is a field projection bound by I5 (cls ≤ internal only); redaction in OTel collector + slog, fail-closed. Wall-health signals and the I1/I3 canaries are required, not optional.

12. Open questions

  • Where do OPA sidecars run on dev-2 vs co-located with each Go service? (topology, not decided)
  • assigned (case assignment) lives in which service, and is it pulled into OPA input per-request or cached?
  • Blind-index key rotation strategy (rotating k invalidates all β).
  • Does triage store free-text as a token from word one, or tokenise on write after PII detection? (former is safer, simpler)
  • How does the telemetry redaction allowlist (I5) stay in sync with cls(f) - generated from the ontology classification, or hand-maintained? (drift here = silent PII leak into traces)

13. Not decided here

  • OPA partial-eval vs Zanzibar (SpiceDB/OpenFGA) - separate evaluation; this RFD assumes OPA per current direction.
  • Production OPA deployment topology and role governance.
  • Snowflake/BI pseudonymisation (the 4th boundary, deferred).

Olly Health Insurance Platform