feat(portal-bff): wire ADR-0013 audit pipeline to the auth lifecycle (#120)
## Summary Wires the audit pipeline (ADR-0013) to the auth lifecycle. The foundation was already in place (Prisma `AuditEvent` model, Postgres roles + grants, `AuditWriter.recordEvent` with `SET LOCAL ROLE audit_writer`); this PR layers a typed event surface and emits the first four events on real code paths. ### What lands - **Typed methods on `AuditWriter`**: `signIn`, `signInFailed`, `signOut`, `sessionExpired`. Callers pass the raw Entra `oid`; hashing happens inside the writer so the salt never leaves the audit module. ADR-0013 explicitly defers adding these typed methods "as the matching feature ships" — auth has shipped, so we add the four events tied to code paths that exist today. - **`HashUserIdService`** — reads `LOG_USER_ID_SALT` once at injection, exposes `hash(userId)` → 16-hex-char digest used by both `audit_events.actor_id_hash` (ADR-0013) and the future Pino `user_id_hash` (ADR-0012). Same salt + same input ⇒ same output ⇒ join key between the two streams. - **`LOG_USER_ID_SALT` env var** promoted from the "future vars" block in `.env.example` to the active section, with the same boot-time validator pattern as `SESSION_SECRET` / `SESSION_ENCRYPTION_KEY`: mandatory, base64url, ≥ 32 bytes decoded, placeholder rejected. Wired in `main.ts`. - **`AuditModule` is now `@Global()`** and also provides `HashUserIdService`. The previous in-line comment said "imported globally by AppModule" but the decorator was missing — without it, AuthController and the absolute-timeout middleware couldn't inject `AuditWriter` without re-importing AuditModule. - **Emission points**: - `/auth/callback` happy path → `auth.sign_in` after `session.save()` (blocking per ADR-0013 §"Blocking writes": a failed audit fails the sign-in). - `/auth/callback` failure paths → `auth.sign_in.failed` with a discriminator `failureKind` (`entra-error`, `missing-code-or-state`, `no-pre-auth-cookie`, or any of the `AuthCodeFlowError` kinds — `state-mismatch`, `flow-expired`, `token-exchange-failed`). - `/auth/logout` (authenticated only) → `auth.sign_out` before `session.destroy()` — once destroy runs we lose the actor id. - Absolute-timeout middleware → `auth.session.expired` with `reason: 'absolute'` and `ageMs` for forensic granularity. ### Out of scope (next PRs) - The other four v1 events from ADR-0013's catalogue (`auth.session.revoked`, `auth.token.validation.failed`, `auth.mfa.assertion.failed`, `authz.deny`) — no triggering code path exists today. They land with the admin "logout everywhere" route, downstream API access (ADR-0014), and the eventual `@RequireMfa()` / `@RequireAdmin` guards. - Idle-timeout expiry is intentionally silent — Redis lets the key disappear with no BFF observation point. Per ADR-0010. - Separate `AUDIT_DATABASE_URL` connection pool with `audit_writer`-only credentials — ADR-0013 marks it as the production hardening step, deferred behind `SET LOCAL ROLE` in v1. - Retention purge job + startup self-test probe — deferred to the on-prem infrastructure ADR per ADR-0013. ### Notable choices - **No CLS-populating middleware.** ADR-0013 anticipates an interceptor that puts `actorIdHash` on the request CLS so `AuditWriter.recordEvent` can pick it up automatically. For the four call sites in this PR, every emission path already has the user object in hand, so we pass `actorIdHash` explicitly via the typed methods and skip the middleware. It can land later when more routes need it. - **Blocking on the happy path = strict ADR posture.** `audit.signIn` is awaited before the 302; a Postgres outage makes the sign-in fail (5xx) rather than silently producing an un-audited session. That's "no audit ⇒ no action" applied to authentication itself. Matches ADR-0013 §"Blocking writes" verbatim. - **`signInFailed` skips the actor hash by default.** Most failure paths reject before any claim is parsed (state mismatch, expired flow). The interface accepts an optional `actor` for the rare identity-after-rejection case (future MFA assertion failure, etc.). ### Test plan - [x] `pnpm nx test portal-bff` (clean env) → **142/142 pass** (was 123; +19 new specs across `check-log-user-id-salt`, `hash-user-id.service`, `audit.service` typed-methods, `auth.controller`, `absolute-timeout.middleware`). - [x] `pnpm nx lint portal-bff` → clean. - [x] `pnpm nx build portal-bff` → clean. - [x] **CI clean-env repro** (lesson from #115/#116/#117): every env var unset → tests still 142/142. The two module specs that previously sat on the boundary (`auth.module`, `session.module`) now bootstrap their own `@Global()` stub providers for `PrismaService` + `ClsService` so AuditWriter's transitive resolution works without booting Prisma for real. - [ ] Manual smoke against running BFF + Postgres: - [ ] Sign in → `select * from audit.events where event_type = 'auth.sign_in'` returns one row with `actor_id_hash`, `subject = 'session:…'`, `payload.amr` populated. - [ ] Sign out → matching `auth.sign_out` row. - [ ] Force `SESSION_ABSOLUTE_TIMEOUT_SECONDS=5` + wait → `auth.session.expired` row with `payload.reason = 'absolute'` and `ageMs > 5000`. - [ ] Manual `UPDATE audit.events SET event_type = 'x' WHERE id = ...` as the BFF role → fails with "permission denied" (the role contract holds even when the migrator runs as a privileged login). --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #120
This commit was merged in pull request #120.
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuditWriter } from './audit.service';
|
||||
import { HashUserIdService } from './hash-user-id.service';
|
||||
|
||||
/**
|
||||
* Provides the AuditWriter to the rest of the BFF. Imported globally
|
||||
* by AppModule so any feature module can inject it without an extra
|
||||
* import. The actual append-only contract is enforced by the
|
||||
* Postgres role grants set up in the audit-schema migration — see
|
||||
* Provides the AuditWriter (and the salt-bound HashUserIdService it
|
||||
* depends on) to the rest of the BFF. Marked `@Global()` so a feature
|
||||
* module can inject `AuditWriter` by writing it in a constructor —
|
||||
* no need to re-import AuditModule in every feature. The append-only
|
||||
* contract is enforced by the Postgres role grants set up in the
|
||||
* audit-schema migration — see
|
||||
* apps/portal-bff/prisma/migrations/*_init_audit_schema.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuditWriter],
|
||||
exports: [AuditWriter],
|
||||
providers: [HashUserIdService, AuditWriter],
|
||||
exports: [HashUserIdService, AuditWriter],
|
||||
})
|
||||
export class AuditModule {}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaService } from 'nestjs-prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AuditWriter } from './audit.service';
|
||||
import type { AuditEventInput } from './audit.types';
|
||||
import { HashUserIdService } from './hash-user-id.service';
|
||||
|
||||
interface AuditEventCreateCall {
|
||||
data: {
|
||||
@@ -45,17 +46,20 @@ async function createSubject(): Promise<{
|
||||
writer: AuditWriter;
|
||||
prisma: MockPrisma;
|
||||
cls: { get: jest.Mock };
|
||||
hashUserId: { hash: jest.Mock };
|
||||
}> {
|
||||
const { prisma, cls } = buildMocks();
|
||||
const hashUserId = { hash: jest.fn((id: string) => `hash(${id})`) };
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuditWriter,
|
||||
{ provide: PrismaService, useValue: prisma },
|
||||
{ provide: ClsService, useValue: cls },
|
||||
{ provide: HashUserIdService, useValue: hashUserId },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
return { writer: moduleRef.get(AuditWriter), prisma, cls };
|
||||
return { writer: moduleRef.get(AuditWriter), prisma, cls, hashUserId };
|
||||
}
|
||||
|
||||
const baseInput: AuditEventInput = {
|
||||
@@ -172,3 +176,89 @@ describe('AuditWriter', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuditWriter — typed event methods', () => {
|
||||
describe('signIn()', () => {
|
||||
it('hashes the actor oid and records auth.sign_in (success / workforce)', async () => {
|
||||
const { writer, prisma, hashUserId } = await createSubject();
|
||||
await writer.signIn({
|
||||
actor: { oid: 'user-oid', amr: ['pwd', 'mfa'] },
|
||||
sessionId: 'sid-1',
|
||||
});
|
||||
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.eventType).toBe('auth.sign_in');
|
||||
expect(call.data.audience).toBe('workforce');
|
||||
expect(call.data.outcome).toBe('success');
|
||||
expect(call.data.actorIdHash).toBe('hash(user-oid)');
|
||||
expect(call.data.subject).toBe('session:sid-1');
|
||||
expect(call.data.payload).toEqual({ amr: ['pwd', 'mfa'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('signInFailed()', () => {
|
||||
it('records auth.sign_in.failed with the failureKind and no actor by default', async () => {
|
||||
const { writer, prisma, hashUserId } = await createSubject();
|
||||
await writer.signInFailed({ failureKind: 'state-mismatch' });
|
||||
expect(hashUserId.hash).not.toHaveBeenCalled();
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.eventType).toBe('auth.sign_in.failed');
|
||||
expect(call.data.outcome).toBe('failure');
|
||||
expect(call.data.actorIdHash).toBeNull();
|
||||
expect(call.data.payload).toEqual({ failureKind: 'state-mismatch' });
|
||||
});
|
||||
|
||||
it('merges payload with failureKind under the same key', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.signInFailed({
|
||||
failureKind: 'entra-error',
|
||||
payload: { entraError: 'access_denied', entraErrorDescription: 'user cancelled' },
|
||||
});
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.payload).toEqual({
|
||||
failureKind: 'entra-error',
|
||||
entraError: 'access_denied',
|
||||
entraErrorDescription: 'user cancelled',
|
||||
});
|
||||
});
|
||||
|
||||
it('hashes the actor oid when one is provided (rare — identity-after-rejection path)', async () => {
|
||||
const { writer, prisma, hashUserId } = await createSubject();
|
||||
await writer.signInFailed({ failureKind: 'amr-missing', actor: { oid: 'user-oid' } });
|
||||
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.actorIdHash).toBe('hash(user-oid)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('signOut()', () => {
|
||||
it('records auth.sign_out (success) with the hashed actor + session subject', async () => {
|
||||
const { writer, prisma, hashUserId } = await createSubject();
|
||||
await writer.signOut({ actor: { oid: 'user-oid' }, sessionId: 'sid-2' });
|
||||
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.eventType).toBe('auth.sign_out');
|
||||
expect(call.data.outcome).toBe('success');
|
||||
expect(call.data.actorIdHash).toBe('hash(user-oid)');
|
||||
expect(call.data.subject).toBe('session:sid-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionExpired()', () => {
|
||||
it('records auth.session.expired with reason + ageMs', async () => {
|
||||
const { writer, prisma, hashUserId } = await createSubject();
|
||||
await writer.sessionExpired({
|
||||
actor: { oid: 'user-oid' },
|
||||
sessionId: 'sid-3',
|
||||
reason: 'absolute',
|
||||
ageMs: 13 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
|
||||
const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall;
|
||||
expect(call.data.eventType).toBe('auth.session.expired');
|
||||
expect(call.data.outcome).toBe('success');
|
||||
expect(call.data.subject).toBe('session:sid-3');
|
||||
expect(call.data.payload).toEqual({ reason: 'absolute', ageMs: 13 * 60 * 60 * 1000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,14 @@ import { trace } from '@opentelemetry/api';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { PrismaService } from 'nestjs-prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { AuditEventInput } from './audit.types';
|
||||
import type {
|
||||
AuditEventInput,
|
||||
SignInActor,
|
||||
SignInFailedInput,
|
||||
SignOutInput,
|
||||
SessionExpiredInput,
|
||||
} from './audit.types';
|
||||
import { HashUserIdService } from './hash-user-id.service';
|
||||
|
||||
/**
|
||||
* AuditWriter — single entry point for ADR-0013 audit-log writes.
|
||||
@@ -37,8 +44,76 @@ export class AuditWriter {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly cls: ClsService,
|
||||
private readonly hashUserId: HashUserIdService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Typed event: successful sign-in via the OIDC callback. Per
|
||||
* ADR-0013's v1 catalogue (`auth.sign_in`).
|
||||
*
|
||||
* Hashes the user id internally — callers pass the raw Entra
|
||||
* `oid`, never the hash, so the salt stays inside the audit
|
||||
* module.
|
||||
*/
|
||||
async signIn(input: { actor: SignInActor; sessionId: string }): Promise<void> {
|
||||
await this.recordEvent({
|
||||
eventType: 'auth.sign_in',
|
||||
audience: 'workforce',
|
||||
outcome: 'success',
|
||||
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
||||
subject: `session:${input.sessionId}`,
|
||||
payload: { amr: input.actor.amr },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed event: failed sign-in at the callback. The `failureKind`
|
||||
* mirrors the discriminator on `AuthCodeFlowError` so the audit
|
||||
* row is self-describing without joining anything.
|
||||
*
|
||||
* `actorIdHash` is left null on purpose: at the moment of
|
||||
* failure we may not have resolved an identity yet (state
|
||||
* mismatch, expired flow, token-exchange error before any user
|
||||
* claim was parsed). Callers can pass an explicit hash when the
|
||||
* identity *was* resolved before rejection.
|
||||
*/
|
||||
async signInFailed(input: SignInFailedInput): Promise<void> {
|
||||
await this.recordEvent({
|
||||
eventType: 'auth.sign_in.failed',
|
||||
audience: 'workforce',
|
||||
outcome: 'failure',
|
||||
...(input.actor !== undefined ? { actorIdHash: this.hashUserId.hash(input.actor.oid) } : {}),
|
||||
payload: { failureKind: input.failureKind, ...(input.payload ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
async signOut(input: SignOutInput): Promise<void> {
|
||||
await this.recordEvent({
|
||||
eventType: 'auth.sign_out',
|
||||
audience: 'workforce',
|
||||
outcome: 'success',
|
||||
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
||||
subject: `session:${input.sessionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed event: session destroyed by the absolute-timeout
|
||||
* middleware (12 h hard ceiling, ADR-0010 §"TTL policy"). The
|
||||
* idle-TTL expiry is *not* surfaced through this method — it
|
||||
* happens silently inside Redis with no BFF observation point.
|
||||
*/
|
||||
async sessionExpired(input: SessionExpiredInput): Promise<void> {
|
||||
await this.recordEvent({
|
||||
eventType: 'auth.session.expired',
|
||||
audience: 'workforce',
|
||||
outcome: 'success',
|
||||
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
||||
subject: `session:${input.sessionId}`,
|
||||
payload: { reason: input.reason, ageMs: input.ageMs },
|
||||
});
|
||||
}
|
||||
|
||||
async recordEvent(input: AuditEventInput): Promise<void> {
|
||||
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
|
||||
const actorIdHash =
|
||||
|
||||
@@ -41,3 +41,44 @@ export interface AuditEventInput {
|
||||
*/
|
||||
actorIdHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity payload accepted by the typed audit methods. The raw
|
||||
* Entra `oid` is hashed by `AuditWriter` itself so the salt stays
|
||||
* inside the module. `amr` is captured for the sign-in record.
|
||||
*/
|
||||
export interface SignInActor {
|
||||
oid: string;
|
||||
amr: readonly string[];
|
||||
}
|
||||
|
||||
export interface SignInFailedInput {
|
||||
failureKind: string;
|
||||
/**
|
||||
* Optional — pass only when the identity was actually resolved
|
||||
* before the rejection (rare). Most failure paths (state
|
||||
* mismatch, expired flow, token-exchange error) reject before any
|
||||
* user claim is parsed and should leave `actor` undefined.
|
||||
*/
|
||||
actor?: { oid: string };
|
||||
/**
|
||||
* Extra fields to merge into the audit row's payload alongside
|
||||
* `failureKind`. PII is the caller's responsibility, same posture
|
||||
* as `AuditEventInput.payload`.
|
||||
*/
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SignOutInput {
|
||||
actor: { oid: string };
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface SessionExpiredInput {
|
||||
actor: { oid: string };
|
||||
sessionId: string;
|
||||
/** `absolute` is the only reason emitted in v1; `idle` is silent. */
|
||||
reason: 'absolute';
|
||||
/** Age of the session at the moment of expiry, in milliseconds. */
|
||||
ageMs: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { HashUserIdService } from './hash-user-id.service';
|
||||
|
||||
const STRONG_SALT = randomBytes(32).toString('base64url');
|
||||
|
||||
function withSalt<T>(salt: string, fn: () => T): T {
|
||||
const original = process.env['LOG_USER_ID_SALT'];
|
||||
process.env['LOG_USER_ID_SALT'] = salt;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env['LOG_USER_ID_SALT'];
|
||||
} else {
|
||||
process.env['LOG_USER_ID_SALT'] = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('HashUserIdService', () => {
|
||||
it('hashes the same input to the same output (stable join key)', () => {
|
||||
withSalt(STRONG_SALT, () => {
|
||||
const service = new HashUserIdService();
|
||||
expect(service.hash('user-oid-42')).toBe(service.hash('user-oid-42'));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a 16-hex-char digest', () => {
|
||||
withSalt(STRONG_SALT, () => {
|
||||
const service = new HashUserIdService();
|
||||
const hash = service.hash('user-oid-42');
|
||||
expect(hash).toMatch(/^[0-9a-f]{16}$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('produces different outputs for different inputs', () => {
|
||||
withSalt(STRONG_SALT, () => {
|
||||
const service = new HashUserIdService();
|
||||
expect(service.hash('alice')).not.toBe(service.hash('bob'));
|
||||
});
|
||||
});
|
||||
|
||||
it('produces different outputs across salts (rotation invalidates the join key)', () => {
|
||||
const a = withSalt(STRONG_SALT, () => new HashUserIdService().hash('alice'));
|
||||
const b = withSalt(randomBytes(32).toString('base64url'), () =>
|
||||
new HashUserIdService().hash('alice'),
|
||||
);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('refuses to construct when LOG_USER_ID_SALT is unset', () => {
|
||||
const original = process.env['LOG_USER_ID_SALT'];
|
||||
delete process.env['LOG_USER_ID_SALT'];
|
||||
try {
|
||||
expect(() => new HashUserIdService()).toThrow(/LOG_USER_ID_SALT is not set/);
|
||||
} finally {
|
||||
if (original !== undefined) {
|
||||
process.env['LOG_USER_ID_SALT'] = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { assertLogUserIdSalt } from '../config/check-log-user-id-salt';
|
||||
|
||||
/**
|
||||
* Pseudonymises a user id (Entra `oid`) into the 16-hex-char hash
|
||||
* that both ADR-0013 (`audit_events.actor_id_hash`) and ADR-0012
|
||||
* (Pino `user_id_hash`) store. The salt is read once at injection
|
||||
* time so the per-request hot path runs purely on `node:crypto`.
|
||||
*
|
||||
* Same salt + same input ⇒ same output, on both writers, across
|
||||
* application restarts within a given environment. That stability
|
||||
* is the *whole point*: an investigator joins audit rows with app
|
||||
* log lines on this hash, then re-hydrates the join key to the
|
||||
* cleartext user id only inside the live operational DB.
|
||||
*
|
||||
* Rotating the salt invalidates the join key for everything that
|
||||
* was written before the rotation. Treat as long-lived per
|
||||
* environment.
|
||||
*
|
||||
* Hash construction:
|
||||
* sha256(`${salt}:${userId}`).hex.slice(0, 16)
|
||||
*
|
||||
* SHA-256 is overkill for collision resistance at 16 hex chars
|
||||
* (64 bits of output), but truncation is intentional — log lines
|
||||
* and audit rows want a compact identifier, and 64 bits of
|
||||
* randomness is well above the birthday bound for any realistic
|
||||
* user count. The salt prevents rainbow attacks even on the
|
||||
* truncated form.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HashUserIdService {
|
||||
private readonly salt: string;
|
||||
|
||||
constructor() {
|
||||
this.salt = assertLogUserIdSalt();
|
||||
}
|
||||
|
||||
hash(userId: string): string {
|
||||
return createHash('sha256').update(`${this.salt}:${userId}`).digest('hex').slice(0, 16);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user