Overview
PensionsPortal.ie uses a broker-centric tenancy model. Every piece of data in the system — employers, schemes, members, documents, audit logs — is anchored to a single broker firm. A broker firm is the unit of tenancy.Tenancy Model
Each broker firm maps to exactly one tenant. ThebrokerId stored in the users table is the tenant anchor for the entire role hierarchy beneath it.
Key invariants:
- Every
employersrow has a non-nullablebrokerIdcolumn. - Every
schemesrow belongs to anemployer, which belongs to abroker. - Every
membersrow belongs to ascheme, which traces back to abroker. - All service queries filter by
actor.tenantIdbefore touching any data.
Role Hierarchy
Tenant Resolution Flow
Tenant identity is established once — at authentication — and is carried forward in an opaque, server-signed JWT for the lifetime of the session. Step 1 — User authenticates The user submits credentials via the Auth.js Credentials provider (POST /api/auth/callback/credentials). On success, Auth.js mints a signed JWT via the jwt() callback in auth.ts. The token payload includes:
brokerId is written into the users record at provisioning time and stored permanently. It is never resolved from client request parameters and never changes after provisioning.
Step 3 — Middleware verification
Next.js middleware (middleware.ts) intercepts every /api/* request (excluding /api/auth/* and /api/health/*). It calls auth() to verify the JWT signature and extract the session payload. Requests without a valid session receive 401 { error: "Unauthorized" } immediately.
Step 4 — ActorContext construction
Inside each API route handler, the verified session is used to build an ActorContext:
actor as a mandatory second argument. Before any data access, the service calls requireTenant(actor, targetTenantId), which throws a TenantMismatchError (surfaced as 403) if actor.tenantId !== targetTenantId, unless actor.role === 'SuperAdmin'.
Step 6 — Scoped database query
All queries include an explicit tenant scope:
audit_logs containing actorId and tenantId, providing a full audit trail for IORP II regulatory examination.
Sequence Diagram
Database Isolation Strategy
Approach: Application-Layer Isolation
PensionsPortal.ie enforces tenant isolation at the application layer viaActorContext, rather than using PostgreSQL Row-Level Security (RLS).
Justification:
Neon’s serverless PostgreSQL uses connection pooling (PgBouncer in transaction mode). Per-tenant RLS policies that rely on SET LOCAL session variables are incompatible with PgBouncer transaction-mode pooling, because session variables do not persist across pooled connections. Application-layer enforcement via ActorContext provides equivalent isolation with:
- Full observability — every check is logged with actor identity
- Testability — service layer is unit-testable without a live DB
- Portability — no DB-vendor-specific policy DSL
An RLS migration path (using
SET app.current_tenant_id + policies) has been considered for future implementation if the connection pooling architecture changes. If Neon adds support for session-level variables in transaction-mode pooling, RLS policies could supplement the existing application-layer enforcement. This is not currently planned.Query Scoping Pattern
Every Drizzle ORM query that touches tenant-scoped data includes an explicitbrokerId filter:
SuperAdmin Bypass
TheSuperAdmin role bypasses the tenant check to allow platform operations:
Storage Isolation
Vercel Blob Path Convention
All documents (scheme PDFs, written policies, trust deeds) are stored in Vercel Blob with a tenant-scoped path prefix:Access Controls
- All blob URLs are signed with expiry — knowing the path does not grant access.
- Cross-tenant URL guessing is not possible (signed URLs, not publicly accessible paths).
- Document metadata is stored in the
documentstable scoped byemployerId → brokerId. - Blob URLs are only returned to requests that pass the
requireTenantcheck for the owning broker.
Cross-Tenant Access Prevention
Multiple defence layers prevent any tenant from accessing another tenant’s data:
All service methods accept
ActorContext as a mandatory second argument — it is never optional:
Provisioning Lifecycle
The email invitation system for BrokerUser onboarding is a roadmap item. BrokerUser accounts are currently created directly by SuperAdmin via the Tenant Provisioning Runbook.
Failure and Rollback Plan
If tenant provisioning fails mid-way (e.g., broker record created but BrokerAdmin user creation fails), orphaned records are identified and cleaned by a daily reconciliation job. The reconciliation job checks for:brokersrecords with no associatedBrokerAdminuser (orphaned broker)userswithbrokerIdreferencing a non-existent broker (dangling FK)employerswithbrokerIdreferencing a non-existent broker
ActorContext Type Reference
requireRole checks: