Skip to content
Updated Jun 9, 2026

Chinese Walls for User PII

Status: draft · Owner: Chakshu · Informs: Phase 3 authz + a new pii-vault service Decides: how tenant, role, and LLM-trace PII boundaries are enforced, and where each rule lives.


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).

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