Skip to main content

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.
CRITICAL: Tenant MUST NOT be trusted from client input. Always derive from server-side JWT.The brokerId (tenantId) is resolved exclusively from the verified server-side JWT. Any brokerId supplied in a request body, query parameter, or header is ignored entirely.

Tenancy Model

Each broker firm maps to exactly one tenant. The brokerId stored in the users table is the tenant anchor for the entire role hierarchy beneath it.
Key invariants:
  • Every employers row has a non-nullable brokerId column.
  • Every schemes row belongs to an employer, which belongs to a broker.
  • Every members row belongs to a scheme, which traces back to a broker.
  • All service queries filter by actor.tenantId before 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:
Step 2 — brokerId is set at user creation time 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:
Step 5 — Service layer enforcement Service methods receive 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:
Step 7 — Audit logging Every state-changing operation appends a record to 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 via ActorContext, 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 explicit brokerId filter:

SuperAdmin Bypass

The SuperAdmin role bypasses the tenant check to allow platform operations:
SuperAdmin tokens are issued only to platform engineering accounts. They are never provisioned for broker or employer users. All SuperAdmin actions are still recorded in audit_logs.

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:
Example:

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 documents table scoped by employerId → brokerId.
  • Blob URLs are only returned to requests that pass the requireTenant check 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:
  • brokers records with no associated BrokerAdmin user (orphaned broker)
  • users with brokerId referencing a non-existent broker (dangling FK)
  • employers with brokerId referencing a non-existent broker
The daily reconciliation job is not yet implemented. Until it is, manual cleanup via SuperAdmin tooling or direct database access is required for failed provisioning runs. See the Tenant Provisioning Runbook — Failure and Rollback Plan for the manual cleanup procedure.

ActorContext Type Reference

Role hierarchy for requireRole checks: