ADR-0017: Employees are modelled as Principals inside the tenant; cross-employee isolation via Postgres RLS¶
- Status: Accepted
- Date: 2026-07-31
- Deciders: El
- Related: ADR-0002 (engines vs products), ADR-0005 (rule precedence — superseded in part), ADR-0006 (control plane), ADR-0007 (deployment topology), ADR-0008 (residency), ADR-0011 (Console vs Client App), ADR-0014 (VPS provisioning, §§5-6 isolation and §9 secrets)
Context¶
Employee/HR management is tenant-spine content, built once as a Product over the shared engines per ADR-0002. This establishes the premise, but does not yet say how an individual employee is represented at runtime, nor how one employee's data is kept from another's inside the same tenant. Email triage forces the question: many staff at one client, each with their own mailbox, voice, and autonomy, all operating within a single tenant spine.
A tenant is therefore no longer a single trust domain. One staff member reading a colleague's inbox or drafts is a breach within the client — and it stings more than a cross-tenant leak because it is personal. Isolation between employees needs to be a property of the architecture, not of every developer's WHERE clause.
Decision¶
This ADR supersedes the rule-precedence chain in ADR-0005: the chain becomes system → vertical → tenant → role → personal → learned (six scopes).
An employee is modelled as a Principal inside the tenant. The hierarchy is three levels: Tenant → Role → Principal.
-
Scope by role, not by person. Rules attach to roles; principals inherit and may override. This makes onboarding a template instantiation, not a re-authoring job, and keeps "productised underneath, bespoke on top" true at the level of people. The rule-precedence chain gains two rungs:
system → vertical → tenant → role → personal → learned. -
Cross-tenant isolation stays physical; cross-employee isolation is Postgres Row-Level Security. Tenants are separated by the container boundary (per ADR-0014 §5–6 and ADR-0007) — nothing to enforce in query logic. Within a tenant spine, every operational row is stamped
principal_id, and RLS keyed to a session principal enforces the boundary in the database, so a query bug cannot leak employee A's data to employee B. The tenant Postgres schema is specified to carry the principal column (ADR-0014 §6); the schema itself is not yet implemented. This ADR turns that hook into enforced policy. Same identity discipline as the ConsoleALLOWED_EMAILSboundary: enforce at the edge, never trust the caller. -
The two feedback channels split along the same seam. Triage feedback ("should we have acted?") trains the shared role layer. Generation/voice feedback ("did we write it well?") is strictly per-principal and never pooled — the moment one person's drafts train another's voice, everyone sounds the same and the bespoke feel dies at the human level.
-
Model everyone; provision only who is paid for. The data model represents all staff, but per-employee M365 delegated OAuth (N token sets, N Graph subscriptions) is wired only for the mailboxes an engagement covers. Tokens are stored encrypted in the tenant's n8n database per ADR-0014 §9, not in Vault.
Two elements are baked in as metadata now, built later: approver-as-a-pointer (Draft.approver_principal_id; defaults to self-review, re-routable to a manager or rota without a schema change) and shared mailboxes (info@, accounts@) modelled as a role-principal (Principal.kind = shared) — company voice, rota approval, one code path.
Consequences¶
Onboarding an employee becomes a ~5-minute inherit-and-consent flow (create principal → assign role → delegated OAuth consent link → L1 default → voice seeded from role defaults). Offboarding is the mirror (revoke token, kill subscription, deactivate principal; data stays in the tenant spine or exports). Employee data inherits tenant isolation, exit and residency guarantees automatically because it is ordinary tenant-spine content.
The registry's tenant → connection-string resolution becomes security-critical: a wrong mapping is a cross-tenant leak, so the resolve-and-connect step is treated with the same care as the RLS layer. The brain holds no ambient authority; it is handed scope per invocation and reads nothing it was not scoped to — which is what makes a shared brain safe across isolated tenants and principals.
Alternatives considered¶
- Shared operational DB with a
tenant_id/principal_idcolumn — rejected: collapses the physical tenant boundary (already settled in ADR-0014 §6); isolation would rest entirely on query correctness. - Rules attached per-person — rejected: every new hire becomes a re-authoring job; loses the role-template economy.
- Per-employee ingress subdomains for isolation — deferred: purest isolation but multiplies provisioning per client. Single authenticated ingress that resolves subscription → tenant via the registry, plus RLS on the tenant DB, gives the isolation guarantee that matters without per-tenant public-endpoint sprawl. Per-tenant ingress remains a later hardening if a client's threat model demands it.
- Pooling voice examples across employees to bootstrap faster — rejected: destroys per-principal voice; cold-start is instead solved by seeding from role defaults and optional Sent-folder ingestion.
Acceptance criteria (2026-07-31): RLS is not "done" until all three hold¶
The cross-employee isolation guarantee rests entirely on implementation discipline. RLS work is complete ONLY when: 1. Deny by default — if no session principal is set on a connection, RLS returns zero rows (never all rows). A forgotten context must fail loud and empty, not leak silently. 2. Single chokepoint — the session principal is set in exactly one place: the sole function/middleware that issues tenant DB connections. No query author can obtain a connection without it. Enforce at the edge, never trust the caller. 3. Standing leak test — an automated test connects WITHOUT setting a principal and asserts zero rows returned. This test must exist and pass before RLS is considered shipped, and guards against regressions. These are a definition-of-done for the tenant RLS work, not optional hardening.