> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pensionsportal.ie/llms.txt
> Use this file to discover all available pages before exploring further.

# Security Architecture

> Security controls, GDPR data protection measures, and AppSec practices for PensionsPortal.ie

## Overview

PensionsPortal.ie handles sensitive pension data for Irish occupational pension schemes. The platform is subject to **IORP II** (S.I. 128/2021), **DORA**, and **GDPR** compliance requirements. This document describes the security controls applied at every layer of the stack.

***

## Authentication

### Auth.js v5 JWT Strategy

Authentication is handled by **Auth.js v5** using the **Credentials provider** with a JWT session strategy. Sessions are stateless — no server-side session store is required.

```typescript theme={null}
// auth.ts (simplified)
export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Credentials({
      async authorize(credentials) {
        const user = await db
          .select()
          .from(usersTable)
          .where(eq(usersTable.email, credentials.email))
          .limit(1);

        if (!user[0]) return null;

        const passwordValid = await bcryptjs.compare(
          credentials.password,
          user[0].passwordHash
        );

        if (!passwordValid) return null;
        return user[0];
      },
    }),
  ],
  session: { strategy: "jwt" },
});
```

### Password Hashing

All passwords are hashed with **bcryptjs** (bcrypt algorithm, work factor 12) before storage. Plaintext passwords are never persisted or logged.

```typescript theme={null}
// At user creation
const passwordHash = await bcryptjs.hash(plaintextPassword, 12);

// At authentication
const valid = await bcryptjs.compare(plaintextPassword, storedHash);
```

