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,5 +1,6 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { Logger } from 'nestjs-pino';
|
||||
import type { AuditWriter } from '../audit/audit.service';
|
||||
import { createAbsoluteTimeoutMiddleware } from './absolute-timeout.middleware';
|
||||
import type { UserSessionIndexService } from './user-session-index.service';
|
||||
|
||||
@@ -42,6 +43,12 @@ function makeIndex(): UserSessionIndexService & { add: jest.Mock; remove: jest.M
|
||||
} as unknown as UserSessionIndexService & { add: jest.Mock; remove: jest.Mock };
|
||||
}
|
||||
|
||||
function makeAudit(): AuditWriter & { sessionExpired: jest.Mock } {
|
||||
return {
|
||||
sessionExpired: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AuditWriter & { sessionExpired: jest.Mock };
|
||||
}
|
||||
|
||||
function makeLogger() {
|
||||
return {
|
||||
log: jest.fn(),
|
||||
@@ -65,7 +72,12 @@ const USER = {
|
||||
|
||||
describe('absoluteTimeout middleware', () => {
|
||||
it('passes through when there is no session at all', async () => {
|
||||
const middleware = createAbsoluteTimeoutMiddleware(makeIndex(), makeLogger(), () => NOW);
|
||||
const middleware = createAbsoluteTimeoutMiddleware(
|
||||
makeIndex(),
|
||||
makeLogger(),
|
||||
makeAudit(),
|
||||
() => NOW,
|
||||
);
|
||||
const req = makeReq(undefined);
|
||||
const res = makeRes();
|
||||
const next: NextFunction = jest.fn();
|
||||
@@ -78,7 +90,12 @@ describe('absoluteTimeout middleware', () => {
|
||||
|
||||
it('passes through when the session has no user (anonymous request hit a session-touching route)', async () => {
|
||||
const session = makeSession();
|
||||
const middleware = createAbsoluteTimeoutMiddleware(makeIndex(), makeLogger(), () => NOW);
|
||||
const middleware = createAbsoluteTimeoutMiddleware(
|
||||
makeIndex(),
|
||||
makeLogger(),
|
||||
makeAudit(),
|
||||
() => NOW,
|
||||
);
|
||||
const res = makeRes();
|
||||
const next: NextFunction = jest.fn();
|
||||
|
||||
@@ -90,7 +107,12 @@ describe('absoluteTimeout middleware', () => {
|
||||
|
||||
it('passes through when absoluteExpiresAt is unset (pre-hardening session)', async () => {
|
||||
const session = makeSession({ user: USER });
|
||||
const middleware = createAbsoluteTimeoutMiddleware(makeIndex(), makeLogger(), () => NOW);
|
||||
const middleware = createAbsoluteTimeoutMiddleware(
|
||||
makeIndex(),
|
||||
makeLogger(),
|
||||
makeAudit(),
|
||||
() => NOW,
|
||||
);
|
||||
const res = makeRes();
|
||||
const next: NextFunction = jest.fn();
|
||||
|
||||
@@ -106,7 +128,12 @@ describe('absoluteTimeout middleware', () => {
|
||||
createdAt: NOW - 60_000,
|
||||
absoluteExpiresAt: NOW + 60_000,
|
||||
});
|
||||
const middleware = createAbsoluteTimeoutMiddleware(makeIndex(), makeLogger(), () => NOW);
|
||||
const middleware = createAbsoluteTimeoutMiddleware(
|
||||
makeIndex(),
|
||||
makeLogger(),
|
||||
makeAudit(),
|
||||
() => NOW,
|
||||
);
|
||||
const res = makeRes();
|
||||
const next: NextFunction = jest.fn();
|
||||
|
||||
@@ -117,7 +144,7 @@ describe('absoluteTimeout middleware', () => {
|
||||
expect(res.clearCookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('destroys the session, removes from the index, clears the cookie, and still calls next() when past the ceiling', async () => {
|
||||
it('destroys the session, removes from the index, clears the cookie, audits expiry, and still calls next() when past the ceiling', async () => {
|
||||
const session = makeSession({
|
||||
user: USER,
|
||||
createdAt: NOW - 13 * 60 * 60 * 1000,
|
||||
@@ -125,7 +152,8 @@ describe('absoluteTimeout middleware', () => {
|
||||
});
|
||||
const index = makeIndex();
|
||||
const logger = makeLogger();
|
||||
const middleware = createAbsoluteTimeoutMiddleware(index, logger, () => NOW);
|
||||
const audit = makeAudit();
|
||||
const middleware = createAbsoluteTimeoutMiddleware(index, logger, audit, () => NOW);
|
||||
const res = makeRes();
|
||||
const next: NextFunction = jest.fn();
|
||||
|
||||
@@ -134,6 +162,12 @@ describe('absoluteTimeout middleware', () => {
|
||||
expect(session.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(index.remove).toHaveBeenCalledWith(USER.oid, 'session-abc');
|
||||
expect(res.clearCookie).toHaveBeenCalledWith('portal_session', { path: '/' });
|
||||
expect(audit.sessionExpired).toHaveBeenCalledWith({
|
||||
actor: { oid: USER.oid },
|
||||
sessionId: 'session-abc',
|
||||
reason: 'absolute',
|
||||
ageMs: 13 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: 'session.absolute_timeout',
|
||||
@@ -145,7 +179,7 @@ describe('absoluteTimeout middleware', () => {
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs but does not throw when destroy() fails — still clears cookie + index + calls next()', async () => {
|
||||
it('logs but does not throw when destroy() fails — still clears cookie + index + audits + calls next()', async () => {
|
||||
const session = makeSession({
|
||||
user: USER,
|
||||
createdAt: NOW - 13 * 60 * 60 * 1000,
|
||||
@@ -156,7 +190,8 @@ describe('absoluteTimeout middleware', () => {
|
||||
);
|
||||
const index = makeIndex();
|
||||
const logger = makeLogger();
|
||||
const middleware = createAbsoluteTimeoutMiddleware(index, logger, () => NOW);
|
||||
const audit = makeAudit();
|
||||
const middleware = createAbsoluteTimeoutMiddleware(index, logger, audit, () => NOW);
|
||||
const res = makeRes();
|
||||
const next: NextFunction = jest.fn();
|
||||
|
||||
@@ -170,6 +205,7 @@ describe('absoluteTimeout middleware', () => {
|
||||
'AbsoluteTimeout',
|
||||
);
|
||||
expect(index.remove).toHaveBeenCalledWith(USER.oid, 'session-abc');
|
||||
expect(audit.sessionExpired).toHaveBeenCalled();
|
||||
expect(res.clearCookie).toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { AuditWriter } from '../audit/audit.service';
|
||||
import { sessionCookieName } from './session-cookie';
|
||||
import { UserSessionIndexService } from './user-session-index.service';
|
||||
|
||||
@@ -29,6 +30,7 @@ import { UserSessionIndexService } from './user-session-index.service';
|
||||
export function createAbsoluteTimeoutMiddleware(
|
||||
index: UserSessionIndexService,
|
||||
logger: Logger,
|
||||
audit: AuditWriter,
|
||||
now: () => number = Date.now,
|
||||
): RequestHandler {
|
||||
return async function absoluteTimeout(
|
||||
@@ -79,6 +81,18 @@ export function createAbsoluteTimeoutMiddleware(
|
||||
await index.remove(userId, sessionId);
|
||||
res.clearCookie(sessionCookieName(), { path: '/' });
|
||||
|
||||
// Audit the expiry — blocking per ADR-0013: if the audit row
|
||||
// can't be written, the request fails (the user sees a 5xx,
|
||||
// ops gets a critical Pino line). The session was already
|
||||
// destroyed above, so the user is signed out regardless — the
|
||||
// failure surfaces in the response, not the security posture.
|
||||
await audit.sessionExpired({
|
||||
actor: { oid: userId },
|
||||
sessionId,
|
||||
reason: 'absolute',
|
||||
ageMs,
|
||||
});
|
||||
|
||||
logger.log(
|
||||
{
|
||||
event: 'session.absolute_timeout',
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { PrismaService } from 'nestjs-prisma';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { SessionModule } from './session.module';
|
||||
import { SESSION_MIDDLEWARE, type RequestHandler } from './session.token';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{ provide: PrismaService, useValue: {} },
|
||||
{ provide: ClsService, useValue: { get: () => undefined } },
|
||||
],
|
||||
exports: [PrismaService, ClsService],
|
||||
})
|
||||
class TestStubsModule {}
|
||||
|
||||
const STRONG_KEY = randomBytes(32).toString('base64url');
|
||||
const STRONG_SECRET = randomBytes(32).toString('base64url');
|
||||
|
||||
@@ -11,12 +25,22 @@ interface OriginalEnv {
|
||||
REDIS_URL: string | undefined;
|
||||
SESSION_SECRET: string | undefined;
|
||||
SESSION_ENCRYPTION_KEY: string | undefined;
|
||||
LOG_USER_ID_SALT: string | undefined;
|
||||
NODE_ENV: string | undefined;
|
||||
}
|
||||
|
||||
// SessionModule's absolute-timeout middleware factory now depends
|
||||
// on `AuditWriter` (provided by AuditModule via @Global() in
|
||||
// production). The spec compiles a slice of the graph so we import
|
||||
// AuditModule explicitly and stub its DB-touching deps.
|
||||
async function compile() {
|
||||
return Test.createTestingModule({
|
||||
imports: [LoggerModule.forRoot({ pinoHttp: { level: 'silent' } }), SessionModule],
|
||||
imports: [
|
||||
LoggerModule.forRoot({ pinoHttp: { level: 'silent' } }),
|
||||
TestStubsModule,
|
||||
AuditModule,
|
||||
SessionModule,
|
||||
],
|
||||
}).compile();
|
||||
}
|
||||
|
||||
@@ -25,6 +49,7 @@ describe('SessionModule', () => {
|
||||
REDIS_URL: process.env['REDIS_URL'],
|
||||
SESSION_SECRET: process.env['SESSION_SECRET'],
|
||||
SESSION_ENCRYPTION_KEY: process.env['SESSION_ENCRYPTION_KEY'],
|
||||
LOG_USER_ID_SALT: process.env['LOG_USER_ID_SALT'],
|
||||
NODE_ENV: process.env['NODE_ENV'],
|
||||
};
|
||||
|
||||
@@ -34,6 +59,7 @@ describe('SessionModule', () => {
|
||||
process.env['REDIS_URL'] = 'redis://default:test-pass@127.0.0.1:65535/0';
|
||||
process.env['SESSION_SECRET'] = STRONG_SECRET;
|
||||
process.env['SESSION_ENCRYPTION_KEY'] = STRONG_KEY;
|
||||
process.env['LOG_USER_ID_SALT'] = randomBytes(32).toString('base64url');
|
||||
process.env['NODE_ENV'] = 'development';
|
||||
});
|
||||
|
||||
@@ -41,6 +67,7 @@ describe('SessionModule', () => {
|
||||
restore('REDIS_URL', original.REDIS_URL);
|
||||
restore('SESSION_SECRET', original.SESSION_SECRET);
|
||||
restore('SESSION_ENCRYPTION_KEY', original.SESSION_ENCRYPTION_KEY);
|
||||
restore('LOG_USER_ID_SALT', original.LOG_USER_ID_SALT);
|
||||
restore('NODE_ENV', original.NODE_ENV);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { assertSessionEncryptionKey } from '../config/check-session-encryption-k
|
||||
import { assertSessionSecret } from '../config/check-session-secret';
|
||||
import { RedisModule } from '../redis/redis.module';
|
||||
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
|
||||
import { AuditWriter } from '../audit/audit.service';
|
||||
import { createAbsoluteTimeoutMiddleware } from './absolute-timeout.middleware';
|
||||
import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter';
|
||||
import { SessionDecryptError, decrypt, encrypt } from './session-crypto';
|
||||
@@ -57,9 +58,12 @@ import { UserSessionIndexService } from './user-session-index.service';
|
||||
UserSessionIndexService,
|
||||
{
|
||||
provide: SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
|
||||
inject: [UserSessionIndexService, Logger],
|
||||
useFactory: (index: UserSessionIndexService, logger: Logger): RequestHandler =>
|
||||
createAbsoluteTimeoutMiddleware(index, logger),
|
||||
inject: [UserSessionIndexService, Logger, AuditWriter],
|
||||
useFactory: (
|
||||
index: UserSessionIndexService,
|
||||
logger: Logger,
|
||||
audit: AuditWriter,
|
||||
): RequestHandler => createAbsoluteTimeoutMiddleware(index, logger, audit),
|
||||
},
|
||||
{
|
||||
provide: SESSION_MIDDLEWARE,
|
||||
|
||||
Reference in New Issue
Block a user