feat(portal-bff): admin user-directory read endpoint (#141)
## 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
This commit was merged in pull request #141.
This commit is contained in:
@@ -354,6 +354,42 @@ describe('AuditWriter — typed event methods', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminUsersQuery()', () => {
|
||||
it('records admin.users.query (success) with filters + resultCount in payload', async () => {
|
||||
const { writer, prisma, hashUserId } = await createSubject();
|
||||
await writer.adminUsersQuery({
|
||||
actor: { oid: 'admin-oid' },
|
||||
filters: { username: 'jane', limit: 50 },
|
||||
resultCount: 7,
|
||||
});
|
||||
expect(hashUserId.hash).toHaveBeenCalledWith('admin-oid');
|
||||
const row = extractInsertedRow(prisma);
|
||||
expect(row.eventType).toBe('admin.users.query');
|
||||
expect(row.audience).toBe('workforce');
|
||||
expect(row.outcome).toBe('success');
|
||||
expect(row.actorIdHash).toBe('hash(admin-oid)');
|
||||
expect(row.subject).toBeNull();
|
||||
expect(row.payloadJson).toBe(
|
||||
JSON.stringify({
|
||||
filters: { username: 'jane', limit: 50 },
|
||||
resultCount: 7,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('persists an empty filters object verbatim (admin paged through everyone)', async () => {
|
||||
const { writer, prisma } = await createSubject();
|
||||
await writer.adminUsersQuery({
|
||||
actor: { oid: 'admin-oid' },
|
||||
filters: {},
|
||||
resultCount: 200,
|
||||
});
|
||||
expect(extractInsertedRow(prisma).payloadJson).toBe(
|
||||
JSON.stringify({ filters: {}, resultCount: 200 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaRequired()', () => {
|
||||
it('records auth.mfa_required (denied) with reason + freshnessSeconds in payload', async () => {
|
||||
const { writer, prisma, hashUserId } = await createSubject();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaService } from 'nestjs-prisma';
|
||||
import type {
|
||||
AdminAccessDeniedInput,
|
||||
AdminAuditQueryInput,
|
||||
AdminUsersQueryInput,
|
||||
AuditEventInput,
|
||||
MfaRequiredInput,
|
||||
SignInActor,
|
||||
@@ -173,6 +174,26 @@ export class AuditWriter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
|
||||
@@ -118,6 +118,21 @@ export interface AdminAuditQueryInput {
|
||||
resultCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same shape as {@link AdminAuditQueryInput} but targets the
|
||||
* `admin.users.query` event type. Per ADR-0020's deterrent posture,
|
||||
* the admin user-list read is auditable just like the audit-log
|
||||
* read — same payload contract, different `eventType`. A future
|
||||
* refactor could fold both into a single generic typed method, but
|
||||
* v1 keeps them split so the call sites declare intent at the type
|
||||
* system.
|
||||
*/
|
||||
export interface AdminUsersQueryInput {
|
||||
actor: { oid: string };
|
||||
filters: Record<string, unknown>;
|
||||
resultCount: number;
|
||||
}
|
||||
|
||||
/** Reason `RequireMfaGuard` rejected a request — fed into the audit payload. */
|
||||
export type MfaRequiredReason = 'no-mfa-in-amr' | 'no-mfa-verified-at' | 'mfa-stale';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user