<Warning>
  **Development fallback accounts** (`admin123`, `broker123`) exist in `auth.ts` and are guarded by `if (process.env.NODE_ENV === 'production') return null`. These credentials MUST NOT be active in production. See [Development Hardcoded Credentials](#development-hardcoded-credentials) below.
</Warning>

### Password Reset Flow

PensionsPortal.ie supports a secure self-service password reset flow for all user roles.

**Flow**:

1. User clicks "Forgot password?" on the login page.
2. User submits their email address to `POST /api/auth/forgot-password`.
3. Server generates a cryptographically secure 32-byte random token.
4. Token is SHA-256 hashed before storage in `password_reset_tokens` table (plaintext never persisted).
5. A reset email is sent via Resend with a link containing the plaintext token.
6. User clicks the link and sets a new password via `POST /api/auth/reset-password`.
7. Server validates the token hash, checks expiry (60 minutes), and updates the password.
8. Token is marked as used (one-time use) to prevent replay.

**Security Controls**:

| Control                     | Implementation                                                                            |
| --------------------------- | ----------------------------------------------------------------------------------------- |
| User enumeration prevention | Always returns generic "check your email" response regardless of whether the email exists |
| Token security              | 32 bytes of `crypto.randomBytes`, SHA-256 hashed before storage                           |
| Token expiry                | 60 minutes from generation                                                                |
| One-time use                | Token marked as `usedAt` after successful password reset                                  |
| Existing token invalidation | All unused tokens for the user are invalidated when a new reset is requested              |
| Rate limiting               | Cloudflare WAF: 10 requests/minute on `/api/auth/*`                                       |
| Password hashing            | New password hashed with bcryptjs (work factor 12)                                        |
| No PII in logs              | Email addresses never logged; only operation status                                       |

```
POST /api/auth/forgot-password  → { email }          → 200 (always)
POST /api/auth/reset-password   → { token, password } → 200 or 400
```

**Code locations**:

* API routes: `src/app/api/auth/forgot-password/route.ts`, `src/app/api/auth/reset-password/route.ts`
* Token schema: `src/db/schema/password-resets.ts`
* Email template: `src/lib/email/templates/password-reset.ts`
* UI pages: `src/app/auth/forgot-password/page.tsx`, `src/app/auth/reset-password/page.tsx`

***

## Authorization

### Role-Based Access Control (RBAC)

Authorization is enforced at the **service layer** via `ActorContext`. Two primary guard functions are used:

| Function                               | Purpose                                    | Throws        |
| -------------------------------------- | ------------------------------------------ | ------------- |
| `requireRole(actor, minimumRole)`      | Asserts actor has sufficient role level    | 403 Forbidden |
| `requireTenant(actor, targetTenantId)` | Asserts actor belongs to the target tenant | 403 Forbidden |

### Role Hierarchy

```
SuperAdmin > BrokerAdmin > BrokerUser > EmployerAdmin > EmployerUser > Member
```

Roles are encoded as an ordered enum. `requireRole` checks that `roleLevel(actor.role) >= roleLevel(minimumRole)`.

### Service Layer Enforcement

Every service method that touches protected data enforces both role and tenant:

```typescript theme={null}
async function deleteScheme(
  schemeId: string,
  actor: ActorContext
): Promise<void> {
  requireRole(actor, "BrokerAdmin");           // minimum role
  requireTenant(actor, scheme.employer.brokerId); // tenant isolation
  // proceed with deletion...
}
```

See [Multi-Tenancy Architecture](/tenancy) for full details on tenant enforcement.

***

## Transport Security

| Control       | Implementation                                                            |
| ------------- | ------------------------------------------------------------------------- |
| Protocol      | HTTPS only (TLS 1.3)                                                      |
| Enforcement   | Vercel platform + Cloudflare proxy                                        |
| HSTS          | `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` |
| HTTP redirect | All HTTP traffic permanently redirected to HTTPS (301)                    |
| Certificate   | Managed by Vercel (Let's Encrypt / Cloudflare) — auto-renewed             |

***

## PPS Number Encryption (GDPR Priority 0)

Personal Public Service (PPS) numbers are **highly sensitive personal identifiers** under Irish GDPR. They receive the highest classification in the data protection model.

### Encryption Scheme

* **Algorithm**: AES-256-GCM (authenticated encryption — provides both confidentiality and integrity)
* **Key size**: 256-bit (32 bytes), stored as 64-character hex string in `PPS_ENCRYPTION_KEY`
* **Storage**: Encrypted ciphertext stored in `members.ppsNumberEncrypted` — plaintext never persisted
* **IV**: Randomly generated per encryption operation, prepended to ciphertext

```typescript theme={null}
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";

const ALGORITHM = "aes-256-gcm";
const KEY = Buffer.from(process.env.PPS_ENCRYPTION_KEY!, "hex"); // 32 bytes

export function encryptPps(plaintext: string): string {
  const iv = randomBytes(12); // 96-bit IV for GCM
  const cipher = createCipheriv(ALGORITHM, KEY, iv);
  const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
  const authTag = cipher.getAuthTag();
  // Store as: iv(12) + authTag(16) + ciphertext — all base64 encoded
  return Buffer.concat([iv, authTag, encrypted]).toString("base64");
}

export function decryptPps(ciphertext: string): string {
  const buf = Buffer.from(ciphertext, "base64");
  const iv = buf.subarray(0, 12);
  const authTag = buf.subarray(12, 28);
  const encrypted = buf.subarray(28);
  const decipher = createDecipheriv(ALGORITHM, KEY, iv);
  decipher.setAuthTag(authTag);
  return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
}
```

### Access Controls

* PPS numbers are **never decrypted for display** without an explicit broker action
* Every decryption event is written to `audit_logs` with actor identity
* PPS numbers are **never sent to AI providers** (Anthropic API receives only non-PII aggregate data)
* PPS numbers are **never logged** in application logs or error traces

### Key Generation

```bash theme={null}
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```

Store the output as `PPS_ENCRYPTION_KEY` in Vercel encrypted environment variables. Rotate on schedule (see [Environment Variable Security](#environment-variable-security)).

***

## Audit Logging (IORP II Requirement)

Audit logging is a **regulatory requirement** under IORP II (S.I. 128/2021) for pension scheme oversight. Every state-changing operation must be traceable.

### Audit Log Schema

```typescript theme={null}
// audit_logs table
{
  id:            string,   // CUID2
  actorId:       string,   // users.id of the actor
  actorType:     Role,     // role at time of action
  action:        string,   // e.g. "scheme.created", "member.pps_decrypted"
  entityType:    string,   // e.g. "scheme", "member", "writtenPolicy"
  entityId:      string,   // PK of the affected entity
  previousState: JsonB,    // state before the action (null for creates)
  newState:      JsonB,    // state after the action (null for deletes)
  ipAddress:     string,   // from x-forwarded-for header
  userAgent:     string,   // from user-agent header
  timestamp:     timestamp // server-assigned UTC timestamp
}
```

### Append-Only Guarantee

The `audit_logs` table has no application-layer DELETE or UPDATE operations. Database-level permissions restrict modification:

* No `DELETE` privilege granted to the application database role
* No `UPDATE` privilege granted to the application database role
* Reads are permitted for SuperAdmin and BrokerAdmin (own tenant only)

<Note>
  Audit logs are retained indefinitely per regulatory requirements. See [Data Retention](/data-model#data-retention).
</Note>

***

## API Security

### Authentication Gate

All `/api/*` routes (except `/api/auth/*` and `/api/health/*`) require an authenticated session. The Next.js middleware enforces this before any route handler runs:

```typescript theme={null}
// middleware.ts
export async function middleware(request: NextRequest) {
  const session = await auth();
  const isApiRoute = request.nextUrl.pathname.startsWith("/api/");
  const isAuthRoute = request.nextUrl.pathname.startsWith("/api/auth/");
  const isHealthRoute = request.nextUrl.pathname.startsWith("/api/health/");

  if (isApiRoute && !isAuthRoute && !isHealthRoute && !session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
}
```

### Input Validation

All `POST` and `PUT` request bodies are validated with **Zod** schemas before any service method is called. Invalid input returns `400 Bad Request` with a structured error:

```typescript theme={null}
const CreateSchemeSchema = z.object({
  employerId: z.string().cuid2(),
  name:       z.string().min(1).max(200),
  type:       z.enum(["DB", "DC", "PRSA", "RAC"]),
});

const parsed = CreateSchemeSchema.safeParse(await req.json());
if (!parsed.success) {
  return NextResponse.json(
    { error: "Validation failed", issues: parsed.error.issues },
    { status: 400 }
  );
}
```

### SQL Injection Prevention

All database queries use **Drizzle ORM** with parameterised queries. String interpolation into raw SQL is not used anywhere in the codebase. Drizzle's query builder compiles to parameterised prepared statements, eliminating SQL injection as an attack surface.

***

## AI Data Minimization

The Anthropic Claude API is used for compliance document drafting and regulatory Q\&A (RAG). Strict data minimization is enforced:

### What Is Sent to Anthropic

| Data type                | Sent to AI? | Notes                                                      |
| ------------------------ | ----------- | ---------------------------------------------------------- |
| Scheme name              | Yes         | Non-PII operational data                                   |
| Scheme type (DB/DC)      | Yes         | Regulatory classification                                  |
| KFH count, policy count  | Yes         | Aggregate counts only                                      |
| Regulatory corpus chunks | Yes         | Public IORP II documents only                              |
| Member names             | **Never**   | PII — excluded                                             |
| Date of birth            | **Never**   | PII — excluded                                             |
| PPS numbers              | **Never**   | PII — excluded (encrypted at rest, never decrypted for AI) |
| Email addresses          | **Never**   | PII — excluded                                             |
| Contribution amounts     | **Never**   | PII — excluded                                             |

### RAG Corpus

The pgvector RAG corpus contains only **public regulatory text** — extracts from S.I. 128/2021 (IORP II transposition), Pensions Authority guidance, and Revenue Commissioner circulars. No member or scheme-specific data is embedded in the vector store.

***

## Dependency Security

| Severity | Count | Location                             | Impact                                               |
| -------- | ----- | ------------------------------------ | ---------------------------------------------------- |
| Moderate | 4     | drizzle-kit / esbuild ecosystem      | Dev tooling only — not present in production runtime |
| High     | 1     | minimatch (indirect, dev dependency) | Dev tooling only — not present in production runtime |
| Critical | 0     | —                                    | —                                                    |

**Production runtime** (the deployed Next.js application) has no known vulnerable packages. Vulnerabilities exist only in dev-time build tools.

`npm audit` is run on every CI build. PRs that introduce new production vulnerabilities are blocked by the CI pipeline.

***

## Environment Variable Security

All secrets are stored in **Vercel encrypted environment variables** — never in source code or committed to git.

```
# .gitignore covers all env files
.env
.env.*
.env.local
.env.production
```

### Secrets Inventory

| Variable                   | Purpose                                                   | Rotation Schedule                   |
| -------------------------- | --------------------------------------------------------- | ----------------------------------- |
| `AUTH_SECRET`              | Auth.js JWT signing key                                   | Annually or on suspected compromise |
| `PPS_ENCRYPTION_KEY`       | AES-256-GCM key for PPS numbers                           | Annually or on suspected compromise |
| `AI_KEY_ENCRYPTION_SECRET` | AES-256-GCM key for BYOK tenant AI provider keys          | Annually or on suspected compromise |
| `DATABASE_URL`             | Neon PostgreSQL connection string                         | On credential rotation              |
| `DATABASE_URL_UNPOOLED`    | Neon unpooled connection (migrations)                     | On credential rotation              |
| `ANTHROPIC_API_KEY`        | Anthropic Claude API                                      | On rotation by Anthropic            |
| `RESEND_API_KEY`           | Resend transactional email                                | On rotation by Resend               |
| `ADMIN_EMAIL`              | System notifications recipient                            | On team change                      |
| `EMAIL_FROM`               | Sender email address                                      | On domain change                    |
| `CRON_SECRET`              | Bearer token for scheduled cron endpoints (`/api/cron/*`) | Annually or on suspected compromise |
| `RAG_INGEST_SECRET`        | Bearer token protecting `POST /api/rag/ingest`            | Annually or on suspected compromise |
| `OPENROUTER_API_KEY`       | OpenRouter alternative AI provider (optional)             | On rotation by OpenRouter           |
| `BLOB_READ_WRITE_TOKEN`    | Vercel Blob storage                                       | On Vercel credential rotation       |

<Warning>
  Rotating `PPS_ENCRYPTION_KEY` requires a migration to re-encrypt all existing PPS values. See the [PPS Encryption Key Rotation Procedure](/runbooks/incident-response#5-pps-encryption-key-rotation-procedure) in the Incident Response runbook. Do not rotate without following the procedure.
</Warning>

***

## Development Hardcoded Credentials

`auth.ts` contains fallback development accounts for local testing:

```typescript theme={null}
// auth.ts — development fallback only
if (process.env.NODE_ENV === "production") return null; // guard

const DEV_USERS = [
  { email: "admin@test.ie", password: "admin123", role: "SuperAdmin" },
  { email: "broker@test.ie", password: "broker123", role: "BrokerAdmin" },
];
```

These accounts:

* Are **completely disabled** in production (guarded by `NODE_ENV` check)
* Are documented as dev-only in code comments
* Use trivially guessable passwords intentionally (they never reach production)
* Are excluded from production builds via the `NODE_ENV` guard

<Warning>
  These credentials MUST NOT appear in production. Verify `NODE_ENV=production` is set in all production and staging deployment environments. The CI pipeline asserts this as part of the deployment checklist.
</Warning>

***

## Cloudflare WAF

All production traffic passes through **Cloudflare** before reaching Vercel:

| Control            | Configuration                                                                   |
| ------------------ | ------------------------------------------------------------------------------- |
| OWASP Core Ruleset | Enabled — blocks SQLi, XSS, path traversal                                      |
| Rate limiting      | 100 requests / minute per IP on `/api/*`; 10 requests / minute on `/api/auth/*` |
| Bot protection     | Cloudflare Bot Management — blocks known scrapers and credential stuffing tools |
| DDoS mitigation    | Cloudflare Anycast network — automatic L3/L4 DDoS absorption                    |
| Geo-blocking       | Ireland and EU allowlisted; high-risk regions challenge-mode                    |

See `deployment/cloudflare.mdx` for full WAF rule configuration.

***

## Security Headers

The following security headers are set on all responses via `next.config.js`:

| Header                      | Value                                                        | Purpose                             |
| --------------------------- | ------------------------------------------------------------ | ----------------------------------- |
| `Content-Security-Policy`   | `default-src 'self'; script-src 'self' 'nonce-{nonce}'; ...` | Prevents XSS and resource injection |
| `X-Frame-Options`           | `DENY`                                                       | Prevents clickjacking in iframes    |
| `X-Content-Type-Options`    | `nosniff`                                                    | Prevents MIME-type sniffing         |
| `Referrer-Policy`           | `strict-origin-when-cross-origin`                            | Limits referrer information leakage |
| `Permissions-Policy`        | `camera=(), microphone=(), geolocation=()`                   | Disables unused browser APIs        |
| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload`               | Enforces HTTPS for 2 years          |

```javascript theme={null}
// next.config.js (headers excerpt)
{
  key: "X-Frame-Options",
  value: "DENY",
},
{
  key: "X-Content-Type-Options",
  value: "nosniff",
},
{
  key: "Referrer-Policy",
  value: "strict-origin-when-cross-origin",
},
{
  key: "Permissions-Policy",
  value: "camera=(), microphone=(), geolocation=()",
},
```

***

## Compliance Cross-Reference

| Requirement           | Control                                           | Standard                              |
| --------------------- | ------------------------------------------------- | ------------------------------------- |
| PPS number protection | AES-256-GCM encryption + access logging           | GDPR Art. 9, Data Protection Act 2018 |
| Audit trail           | Append-only `audit_logs` table                    | IORP II S.I. 128/2021, Art. 22        |
| Access control        | RBAC via ActorContext + requireRole/requireTenant | IORP II, DORA Art. 9                  |
| Data minimization     | AI input filtering (no PII to Anthropic)          | GDPR Art. 5(1)(c)                     |
| Incident logging      | Audit logs with IP + userAgent                    | DORA Art. 17                          |
| Secure transmission   | TLS 1.3 + HSTS                                    | GDPR Art. 32, DORA Art. 9             |
