Tenant Onboarding - Test & Review Flow
Status: draft · Owner: Chakshu · Informs: Phase 3 authz · Concern: ABAC (access), not at-rest Decides: how a new tenant's access configuration is validated and signed off before it can serve a single production request. Tutorial-style: follow it top to bottom. Use the ∑ terse / ¶ plain toggle for each step.
A tenant config is data, not code - it plugs into the one shared Rego policy and the RLS floor. So onboarding a tenant is a configuration review, gated by the same invariants the POCs proved hold at scale.
What "a tenant configuration" is
The reviewable unit. Everything that decides who in this tenant sees which rows and fields:
| Part | Example | Lives in |
|---|---|---|
| Identity | org_locator / scheme / tenant_id | Keycloak (subject claims) + the row tenant_id |
| Roles → row scope | member: claim.member_id == actor.id, employer-admin: claim.tenant_id == actor.tenant_id | data.claim_access.role_rules (OPA data) |
| RLS predicate | tenant_id = current_setting('app.tenant') | Postgres policy (the floor) |
Field policy Φ | dx: deny, dob: year, name: via_vault | OPA Rego / data |
| Field classes | which columns are tokenised at rest | vault config (separate at-rest concern) |
The config is authored as code in a PR and applied as OPA data + an RLS migration. Nothing here is typed into a console by hand in prod.
The flow
Two automated gates (B, D), one human gate (E), one operational gate (F). Failure anywhere returns to the author - config never advances on a red.
Step by step
1 · Author (PR) config-as-code
Tenant config committed as data: role_rules entries, the RLS predicate, Φ overrides, field classes. Reviewable diff; no prod console edits.
Plain-English explanation
The person onboarding the tenant writes the config into version control - the role-to-scope rules, the row-level-security predicate, any field-policy overrides, and which fields are tokenised. It arrives as a pull request, so the change is a reviewable diff with history, not an ad-hoc change made directly against the running system.
2 · Static validation (CI) - gate B
What CI checks, all mechanical:
| Check | Pass condition | Backed by |
|---|---|---|
| Schema | config parses; required keys present | JSON schema |
| Allowlist | every rule's column ∈ table allowlist, op ∈ {eq, in, all}, actor_field set | roles.Validate (same code the harness shows as VALID/INVALID) |
| RLS predicate | compiles against the real schema; references only tenant_id + a GUC | migration dry-run |
| Φ targets | referenced fields exist; no field both deny and via_vault | policy lint |
Gate B: all green, else back to author. *Cost: ms
- no data needed.*
3 · Automated isolation & no-drift (sandbox) - gate D
CI seeds a synthetic copy of the new tenant and at least one sibling tenant, then runs the same checks the POC harness runs
now over the tenant's own roles:
Isolation (I-cross): new tenant's every role returns 0 sibling-tenant rows; sibling returns 0 of the new tenant's. RLS enforced even with the app filter removed.
No-drift (I1):
list(role) = { x : canView(role, x) }exactly, over ≥500 synthetic actors/role.Φ: restricted fields never returned in clear;
via_vaultfields stay tokens unlessΦsays otherwise;dob:yearnever leaks day/month.Fail-closed: an unknown role / malformed rule ⇒ deny / 0 rows, never allow-all.
Sargable: generated SQL is index-based (no Seq Scan on the tenant's hot tables).
Plain-English explanation
The system stands up throwaway data for this tenant and a neighbour, then asks, for every role the tenant defines: does a list query return exactly the rows a one-by-one permission check would allow (no drift)? Does this tenant ever see a neighbour's rows, even if the application code's own filter is deleted (the database must still refuse)? Are fields that should be hidden actually hidden, and are the ones that should only appear via the vault still encrypted? If the config is broken or incomplete, does the system deny rather than fall open? And do the queries still use indexes so they stay fast at scale? Every one of these must be green.
| Check | Proves | POC / invariant |
|---|---|---|
| Isolation | no cross-tenant leak even on an app bug | #2 RLS - safe pattern 0 leaks / 50k |
| No-drift | list filter ≡ point check (one policy) | #1 OPA partial-eval - I1, 500 actors/role |
| Φ field-level | right fields hidden/tokenised | ABAC design |
| Fail-closed | misconfig denies, never opens | translate + RLS NULLIF |
| Sargable | stays fast at 1M+ rows | #1 / #2 EXPLAIN plans |
Gate D: all green, else back to author. The report is attached to the PR.
4 · Human review · 4-eyes - gate E approval recorded
A reviewer who is not the author reads the diff + the green report, then clicks through the tenant's roles in the harness (roles-poc.dev.hiolly.com) - pick an actor, list rows, point-check an in-set and an out-of-set row, confirm the no-drift badge. Sign-off is audited.
Plain-English explanation
Automated green is necessary but not sufficient - someone independent confirms the intent is right (e.g. that "employer-admin" really should see the whole tenant, that no role was scoped too broadly). The reviewer opens the interactive harness, picks a real actor for each role, lists what they can see, spot-checks a row that should be allowed and one that should not, and watches the no-drift verdict. Their approval is recorded with who/when, so the decision is attributable later.
Reviewer checklist:
| ✓ | Item |
|---|---|
| ☐ | Each role's scope matches the intent in the onboarding ticket (no role broader than needed) |
| ☐ | employer-admin (or tenant-wide roles) are deliberate, not accidental |
| ☐ | Harness: in-set row → ALLOW, out-of-set → DENY, no-drift ✓ for every role |
| ☐ | Sensitive fields (nhs, dx) show deny; dob shows year only |
| ☐ | EXPLAIN shows index scan (no Seq Scan) for the tenant's tables |
| ☐ | Reviewer ≠ author; sign-off captured in the audit log |
Gate E: approved (audited), else changes requested.
5 · Staged rollout - gate F
Apply to a staging tenant first: push the OPA data + run the RLS migration, run a smoke suite, optionally shadow real-shaped traffic (canary). Watch the composed-path latency budget (#4: p99 within +50 ms).
Gate F: staging green, else back to author. Rollback path stays open.
6 · Promote to prod ✓ live
Versioned, reversible apply: OPA data push (atomic) + RLS migration, with an audit entry. The previous config version is retained so promotion can be reverted in one step (the dashed rollback edge).
Reviewer's-eye sequence
Hard gates (nothing promotes past a red)
- Any cross-tenant row in the isolation test ⇒ blocked. Non-negotiable.
- Any drift (
list ≠ canViewset) for any role ⇒ blocked. - Fail-open on a malformed/empty config ⇒ blocked (must deny).
- No human approval, or author == reviewer ⇒ blocked.
- A Seq Scan on a hot table is a warning, reviewed case-by-case (not an auto-block) - see #1/#2 plans.
This is the access-config review flow. The orthogonal question - how the PII those rows contain is protected on disk - is data protection at rest, reviewed separately.
