1df99cd800
## Summary Second PR of the **portal-admin User-list chantier** per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"v1 scope — User list (read-only)". Ships the **read side**: paginated, filterable HTTP endpoint that queries the `public.users` directory populated at sign-in by PR #140. The SPA viewer screen lands in the final PR of the chantier. ## What lands ### [`AdminUsersQueryDto`](apps/portal-bff/src/admin/users-query.dto.ts) Mirrors `AdminAuditQueryDto`'s posture — filters all optional, every unknown query key rejected by `forbidNonWhitelisted`, limit capped at **200** / default **50**. | Filter | Type | Notes | | --- | --- | --- | | `username` | string ≤128 | Exact-prefix match (Prisma `startsWith`). | | `displayName` | string ≤128 | Case-insensitive `contains` (display names vary in casing). | | `audience` | enum | `workforce` \| `customer`. | | `lastSeenAtFrom` | ISO-8601 | Inclusive lower bound. | | `lastSeenAtTo` | ISO-8601 | Exclusive upper bound. | | `limit` | int 1..200 | Default **50**. | | `offset` | int ≥0 | Default **0**. | ### [`AdminUsersReader`](apps/portal-bff/src/admin/admin-users-reader.service.ts) Prisma typed client against `public.users` — **no `SET LOCAL ROLE`** dance because `public.users` has no role-based privilege gate. The trust boundary is the controller's `@RequireAdmin` guard. - **Order**: `last_seen_at DESC, oid ASC`. The second clause is a deterministic tie-breaker for pagination during sign-in bursts that share a timestamp. - **COUNT + SELECT in one Prisma transaction** so the `total` reported to the SPA matches what's on the page even under a concurrent sign-in landing between the two queries. - **Hard cap on limit** at 200 even when a caller bypasses the DTO — defense in depth on the BFF's event loop. ### [`AdminUsersController`](apps/portal-bff/src/admin/admin-users.controller.ts) `GET /api/admin/users`, `@RequireAdmin()` at the class level. Forwards the validated DTO to `AdminUsersReader`, then emits `admin.users.query` with `{ filters, resultCount }` — the fishing-expedition deterrent (mirror of `admin.audit.query` from PR #132). ### [`AuditWriter.adminUsersQuery()`](apps/portal-bff/src/audit/audit.service.ts) New typed method + `AdminUsersQueryInput` type. Same `outcome=success` / payload shape as `adminAuditQuery` — two distinct event types so a reviewer can pivot directly on `eventType` without parsing payload. ## Notes for the reviewer - The shape mirrors `AdminAuditController` (#132) deliberately. Future admin read endpoints (CMS pages, menu items if they grow read filters) should adopt the same shape — keeps the SPA's data-flow pattern consistent across modules. - `public.users` queries don't need `SET LOCAL ROLE` because there's no append-only / read-only role split on the table. That's a deliberate v1 simplification; if the security review later asks for stricter isolation we'd add a `users_reader` role + the same SET-LOCAL pattern AuditReader uses. - The future "sign-in counts" join from `audit.events` on `actor_id_hash` is **deferred**. The salted hash is computable on the fly via `HashUserIdService`, so adding it later is a service-level change — no schema migration required. - The `actor_id_hash` is deliberately **NOT** stored on `public.users` (per ADR-0013's invariant — the salt stays inside the audit module). ## Test plan - [x] `pnpm nx test portal-bff` — **391 specs pass** (was 365; +26 covering DTO validation, reader transaction shape + filter forwarding + pagination defaults + cap, controller path, audit typed method). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Prisma transaction shape verified: COUNT + SELECT both fire exactly once per call; tuple-return contract honoured. - [x] Filter projections verified per filter: username `startsWith`, displayName case-insensitive `contains`, audience exact match, lastSeenAt `gte/lt` composition. - [ ] e2e — pending the admin SPA viewer screen + a real admin session. Once both exist: `curl --cookie 'portal_admin_session=...' /api/admin/users?username=jane&limit=10` returns the matching subset and a new `admin.users.query` audit row lands. ## What's next The chantier's final PR: - **portal-admin `/users` screen** — SPA viewer with filter form + table + pagination. Same shape as the `/audit` page (PR #136): signal-driven state, color-coded badges for audience, ISO timestamps formatted locale-side, status states. Will graduate the sidebar entry from `aria-disabled` "Soon" badge to a live link. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #141
266 lines
10 KiB
TypeScript
266 lines
10 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,
|
|
AdminAuditQueryInput,
|
|
AdminUsersQueryInput,
|
|
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: an admin queried the audit-log viewer. Recorded at
|
|
* **coarser granularity** per ADR-0020 §"Audit log captures every
|
|
* admin write action … Read actions (audit log viewing, user list
|
|
* browsing) are also captured … to deter fishing expeditions".
|
|
*
|
|
* The filter object the admin used is serialised into the payload
|
|
* so a reviewer can see what was searched (event type, time range,
|
|
* actor hash, etc.). `resultCount` is the number of rows the SPA
|
|
* received in this trip — paged calls each emit one event; an
|
|
* auditor spotting a sequence of `admin.audit.query` with growing
|
|
* offsets is the v1 detection signal for a sweep.
|
|
*
|
|
* `outcome=success` regardless of whether the query returned any
|
|
* rows: the event captures "the admin ran a query", not "the
|
|
* query matched something". An `outcome=failure` audit row from
|
|
* this surface is reserved for the day we add per-row authZ.
|
|
*/
|
|
async adminAuditQuery(input: AdminAuditQueryInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'admin.audit.query',
|
|
audience: 'workforce',
|
|
outcome: 'success',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
payload: {
|
|
filters: input.filters,
|
|
resultCount: input.resultCount,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: an admin queried the user-directory viewer. Same
|
|
* deterrent posture as {@link adminAuditQuery} per ADR-0020
|
|
* §"Read actions ... to deter fishing expeditions" — captures the
|
|
* filter and the result count so a reviewer can spot a sweep
|
|
* (admin paging through everyone) without joining anything.
|
|
*/
|
|
async adminUsersQuery(input: AdminUsersQueryInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'admin.users.query',
|
|
audience: 'workforce',
|
|
outcome: 'success',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
payload: {
|
|
filters: input.filters,
|
|
resultCount: input.resultCount,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
);
|
|
});
|
|
}
|
|
}
|