Skip to content

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.

Three strategies were considered:

StrategyVerdict
Database-per-tenantRejected. Migrations across 100-200 tenants are operationally expensive. Cross-tenant analytics require a separate pipeline.
Schema-per-tenantRejected. Same migration cost. Connection-pool fragmentation degrades throughput.
Shared schema with row-level securityChosen. 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.

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.

// pseudocode for the request middleware
async 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.

export type Role =
| "principal-admin"
| "principal-compliance-officer"
| "ar-user"
| "fca-auditor";
RoleWhoNotes
principal-adminSenior Manager Function holder, typically SMF16 / SMF17 / SMF3Buyer side. Settings, user management, FCA notifications, annual-review sign-off. Step-up auth required for terminal actions.
principal-compliance-officerDay-to-day compliance staffBuyer side. Triages breaches, runs file reviews, drafts annual reviews. Cannot sign off or notify FCA.
ar-userThe supervised individual or designated person at an AR firmSupervised side. Scoped to a single AR via users.ar_id. Files own breaches, submits own MI returns, challenges own file-review findings.
fca-auditorFCA supervisor or skilled-person reviewer (s.166)Guest. Read-only. Time-bound by users.expiresAt. Gated by tenant feature flag (off by default).

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.

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.

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 (re-enter password and TOTP) is required for:

  • POST /api/breaches/:id/notify-fca (records SUP 15 notification)
  • POST /api/annual-reviews/:id/sign-off
  • PATCH /api/ars/:id when transitioning to terminated
  • 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.

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.

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.

ResourceActionprincipal-adminprincipal-compliance-officerar-userfca-auditor
Tenant settingsviewRR-R
Tenant settingseditW---
Risk-scoring weightseditT---
Usersinvite/manageW---
AR registerlistRR-R
AR registercreateW---
AR detailviewRRR (own)R
AR detaileditWW (limited)--
ARterminateT---
Permissions on ARgrant/revokeW---
Breach reportslistRRR (own)R
Breach reportscreateWWW (own)-
Breach reportsupdateWW--
Breach reportsnotify FCAT---
File reviewslistRRR (own)R
File reviewscreate/score-W--
File reviewscomplete-T--
File reviewschallenge--W (own)-
MI returnslistRRR (own)R
MI returnssubmit--W (own)-
MI returnsmark queried/accepted-W--
Annual reviewsviewRRR (own)R
Annual reviewsedit draft-W--
Annual reviewssign offT---
Audit logviewRR-R
Audit logexportWW-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.

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.

EntityRetentionDeletionLegal basis
TenantIndefinite while active; 7 years after off-boardingSoft on off-boarding, hard after windowContract, SYSC 9
User7 years after last regulated activitySoft on off-boarding; PII tombstoned at off-boardingContract, UK GDPR Art 5(1)(e)
AppointedRep7 years after terminationSoft on terminate, hard after windowSYSC 9, SUP 12
BreachReport7 years after closedSoft, then hardSYSC 9, SUP 15
FileReview7 years after completeSoft, then hardSYSC 9, MCOB / ICOBS / CONC retention
MIReturn7 years after submissionSoft, then hardSYSC 9
AnnualReview7 years after sign-offSoft, then hardSYSC 9, PS22/11
ConductEvent7 years from occurredAtSoft, then hardSYSC 9
AuditEvent10 years from atHard delete only by year shardSYSC 9 with margin, hash chain integrity
AttachmentSame as parentObject-store deletion after parent hard-deleteSame as parent
Session30 days after expiresAtHardOperational

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.

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.