Persona and tenant model
Each principal firm is one tenant. The multi-tenancy strategy is shared database, shared schema, with row-level security on every table. Personas are roles within a tenant; a single user holds exactly one role.
Tenancy strategy
Section titled “Tenancy strategy”Three strategies were considered:
| Strategy | Verdict |
|---|---|
| Database-per-tenant | Rejected. Migrations across 100-200 tenants are operationally expensive. Cross-tenant analytics require a separate pipeline. |
| Schema-per-tenant | Rejected. Same migration cost. Connection-pool fragmentation degrades throughput. |
| Shared schema with row-level security | Chosen. One migration, one query plan cache, native Postgres enforcement. |
Every tenant-scoped table carries a tenant_id uuid not null column with a foreign key to tenants(id), and a Postgres RLS policy that filters on tenant_id = current_setting('app.tenant_id')::uuid.
RLS policy template
Section titled “RLS policy template”CREATE TABLE appointed_reps ( id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenants(id), -- ...other columns created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now());
ALTER TABLE appointed_reps ENABLE ROW LEVEL SECURITY;ALTER TABLE appointed_reps FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON appointed_reps USING (tenant_id = current_setting('app.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);FORCE ROW LEVEL SECURITY makes the policy apply to the table owner too, so the application’s connection role cannot bypass it.
Session middleware sets the GUC
Section titled “Session middleware sets the GUC”// pseudocode for the request middlewareasync function withTenantContext<T>( session: Session, fn: (db: Database) => Promise<T>,): Promise<T> { return db.transaction(async (tx) => { await tx.execute( `SELECT set_config('app.tenant_id', $1, true)`, [session.tenantId], ); return fn(tx); });}set_config(..., true) scopes the GUC to the transaction, so a connection returned to the pool carries no tenant context. A query that runs without the GUC set returns no rows from a tenant-scoped table; this is the safe failure mode.
A migration check rejects any deployment in which a tenant-scoped table lacks ENABLE ROW LEVEL SECURITY, FORCE ROW LEVEL SECURITY, and a tenant_isolation policy.
Personas (roles within a tenant)
Section titled “Personas (roles within a tenant)”export type Role = | "principal-admin" | "principal-compliance-officer" | "ar-user" | "fca-auditor";| Role | Who | Notes |
|---|---|---|
principal-admin | Senior Manager Function holder, typically SMF16 / SMF17 / SMF3 | Buyer side. Settings, user management, FCA notifications, annual-review sign-off. Step-up auth required for terminal actions. |
principal-compliance-officer | Day-to-day compliance staff | Buyer side. Triages breaches, runs file reviews, drafts annual reviews. Cannot sign off or notify FCA. |
ar-user | The supervised individual or designated person at an AR firm | Supervised side. Scoped to a single AR via users.ar_id. Files own breaches, submits own MI returns, challenges own file-review findings. |
fca-auditor | FCA supervisor or skilled-person reviewer (s.166) | Guest. Read-only. Time-bound by users.expiresAt. Gated by tenant feature flag (off by default). |
One role per user
Section titled “One role per user”A single User row carries one role. A natural person who legitimately holds two roles (compliance officer for a principal who also runs an AR firm) maintains two accounts and signs in to each. The demo’s persona switcher is a chrome affordance that has no production analogue.
arId scoping
Section titled “arId scoping”ar-user rows carry arId. The session row also carries arId. Every API handler that returns AR-scoped data checks session.role === "ar-user" ? rec.arId === session.arId : true after the RLS-filtered query has run. RLS handles the tenant boundary; the handler enforces the AR boundary inside it.
Session and authentication
Section titled “Session and authentication”Email and password with TOTP. TOTP enrolment is required for principal-admin and principal-compliance-officer. ar-user is password-only by default; a per-tenant flag flips this to require TOTP.
interface Session { id: Ulid; // Opaque, returned in the cookie userId: Ulid; tenantId: Ulid; role: Role; arId: Ulid | null; // Null unless role === "ar-user" createdAt: IsoTimestamp; expiresAt: IsoTimestamp; // 12-hour sliding, 7-day absolute cap lastSeenAt: IsoTimestamp; ip: string | null; userAgent: string | null; csrfToken: string;}Cookie attributes: httpOnly, Secure, SameSite=Lax, Path=/, no Domain (host-only). Cookie value is the opaque session id; the row holds the rest.
CSRF protection is double-submit. GET /api/me returns csrfToken in the JSON body. Write routes require an X-CSRF-Token header that matches the session’s csrfToken.
Step-up authentication
Section titled “Step-up authentication”Step-up (re-enter password and TOTP) is required for:
POST /api/breaches/:id/notify-fca(records SUP 15 notification)POST /api/annual-reviews/:id/sign-offPATCH /api/ars/:idwhen transitioning toterminated- Risk-weights save in tenant settings
Step-up issues a short-lived (10 minute) bearer the client sends as X-Step-Up-Token on the elevated request.
Why session-based, not magic links
Section titled “Why session-based, not magic links”Lending Agent Presenter (the sibling product) uses magic-link tokens because each presenter session is a one-shot conversation about a single quote with an unauthenticated retailer-customer pair. Oversight is a stateful workspace used daily by named regulated individuals over months. PS22/11 and SYSC 9 require:
- Identity continuity across sessions (the same audit record references the same user id on every action).
- Named accountability (no shared logins, no anonymous access).
- MFA where appropriate (FCA’s expectation for senior management functions).
- Session revocation (an admin must be able to invalidate a session within seconds of off-boarding a user).
- Tamper-evident records linking each action to a session id, which the audit chain references.
Magic links cannot carry MFA, do not survive device handoff cleanly, and do not produce the per-session record the audit chain references.
RBAC matrix
Section titled “RBAC matrix”R read, W write, T terminal action requiring step-up auth, - no access. AR-user reads scoped to own AR. FCA-auditor read-only at principal firm’s discretion.
| Resource | Action | principal-admin | principal-compliance-officer | ar-user | fca-auditor |
|---|---|---|---|---|---|
| Tenant settings | view | R | R | - | R |
| Tenant settings | edit | W | - | - | - |
| Risk-scoring weights | edit | T | - | - | - |
| Users | invite/manage | W | - | - | - |
| AR register | list | R | R | - | R |
| AR register | create | W | - | - | - |
| AR detail | view | R | R | R (own) | R |
| AR detail | edit | W | W (limited) | - | - |
| AR | terminate | T | - | - | - |
| Permissions on AR | grant/revoke | W | - | - | - |
| Breach reports | list | R | R | R (own) | R |
| Breach reports | create | W | W | W (own) | - |
| Breach reports | update | W | W | - | - |
| Breach reports | notify FCA | T | - | - | - |
| File reviews | list | R | R | R (own) | R |
| File reviews | create/score | - | W | - | - |
| File reviews | complete | - | T | - | - |
| File reviews | challenge | - | - | W (own) | - |
| MI returns | list | R | R | R (own) | R |
| MI returns | submit | - | - | W (own) | - |
| MI returns | mark queried/accepted | - | W | - | - |
| Annual reviews | view | R | R | R (own) | R |
| Annual reviews | edit draft | - | W | - | - |
| Annual reviews | sign off | T | - | - | - |
| Audit log | view | R | R | - | R |
| Audit log | export | W | W | - | W |
Server-side enforcement runs in every API handler. The persona-gated UI in the demo is a chrome affordance only; it is not a security boundary.
Retention table
Section titled “Retention table”Driven by SYSC 9 floor with sector-specific overlays in MCOB, ICOBS, CONC, DISP. The 7-year default sits above the longest commonly applicable floor; the audit chain extends to 10 years for tamper-evidence.
| Entity | Retention | Deletion | Legal basis |
|---|---|---|---|
Tenant | Indefinite while active; 7 years after off-boarding | Soft on off-boarding, hard after window | Contract, SYSC 9 |
User | 7 years after last regulated activity | Soft on off-boarding; PII tombstoned at off-boarding | Contract, UK GDPR Art 5(1)(e) |
AppointedRep | 7 years after termination | Soft on terminate, hard after window | SYSC 9, SUP 12 |
BreachReport | 7 years after closed | Soft, then hard | SYSC 9, SUP 15 |
FileReview | 7 years after complete | Soft, then hard | SYSC 9, MCOB / ICOBS / CONC retention |
MIReturn | 7 years after submission | Soft, then hard | SYSC 9 |
AnnualReview | 7 years after sign-off | Soft, then hard | SYSC 9, PS22/11 |
ConductEvent | 7 years from occurredAt | Soft, then hard | SYSC 9 |
AuditEvent | 10 years from at | Hard delete only by year shard | SYSC 9 with margin, hash chain integrity |
Attachment | Same as parent | Object-store deletion after parent hard-delete | Same as parent |
Session | 30 days after expiresAt | Hard | Operational |
GDPR right-to-erasure
Section titled “GDPR right-to-erasure”Erasure requests against AR-user PII are honoured by tombstoning the User row (replacing email, displayName with placeholder values, blanking lastLoginAt). The supervision records the user contributed to are retained under UK GDPR Article 6(1)(c) (legal obligation) and Article 17(3)(b) (legal obligation override). The audit chain references the tombstoned user id; the link is preserved, the PII is gone.
Retention sweeper
Section titled “Retention sweeper”A daily cron job moves entities past their retention window from soft-deleted to hard-deleted, removes attachments from the object store, and writes a retention.sweep audit event per affected entity. The sweeper runs in transactions of bounded size to avoid long locks. See Production hardening.