707ff25ee007bd9f50c99e00aa7df019208f359c
3 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
707ff25ee0 |
feat(portal-bff): admin audit-log read endpoint + audit_reader-locked path
First real consumer of the admin module — paginated audit-log viewer
per ADR-0020's v1 catalogue, gated by @RequireAdmin and emitting
admin.audit.query on every read as the "fishing expedition" deterrent
called out in ADR-0020 §"Audit log captures … Read actions are also
captured … to deter fishing expeditions".
What ships
- AuditReader (apps/portal-bff/src/admin/audit-reader.service.ts):
- SET LOCAL ROLE audit_reader inside the transaction — symmetric
with AuditWriter's SET LOCAL ROLE audit_writer. The runtime
role contract holds even when the BFF connection is otherwise
privileged; UPDATE/INSERT/DELETE on audit.events fail at the
Postgres level.
- Parameterised SELECT only — never concatenates filter values
into SQL. Subject prefix uses LIKE with explicit ESCAPE and
escapes %/_ in the literal so an admin-side wildcard can't
masquerade as a meta-character.
- COUNT(*) + LIMIT/OFFSET pagination. Order: created_at DESC,
id DESC for deterministic page boundaries on identical
timestamps.
- Hard caps limit at MAX_LIMIT (200) even if a caller bypasses
the DTO — defense in depth on the BFF's event loop.
- AdminAuditQueryDto (apps/portal-bff/src/admin/audit-query.dto.ts):
- Filters: eventType, actorIdHash, audience, outcome,
subjectPrefix, createdAtFrom/createdAtTo (ISO-8601).
- Pagination: limit (1..200, default 50), offset (default 0).
- Wired through Nest's global ValidationPipe so unknown query keys
are rejected (forbidNonWhitelisted) and limit is bounded.
- AdminAuditController:
- GET /api/admin/audit, @RequireAdmin at the class level.
- Forwards the validated DTO to AuditReader, then emits
admin.audit.query with { filters, resultCount } as payload so a
reviewer can see what the admin searched for.
- @RequireMfa intentionally NOT applied in v1: the admin surface
already sits behind a freshly-MFA'd session at sign-in, and the
per-query audit row is the deterrent. Adding @RequireMfa later
is a one-line change.
- AuditWriter gains adminAuditQuery() typed method emitting the
event with outcome=success (the read happened; whether it
returned rows is captured separately in resultCount).
Tests: +30 specs (AuditReader 10, AdminAuditController 6, AuditWriter
typed event 2, DTO validation 12). All 308 BFF specs pass.
|
||
|
|
fed905edc5 |
feat(portal-bff): distinct admin session + /api/admin/auth flow (#129)
## Summary
Phase-3a step per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"Sessions — distinct from `portal-shell`". Wires a second `express-session` middleware on `/api/admin/*` carrying `__Host-portal_admin_session` over Redis prefix `session:admin:`, and ships the parallel `/api/admin/auth/{login,callback,me,logout}` flow that populates it. Signing in to one surface no longer signs the user into the other — Entra SSO at the IdP level still preserves the click-through.
## What lands
### Session middlewares — path-routed dispatch
| Token | Cookie | Redis prefix | Bound to |
| --- | --- | --- | --- |
| `SESSION_MIDDLEWARE` | `portal_session` / `__Host-portal_session` | `session:` | every path **except** `/api/admin/*` |
| `ADMIN_SESSION_MIDDLEWARE` | `portal_admin_session` / `__Host-portal_admin_session` | `session:admin:` | `/api/admin/*` only |
Implemented via a `buildSessionMiddleware(redis, logger, opts)` factory in [session.module.ts](apps/portal-bff/src/session/session.module.ts) — the TTL policy, encryption key, signing secret, session-id entropy, and serializer error-handling all come from the same source. Only the cookie name + Redis key prefix differ.
The dispatch in [main.ts](apps/portal-bff/src/main.ts) is a tiny `(req, res, next) => req.path.startsWith('/api/admin') ? adminSession(...) : userSession(...)`. Running both middlewares unconditionally would have the second overwrite `req.session` from the first, collapsing the two surfaces.
### Distinct admin auth flow
[`AdminAuthController`](apps/portal-bff/src/admin/admin-auth.controller.ts) mounts `/api/admin/auth/{login,callback,me,logout}`. Structurally identical to [`AuthController`](apps/portal-bff/src/auth/auth.controller.ts) but passes `adminRedirectUri` / `adminPostLogoutRedirectUri` and clears the admin session cookie on logout. `me` exposes the `roles` claim (admin SPA needs it for conditional UI); the user-portal `me` intentionally still doesn't.
### Shared `SessionEstablisher` (no controller duplication)
[`SessionEstablisher`](apps/portal-bff/src/auth/session-establisher.service.ts) encapsulates the session lifecycle so both controllers stay thin:
- `establish({ user, req, res, surface })` — mints CSRF, populates `user / createdAt / absoluteExpiresAt / csrfToken / mfaVerifiedAt`, saves, sets the CSRF cookie, registers in `user_sessions` index, emits `auth.sign_in` audit (blocking), logs with the `surface` tag.
- `destroy({ actor, req })` — when `actor` is set, removes from index + emits `auth.sign_out`; always destroys the session with Redis-hiccup tolerance.
No code duplicated between the two surfaces — the only per-surface differences are the redirect URIs (passed in) and the cookie names cleared on logout (controller-local).
### Entra config gains two URIs
`EntraConfig` adds `adminRedirectUri` + `adminPostLogoutRedirectUri`, validated at boot in [check-entra-config.ts](apps/portal-bff/src/config/check-entra-config.ts). The validator **refuses to start** when `ENTRA_ADMIN_REDIRECT_URI === ENTRA_REDIRECT_URI` — that misconfiguration would silently collapse the two surfaces into one session. Both URIs must be registered on the same Entra app registration's "Redirect URIs" list.
### `AuthService` API change
`beginAuthCodeFlow(redirectUri)`, `completeAuthCodeFlow(code, state, preAuth, redirectUri, now?)`, and `buildLogoutUrl(postLogoutRedirectUri)` now take their URI as a parameter. Callers (user-portal vs admin-portal controllers) pick which set to pass.
## Required ops action before this PR can run locally
Two new mandatory env vars. The BFF refuses to start without them.
```env
ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4201/
```
The example values land in [apps/portal-bff/.env.example](apps/portal-bff/.env.example) for reference. The corresponding Entra app registration also needs `/api/admin/auth/callback` added to its "Redirect URIs" list before any admin sign-in works end-to-end.
## Notes for the reviewer
- The user-portal callback's post-login redirect still targets `postLogoutRedirectUri` (existing quirk where the post-auth and post-logout landing happen to be the same URL). The admin callback mirrors the pattern for `adminPostLogoutRedirectUri`. Splitting these into dedicated post-login URIs is a separate ADR/PR.
- `AdminModule` now imports `AuthModule` to consume `AuthService`, `SessionEstablisher`, and `ENTRA_CONFIG`. `AuditWriter` and `RequireMfaGuard` come through transitively.
- Existing `AuthController` spec assertions are preserved through the refactor by constructing a **real** `SessionEstablisher` in the test fixture with the same audit / index / logger mocks. No behavioural assertion was removed — the inline session-state-setting logic is now exercised through the establisher.
- The pre-existing docstring in `check-entra-config.ts` line 11-16 still says "the two redirect URIs are mandatory once the OIDC routes ship (next PR)" — stale, the routes have shipped. Not touched in this PR to keep the diff focused; can be a one-line doc PR later.
## Test plan
- [x] `pnpm nx test portal-bff` — **278 specs pass** (was 253; +25: admin cookie 3, session-establisher 11, admin auth controller 9, entra config 2).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean (the pre-existing `_res` / `_next` warnings in `rate-limit.middleware.ts` are unrelated).
- [x] Entra config validator: both URIs required, both URL-validated, equality refused.
- [x] Path-dispatch verified by routing — `/api/admin/me` and `/api/admin/auth/*` see the admin session; everything else sees the user session.
- [ ] e2e — pending env var update + Entra registration update to add the admin redirect URI. Once both are in place: sign in via `/api/auth/login`, see `portal_session` cookie; clear cookies; sign in via `/api/admin/auth/login`, see `portal_admin_session` cookie; verify `/api/admin/me` works on the admin session and `/api/auth/me` works on the user session — neither sees the other's session.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #129
|
||
|
|
3ed6dae3a5 |
feat(portal-bff): admin module + role guard + /api/admin/me self-test (#127)
## Summary
Lays the foundation for the `/api/admin/*` surface per [ADR-0020](docs/decisions/0020-portal-admin-app.md). This PR ships the role guard, the `@RequireAdmin()` decorator, and a self-test endpoint — no business routes yet. The next consumer (audit log viewer) lands in a later PR once the distinct admin session is in place.
## What ships
- **[AdminRoleGuard](apps/portal-bff/src/admin/admin-role.guard.ts)** — three branches:
- No session at all → **401**. No audit; unauthenticated probes are normal traffic, not a privilege-escalation signal.
- Session but `roles` lacks `admin` → **403** + `admin.access_denied` audit row with actor hash, attempted route (`${METHOD} ${originalUrl}`), and the roles the user did hold.
- Session with `admin` role → pass through.
- Audit-write failures propagate (no audit ⇒ no action, consistent with the existing call sites in [AuthController](apps/portal-bff/src/auth/auth.controller.ts)).
- **[`@RequireAdmin()`](apps/portal-bff/src/admin/require-admin.decorator.ts)** — semantic sugar for `@UseGuards(AdminRoleGuard)` built with `applyDecorators` so future composition (e.g. with the upcoming `@RequireMfa({ freshness })` for the admin entry route) is mechanical.
- **`GET /api/admin/me`** — self-test endpoint named in ADR-0020 §"Confirmation" step 3. Returns the public user payload + `roles` so ops can `curl` the gate with three sessions (no cookie / non-admin cookie / admin cookie) and observe `401` / `403` + audit / `200` respectively.
- **[`AuditWriter.adminAccessDenied()`](apps/portal-bff/src/audit/audit.service.ts)** — new typed method using the pre-existing `denied` outcome enum value. Keeps the salt inside the audit module, matches the pattern of `signIn` / `signOut` / `sessionExpired`.
## Why the shared portal-shell session (for now)
ADR-0020 mandates a distinct `__Host-portal_admin_session` cookie + Redis namespace `session:admin:*` for the admin app. **That is not in this PR.** The chantier sequence splits it out: this PR proves the guard semantics + audit integration on the existing session; the next PR introduces the distinct session middleware + admin-specific auth flow.
Rationale: the guard logic is independent of the session implementation — `session.user.roles` is the only field it reads. Landing it first means a smaller diff to review, a faster opportunity to validate the audit emission on a real Postgres, and a clean baseline to layer the session split onto.
## Notes for the reviewer
- The non-null assertion on `req.session.user!` in [admin.controller.ts:27](apps/portal-bff/src/admin/admin.controller.ts#L27) is explicitly disabled with an inline comment pointing at the guard's contract. The alternative (a defensive runtime check) duplicates the guard's logic without adding safety. The spec for the guard covers every branch including the absent-user path.
- `AdminController` does not depend on `AuthService`'s `toPublicUser` projection — that helper is private to the auth module and pulls `displayName` / `username` with extra account-object fallbacks specific to the OIDC callback. The admin response is built from the already-populated session, so a duplicated projection here is the simpler shape.
## Open questions (out of scope)
- The Entra app role `admin` must be **declared on the app registration manifest** and **assigned to at least one test user** before this gate can be exercised end-to-end. That's an Entra Admin Center operation, not code. The guard's behaviour under all three branches is covered by unit tests; e2e validation waits until the role is assigned.
## Test plan
- [x] `pnpm nx test portal-bff` — **214 specs pass** (was 203; +11 covering guard branches, controller projection, audit method).
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean (the pre-existing `_res` / `_next` warnings in `rate-limit.middleware.ts` are unrelated).
- [x] Audit row schema verified — `admin.access_denied` events use `outcome=denied`, store the route in `subject`, and persist `{ rolesHeld: [...] }` in the JSONB payload.
- [ ] e2e — pending Entra role declaration + assignment. Will be covered by manual ops curl checks once the role exists.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #127
|