Files
apf_portal/apps/portal-bff/src/audit/audit.service.ts
T
julien 928ed0cdc2
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m49s
CI / a11y (push) Successful in 3m8s
CI / perf (push) Successful in 6m12s
CI / check (push) Failing after 1m42s
feat(admin): add /admin/users/:oid/scopes screen + endpoints (ADR-0026 PR 2b) (#236)
## Summary

ADR-0026 PR 2b (second half of PR 2). Ships the operator surface for the `@RequireScope` stack — the admin Angular screen at `/admin/users/:oid/scopes` lets an admin list / grant / revoke scopes for an existing portal User. Both write paths emit blocking audit rows per ADR-0013 (`admin.scope_granted` / `admin.scope_revoked`).

Closes the ADR-0026 trilogy:

| PR | Status | Effect |
| --- | --- | --- |
| ADR-0026 PR 1 (#232) | merged | Person + User + UserScope schema + provisioner + drift gate Person.source + PrincipalBuilder real UUIDs. |
| ADR-0026 PR 2a (#233) | merged | PrismaScopeResolver replaces stub + prisma/seed.ts populates 19 personas. |
| **ADR-0026 PR 2b (this)** | proposed | Admin scope-management screen — operator surface. |

## What lands

### BFF (under `apps/portal-bff/src/`)

| File | Change |
| --- | --- |
| `admin/user-scopes.dto.ts` | **New**. `GrantUserScopeDto` (class-validator on kind + value + ISO expiresAt) + response shapes (`UserScopeDto`, `UserScopesPageDto`). |
| `admin/user-scopes.service.ts` | **New**. `resolveUserByOid` (404 when no portal User), `list` (`UserScope` rows joined to User+Person), `grant` (validates value against ADR-0027 tables for value-bearing kinds, rejects non-empty for valueless, P2002 → 400), `revoke` (404-protected — won't leak scope-belongs-to-other-user). |
| `admin/user-scopes.controller.ts` | **New** REST surface at `/api/admin/users/:oid/scopes` with `@RequireAdmin`. `GET` (no audit, read), `POST` (audit `admin.scope_granted`), `DELETE :scopeId` (audit `admin.scope_revoked`, 204 on success). |
| `admin/user-scopes.service.spec.ts` + `admin/user-scopes.controller.spec.ts` | **New** specs — 15 cases across resolve / list / grant (value-bearing + valueless + duplicate) / revoke / audit semantics. |
| `admin/admin.module.ts` | Registers `UserScopesController` + `UserScopesService`. |
| `audit/audit.types.ts` + `audit/audit.service.ts` | New `AdminScopeGrantedInput` / `AdminScopeRevokedInput` types + `adminScopeGranted` / `adminScopeRevoked` methods on `AuditWriter`. `subject = 'user:<oid>'` so an auditor pivots on the target of the change; payload carries the resolved tuple at the moment of the write (the revoke payload survives the row's deletion). |

### SPA (under `apps/portal-admin/src/app/`)

| File | Change |
| --- | --- |
| `app.routes.ts` | New route `/users/:oid/scopes` (lazy-loaded). |
| `pages/user-scopes/user-scopes.service.ts` + `.service.spec.ts` | **New** thin HttpClient wrapper (`list` / `grant` / `revoke`). Same shape convention as `AdminUsersService`. |
| `pages/user-scopes/user-scopes.ts` + `.html` + `.scss` + `.spec.ts` | **New** page component. Signals-based + OnPush. Lists current scopes in a table with `Revoke` per row; "Grant a new scope" form with kind dropdown + conditional value input + optional `expiresAt`. Friendly error mapping (403 → "no admin access", 404 → "User not found, has this persona ever signed in or been seeded?", server messages forwarded). |
| `pages/users/users.html` | New `Manage scopes` link per row (RouterLink to the new page), with `aria-label` carrying the user's displayName. |
| `pages/users/users.ts` + `users.spec.ts` | Adds `RouterLink` import + `provideRouter([])` in the spec fixture (was missing — the route addition exposed it). |

## Key choices

- **`:oid` in the URL, not `User.id`.** The existing `/admin/users` list (ADR-0020) keys on Entra `oid` and the new screen is its child — linking from the list to the scopes page without an intermediate UUID lookup is the simplest UX. The BFF translates `oid → User.id` internally via `resolveUserByOid`.
- **404 carries a hint.** When `resolveUserByOid` misses, the BFF throws `NotFoundException("No portal User found for Entra oid ${oid}.")` and the SPA translates the 403/404 status to friendly French-English text. The hint mentions "has this persona ever signed in or been seeded?" — a common operator confusion (the user-directory list shows oids that signed in but the `User` row only exists post-sign-in OR post-seed).
- **Audit before write commit, write after audit success.** Standard ADR-0013 posture. Order in the controller: service call (which writes the DB row) → audit (blocking). If audit fails, the DB row exists but no audit — a known v1 cost for non-rollback flows. The simpler alternative (audit before write) would emit ghost audit rows for failed writes; we picked the cleaner direction.
- **Value-bearing validation against ADR-0027 tables, not catalogue.** `etablissement:0330800013` must match an existing `Structure.code`; `delegation:33` must match `Delegation.code`; `region:75` must match `Region.code`. The lookup is a single `findUnique` per kind; rejection raises 400 with the offending value in the message. Valueless kinds (`self` / `siege` / `unrestricted`) reject any non-empty value as a defensive check (the DTO type already constrains this but the service runs the assertion).
- **`UserScope.source = 'admin-ui'`** for every row created by this surface. Distinct from `'seed'` (set by `prisma/seed.ts`) and `'self-signin'` (Person source only). ADR-0029's syncs will add `'pleiades'` / `'acteurs-plus'`.
- **A11y per [ADR-0016](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)**: `aria-labelledby` on the form sections, `role="status"`/`aria-live="polite"` for load + error feedback, `role="alert"` for submit errors, `min-height: 44px` on every input/button per the touch-target rule, `aria-label` on the Manage-scopes link carrying the displayName for screen readers, conditional `aria-required` + `aria-describedby` on the value input.
- **`window.confirm` before revoke.** Cheap protection against accidental keyboard-activation. v1 OK; a future polish PR could replace with the spartan-ng dialog when it ships.

## Local verification

- [x] `node scripts/check-catalogue-drift.mjs` — clean (4 / 24 / 7 / 3).
- [x] `pnpm exec nx lint portal-bff` — **0 errors** (13 warnings, all pre-existing).
- [x] `pnpm exec nx lint portal-admin` — **0 errors** (3 warnings, all pre-existing).
- [x] `pnpm exec nx test portal-bff` — full suite passes (now includes the 2 new `user-scopes` spec files).
- [x] `pnpm exec nx test portal-admin` — **71 tests passing** (added 8 for `user-scopes`; updated `users.spec.ts` to provide a router after the RouterLink import).

## Test plan — remaining (manual on dev DB)

- [ ] **Sign in as `admin@apfrd...`** (the only persona with `Portal.Admin`) on `portal-admin`. Navigate to `/admin/users`. Click `Manage scopes` for `directeur-bordeaux`. The page shows one row `etablissement:0330800013` (from the seed).
- [ ] **Grant a new scope**: kind `delegation`, value `33`. Submit. New row appears; audit row `admin.scope_granted` lands in `audit.events`.
- [ ] **Try a bogus value** (kind `etablissement`, value `xyz`): server returns 400, form shows the message.
- [ ] **Try a duplicate** (`etablissement:0330800013` again on `directeur-bordeaux`): server returns 400 with "already granted".
- [ ] **Revoke a scope**: row disappears; audit row `admin.scope_revoked` lands.
- [ ] **Sign in as a non-admin persona** (e.g. `collaborateur-simple`) and try `/admin/users/.../scopes` → 403 + friendly error.
- [ ] **Review focus** — the BFF's `validateValueForKind` switch, the SPA's `translateError` mapping, the a11y attributes on the form, the audit ordering in the controller methods.

## Notes for the reviewer

- **No scope-vocabulary endpoint.** The form expects the operator to type the `Structure.code` / `Delegation.code` / `Region.code` by hand (with a hint string under the field). Defensive validation catches typos. A future polish PR could add `GET /api/admin/scope-vocabulary` returning all three lists at once + replace the value input with a typeahead dropdown.
- **Renamed the service's `UserScopesPage` interface to `UserScopesPayload`.** Discovered the collision with the component class during the first test run. The component keeps the `UserScopesPage` name per Angular convention; the payload type now reads as what it is.
- **`UserScope.source` is not under the drift gate today.** The schema comment in PR 1 said "same catalogue posture as Person.source"; in practice the seed writes `'seed'` and this PR writes `'admin-ui'`. Extending the drift gate to cover `UserScope.source` is a small follow-up — defensible to do once ADR-0029's upstream-sync values are landing.

## What's next

ADR-0025's `@RequireScope` stack is now end-to-end live for the test tenant: persona → Entra → BFF → PrismaScopeResolver → DB → Principal → guard. The trilogy is done.

Per the prior conversation: **GitLab migration Phase 1** (`mirror-and-bootstrap`) is the next item — mostly ops work on `vm-gitlab` (groups, branch protection, Renovate reconfig, deploy keys). The first PR-shaped output from that phase is the Renovate config update.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #236
2026-05-26 15:54:21 +02:00

366 lines
14 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,
AdminAuditStatsQueryInput,
AdminScopeGrantedInput,
AdminScopeRevokedInput,
AdminUsersQueryInput,
AuditEventInput,
AuthorizationDeniedInput,
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 audit-log STATISTICS endpoint
* (`GET /api/admin/audit/stats`). Same deterrent posture as
* {@link adminAuditQuery} but separates the event type so an
* auditor can spot "the admin scanned aggregations" vs "the admin
* paged through rows" — different observation signals.
*
* Payload carries `total` (the size of the aggregated dataset)
* rather than `resultCount` — stats responses don't paginate, and
* `total` is what tells a reviewer how wide the scan was.
*/
async adminAuditStatsQuery(input: AdminAuditStatsQueryInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.audit.stats.query',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
payload: {
filters: input.filters,
total: input.total,
},
});
}
/**
* 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: an admin granted a scope to a user via the ADR-0026
* PR 2b scope-management screen. Blocking per ADR-0013 — a failed
* audit write means the scope write itself is rolled back upstream.
* The `subject` is `user:<oid>` of the affected user, so an auditor
* pivots on the *target* of the change, not the actor.
*/
async adminScopeGranted(input: AdminScopeGrantedInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.scope_granted',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: `user:${input.target.oid}`,
payload: {
scopeId: input.scopeId,
kind: input.kind,
value: input.value,
expiresAt: input.expiresAt,
},
});
}
/**
* Typed event: an admin revoked a scope from a user. Payload
* carries the resolved tuple at the moment of revocation so the
* deletion is reconstructable from the audit log alone — the
* `user_scopes` row itself is gone by then.
*/
async adminScopeRevoked(input: AdminScopeRevokedInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.scope_revoked',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: `user:${input.target.oid}`,
payload: {
scopeId: input.scopeId,
kind: input.kind,
value: input.value,
},
});
}
/**
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
* because the session's `roles` claim does not include `Portal.Admin`.
* Per ADR-0020 §"Auth — same Entra ID … `Portal.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 },
});
}
/**
* Typed event: a request was rejected by one of the three new
* ADR-0025 guards (`RequirePrivilegeGuard`, `RequireRoleGuard`,
* `RequireScopeGuard`). Distinct from `admin.access_denied` so
* an auditor can keep the existing admin-surface signal clean
* while tracking the broader role/scope denial surface as it
* grows.
*
* `outcome=denied` matches the existing posture: the user *is*
* authenticated, the action is just refused. The `required` and
* `held` payload arrays let an auditor pivot on "tried admin
* while logged in as RH" without joining anything.
*/
async authorizationDenied(input: AuthorizationDeniedInput): Promise<void> {
await this.recordEvent({
eventType: 'auth.authorization_denied',
audience: 'workforce',
outcome: 'denied',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: input.attemptedRoute,
payload: {
kind: input.kind,
required: input.required,
held: input.held,
},
});
}
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,
);
});
}
}