9ba5815f1b
Designed-in, dormant per ADR-0011 §"Step-up MFA". This PR ships the
guard, the decorator, and the audit integration — no v1 route uses
the decorator yet. First consumer will be the admin entry route per
ADR-0020 (`@RequireMfa({ freshness: 600 })`) once the distinct admin
session is in place.
Mechanics:
- `auth/mfa.ts` exports the documented `MFA_AMR_VALUES` allow-list
(mfa, otp, fido, wia, phr) and `wasMultiFactor(amr)`. Adding a
value is an ADR-recorded decision; the spec pins the list.
- `config/check-mfa-config.ts` reads `MFA_FRESHNESS_SECONDS` with
default 600 s + minimum 60 s. Anything below the floor fails the
validator (the floor catches "MFA on every navigation"
misconfiguration).
- `RequireMfaGuard` (`auth/require-mfa.guard.ts`):
- No session → 401 `unauthenticated`, no audit.
- Session with no MFA-class `amr` → 401 `mfa_required` + audit
`auth.mfa_required reason=no-mfa-in-amr`.
- Session with no `mfaVerifiedAt` → 401 + audit `reason=no-mfa-verified-at`.
- Session with stale `mfaVerifiedAt` → 401 + audit
`reason=mfa-stale` (includes `mfaAgeMs` payload field).
- Same audit-write propagation posture as `AdminRoleGuard`.
- The 401 carries `code: 'mfa_required'` in the structured error
envelope. The `reason` discriminator is NOT surfaced over the
wire — only the audit row carries it, so an attacker can't
fingerprint sessions by probing.
- `RequireMfaOptions.freshness` overrides the env default per-route.
Read via `Reflector.getAllAndOverride` with method-level metadata
winning over class-level — Nest's standard merge.
- `WWW-Authenticate` header + MSAL claims-challenge blob (ADR-0011
§"Step-up MFA — designed-in" step 2) defer to a later PR — they
need MSAL Node integration AND the SPA interceptor to consume
them. The structured `code: 'mfa_required'` is sufficient for the
SPA to pivot on once the interceptor lands.
Session payload:
- `session.mfaVerifiedAt` added to the express-session augmentation
in `session.types.ts`. Set to `Date.now()` at sign-in by the
callback — Entra's CA policy is the authority on whether MFA
actually happened; the BFF just stamps "now" when persisting a
session whose `amr` reflects MFA. Refreshed by future step-up
re-auth flows.
Tests: +37 specs (mfa helpers 9, config reader 9, guard 12, audit
typed method 3, callback assertion +1, +3 parametric expansions).
213 lines
8.0 KiB
TypeScript
213 lines
8.0 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { trace } from '@opentelemetry/api';
|
|
import { ClsService } from 'nestjs-cls';
|
|
import { PrismaService } from 'nestjs-prisma';
|
|
import type {
|
|
AdminAccessDeniedInput,
|
|
AuditEventInput,
|
|
MfaRequiredInput,
|
|
SignInActor,
|
|
SignInFailedInput,
|
|
SignOutInput,
|
|
SessionExpiredInput,
|
|
} from './audit.types';
|
|
import { HashUserIdService } from './hash-user-id.service';
|
|
|
|
/**
|
|
* AuditWriter — single entry point for ADR-0013 audit-log writes.
|
|
*
|
|
* Contract
|
|
* --------
|
|
* - **Append-only at the database level.** Every write runs inside a
|
|
* transaction whose first statement is `SET LOCAL ROLE
|
|
* audit_writer`. That role only has `INSERT` on `audit.events`
|
|
* (per the migration that created the table); `UPDATE`, `DELETE`,
|
|
* `TRUNCATE` all fail at the Postgres level even if the BFF
|
|
* connection is otherwise privileged. The role is reset
|
|
* automatically at transaction end.
|
|
*
|
|
* - **Fail loud, never swallow.** Per ADR-0013 §"Blocking writes":
|
|
* no audit ⇒ no action. Callers must propagate the rejection up
|
|
* so the requested action does not proceed when its audit trail
|
|
* cannot be written. The service throws the underlying Prisma
|
|
* error unchanged; do not wrap it in a catch-and-log block.
|
|
*
|
|
* - **trace_id and actor_id_hash are auto-resolved.** trace_id is
|
|
* read from the active OTel span context (so the audit row joins
|
|
* with the BFF request span and the Pino log lines on the same
|
|
* request). actor_id_hash is read from the CLS context populated
|
|
* by future auth guards (ADR-0009 / ADR-0010); v1 stores `null`
|
|
* when no actor is established. Callers can override either by
|
|
* passing them on `AuditEventInput`.
|
|
*/
|
|
@Injectable()
|
|
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 },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: a request was rejected by `RequireMfaGuard` because
|
|
* the session did not satisfy the route's MFA freshness gate.
|
|
* Per ADR-0011 §"Step-up MFA — designed-in, dormant", every
|
|
* step-up challenge emitted by the BFF lands here so auditors can
|
|
* track the distribution of step-up prompts (and the rate of stale
|
|
* `mfaVerifiedAt` rejections that would suggest the default
|
|
* freshness is too tight). `outcome=denied` matches the
|
|
* `AdminRoleGuard` posture: the user *is* authenticated, the
|
|
* action is just refused.
|
|
*/
|
|
async mfaRequired(input: MfaRequiredInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'auth.mfa_required',
|
|
audience: 'workforce',
|
|
outcome: 'denied',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
subject: input.attemptedRoute,
|
|
payload: {
|
|
reason: input.reason,
|
|
freshnessSeconds: input.freshnessSeconds,
|
|
...(input.mfaAgeMs !== undefined ? { mfaAgeMs: input.mfaAgeMs } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
|
|
* because the session's `roles` claim does not include `admin`.
|
|
* Per ADR-0020 §"Auth — same Entra ID … `admin` role claim", every
|
|
* 403 from the admin surface is captured here with the attempted
|
|
* route and the roles the user actually held — auditors looking
|
|
* for privilege-escalation attempts pivot on `subject` (the route)
|
|
* and `outcome=denied`.
|
|
*/
|
|
async adminAccessDenied(input: AdminAccessDeniedInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'admin.access_denied',
|
|
audience: 'workforce',
|
|
outcome: 'denied',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
subject: input.attemptedRoute,
|
|
payload: { rolesHeld: input.rolesHeld },
|
|
});
|
|
}
|
|
|
|
async recordEvent(input: AuditEventInput): Promise<void> {
|
|
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
|
|
const actorIdHash =
|
|
input.actorIdHash ?? this.cls.get<string | undefined>('actorIdHash') ?? null;
|
|
const payloadJson = input.payload === undefined ? null : JSON.stringify(input.payload);
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
// Lock the connection to audit_writer for the duration of this
|
|
// transaction. SET LOCAL is reset at COMMIT/ROLLBACK so the
|
|
// pool's next consumer sees the original role.
|
|
await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_writer`);
|
|
|
|
// Deliberately NOT `tx.auditEvent.create(...)`. The Prisma ORM
|
|
// create() emits `INSERT … RETURNING *` to hydrate the entity
|
|
// it returns, and Postgres requires SELECT on every column
|
|
// listed in RETURNING. `audit_writer` is granted INSERT only
|
|
// (ADR-0013 §"Append-only by role grants"); RETURNING fails
|
|
// with the deeply misleading "permission denied for table
|
|
// events" error code 42501. Raw parameterised INSERT keeps
|
|
// the role contract strict — audit_writer never needs SELECT.
|
|
//
|
|
// `gen_random_uuid()` is built into Postgres 13+ (the dev /
|
|
// prod target is 17). The enum + jsonb casts are needed
|
|
// because the parameter values are sent as TEXT over the
|
|
// wire.
|
|
await tx.$executeRawUnsafe(
|
|
`INSERT INTO "audit"."events"
|
|
(id, event_type, audience, outcome, subject, actor_id_hash, trace_id, payload)
|
|
VALUES (
|
|
gen_random_uuid(),
|
|
$1,
|
|
$2::"audit"."AuditAudience",
|
|
$3::"audit"."AuditOutcome",
|
|
$4,
|
|
$5,
|
|
$6,
|
|
$7::jsonb
|
|
)`,
|
|
input.eventType,
|
|
input.audience,
|
|
input.outcome,
|
|
input.subject ?? null,
|
|
actorIdHash,
|
|
traceId,
|
|
payloadJson,
|
|
);
|
|
});
|
|
}
|
|
}
|