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 WHEREI1. 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 custodyI2 (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).
| Layer | Runs in | Enforces | Bypassed by |
|---|---|---|---|
| L1 RLS | Postgres | coarse tenant/org/scheme ownership | insider with BYPASSRLS / table-owner / master key |
| L2 OPA | sidecar, called pre-return & pre-prompt | authoritative row scope + field/action/context | a service that never calls it; direct DB read |
| L3 vault | standalone host, own creds | plaintext of cls ≥ pii fields | possession 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| Threat | Defeated by | Argument | Caveat (must hold) |
|---|---|---|---|
| T2 | L1 | ρ is conjoined to every query regardless of its text ⇒ rows ∉ ρ never return | app role ≠ table owner; FORCE ROW LEVEL SECURITY; no BYPASSRLS; GUCs reset per txn |
| T1 | L2 | Φ denies rows/fields beyond grant | data-access path is the only path to PII and always calls OPA |
| T3 | L3 | tables hold tokens; plaintext recovery needs the vault, whose at-rest store excludes any single admin | key 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 ≥ piiacross 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
detokand every OPAdeny→ 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 ≥ piifields; 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
| Signal | Source | Alert when |
|---|---|---|
| OPA decisions {policy, action, result}, deny rate, eval p99, untranslatable-residual count | OPA | deny spike; any untranslatable in prod |
| no-drift sample, point ≡ list (I1) | canary job (the roles-poc check, in prod) | mismatch ≠ ∅ |
I3 assertion π ⟹ ρ | periodic job | counterexample found |
| vault: detok rate, bulk-size dist, audit-write p99, decrypt errors, key-rotation age | pii-vault | decrypt error > 0; rotation overdue |
| audit lag (only if ever made async) | audit store | lag > SLO (async weakens T3 attribution) |
| break-glass events | OPA/vault | every 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
| Phase | Scope | Build-ready now? |
|---|---|---|
| 0 | sensitivity classification on the ontology (cls(f)); append-only audit store; telemetry redaction in OTel collector + slog, fail-closed (I5) | yes |
| 1 | RLS floor: tenant_id/org/scheme cols + policies + per-checkout GUCs across services; I3 assertion canary | yes |
| 2 | pii-vault MVP: V / detok / bulk_detok / rotate; σ + β; migrate triage free-text + party identity first; Langfuse exporter emits tokens only; vault signals (§9b) | yes |
| 3 | OPA 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 surfaces | strategy 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 ≤ internalonly); 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 OPAinputper-request or cached?- Blind-index key rotation strategy (rotating
kinvalidates 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).
