From 0817520e49823a5021052b3b240708d967593853 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Thu, 14 May 2026 19:56:11 +0200 Subject: [PATCH] feat(portal-bff): admin user-directory read endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second PR of the portal-admin User-list chantier per ADR-0020 §"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 (admin/users-query.dto.ts): mirrors AdminAuditQueryDto's posture — filters all optional, every unknown key rejected by `forbidNonWhitelisted`, limit capped at MAX_LIMIT (200) / default 50. Filters: username (exact prefix), displayName (case-insensitive contains), audience (workforce | customer enum), lastSeenAtFrom/To (ISO-8601). - AdminUsersReader (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 run in a single Prisma transaction so the `total` reported to the SPA matches what's on the page even under a concurrent sign-in. - AdminUsersController (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 } as the fishing-expedition deterrent (mirror of admin.audit.query from PR #132). - AuditWriter.adminUsersQuery() 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. Tests: +18 specs (DTO validation 9, reader 9 covering COUNT+SELECT ordering, filter forwarding, default/cap on limit, range filter composition; controller 6; audit typed method 2). All 391 BFF specs pass. Out of scope (next PR of the chantier): - portal-admin /users screen — the SPA viewer with filter form + table + pagination, mirroring the /audit page that the audit viewer PR shipped. - Sign-in counts joined from `audit.events` on `actor_id_hash` (computed via HashUserIdService on the fly). Deferred until the v1 list ships and the admin demand makes itself known. --- .../admin/admin-users-reader.service.spec.ts | 147 ++++++++++++++++++ .../src/admin/admin-users-reader.service.ts | 118 ++++++++++++++ .../src/admin/admin-users.controller.spec.ts | 91 +++++++++++ .../src/admin/admin-users.controller.ts | 42 +++++ apps/portal-bff/src/admin/admin.module.ts | 6 +- .../src/admin/users-query.dto.spec.ts | 60 +++++++ apps/portal-bff/src/admin/users-query.dto.ts | 73 +++++++++ .../src/audit/audit.service.spec.ts | 36 +++++ apps/portal-bff/src/audit/audit.service.ts | 21 +++ apps/portal-bff/src/audit/audit.types.ts | 15 ++ 10 files changed, 607 insertions(+), 2 deletions(-) create mode 100644 apps/portal-bff/src/admin/admin-users-reader.service.spec.ts create mode 100644 apps/portal-bff/src/admin/admin-users-reader.service.ts create mode 100644 apps/portal-bff/src/admin/admin-users.controller.spec.ts create mode 100644 apps/portal-bff/src/admin/admin-users.controller.ts create mode 100644 apps/portal-bff/src/admin/users-query.dto.spec.ts create mode 100644 apps/portal-bff/src/admin/users-query.dto.ts diff --git a/apps/portal-bff/src/admin/admin-users-reader.service.spec.ts b/apps/portal-bff/src/admin/admin-users-reader.service.spec.ts new file mode 100644 index 0000000..401e396 --- /dev/null +++ b/apps/portal-bff/src/admin/admin-users-reader.service.spec.ts @@ -0,0 +1,147 @@ +import { Test } from '@nestjs/testing'; +import { PrismaService } from 'nestjs-prisma'; +import { AdminUsersReader } from './admin-users-reader.service'; + +interface MockPrisma { + user: { + count: jest.Mock; + findMany: jest.Mock; + }; + $transaction: jest.Mock; +} + +function buildPrisma(opts?: { count?: number; items?: unknown[] }): MockPrisma { + const count = jest.fn().mockResolvedValue(opts?.count ?? 0); + const findMany = jest.fn().mockResolvedValue(opts?.items ?? []); + return { + user: { count, findMany }, + // The reader calls $transaction with the promises returned by + // `count()` + `findMany()` — the operations have already fired + // by the time $transaction sees them. The mock just resolves + // them together so Prisma's tuple-return contract is honoured. + $transaction: jest + .fn() + .mockImplementation((promises: ReadonlyArray>) => Promise.all(promises)), + }; +} + +async function createSubject(prisma: MockPrisma): Promise { + const moduleRef = await Test.createTestingModule({ + providers: [AdminUsersReader, { provide: PrismaService, useValue: prisma }], + }).compile(); + return moduleRef.get(AdminUsersReader); +} + +const ROW = { + oid: 'user-oid', + tid: 'tenant-1', + audience: 'workforce', + username: 'jane.doe@apf.example', + displayName: 'Jane Doe', + firstSeenAt: new Date('2026-05-10T10:00:00.000Z'), + lastSeenAt: new Date('2026-05-14T08:30:00.000Z'), +}; + +describe('AdminUsersReader.findUsers', () => { + it('issues a COUNT + SELECT in a single Prisma transaction', async () => { + const prisma = buildPrisma({ count: 5, items: [ROW] }); + const reader = await createSubject(prisma); + const page = await reader.findUsers({}); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.user.count).toHaveBeenCalledTimes(1); + expect(prisma.user.findMany).toHaveBeenCalledTimes(1); + expect(page.total).toBe(5); + expect(page.items).toHaveLength(1); + }); + + it('projects rows into the SPA-facing shape (ISO timestamps)', async () => { + const prisma = buildPrisma({ items: [ROW] }); + const reader = await createSubject(prisma); + const page = await reader.findUsers({}); + expect(page.items[0]).toEqual({ + oid: 'user-oid', + tid: 'tenant-1', + audience: 'workforce', + username: 'jane.doe@apf.example', + displayName: 'Jane Doe', + firstSeenAt: '2026-05-10T10:00:00.000Z', + lastSeenAt: '2026-05-14T08:30:00.000Z', + }); + }); + + it('orders by last_seen_at DESC, oid ASC for deterministic pagination', async () => { + const prisma = buildPrisma(); + const reader = await createSubject(prisma); + await reader.findUsers({}); + const findManyArgs = prisma.user.findMany.mock.calls[0]?.[0] as { + orderBy: ReadonlyArray>; + }; + expect(findManyArgs.orderBy).toEqual([{ lastSeenAt: 'desc' }, { oid: 'asc' }]); + }); + + it('passes startsWith filter on username through to Prisma', async () => { + const prisma = buildPrisma(); + const reader = await createSubject(prisma); + await reader.findUsers({ username: 'jane' }); + const args = prisma.user.findMany.mock.calls[0]?.[0] as { + where: { username?: { startsWith?: string } }; + }; + expect(args.where.username).toEqual({ startsWith: 'jane' }); + }); + + it('passes case-insensitive contains filter on displayName through to Prisma', async () => { + const prisma = buildPrisma(); + const reader = await createSubject(prisma); + await reader.findUsers({ displayName: 'doe' }); + const args = prisma.user.findMany.mock.calls[0]?.[0] as { + where: { displayName?: { contains?: string; mode?: string } }; + }; + expect(args.where.displayName).toEqual({ contains: 'doe', mode: 'insensitive' }); + }); + + it('combines lastSeenAtFrom and lastSeenAtTo into a single gte/lt range filter', async () => { + const prisma = buildPrisma(); + const reader = await createSubject(prisma); + await reader.findUsers({ + lastSeenAtFrom: '2026-05-01T00:00:00.000Z', + lastSeenAtTo: '2026-05-14T23:59:59.999Z', + }); + const args = prisma.user.findMany.mock.calls[0]?.[0] as { + where: { lastSeenAt?: { gte?: Date; lt?: Date } }; + }; + expect(args.where.lastSeenAt?.gte).toBeInstanceOf(Date); + expect(args.where.lastSeenAt?.lt).toBeInstanceOf(Date); + expect(args.where.lastSeenAt?.gte?.toISOString()).toBe('2026-05-01T00:00:00.000Z'); + expect(args.where.lastSeenAt?.lt?.toISOString()).toBe('2026-05-14T23:59:59.999Z'); + }); + + it('applies default limit (50) and offset (0) when not provided', async () => { + const prisma = buildPrisma(); + const reader = await createSubject(prisma); + const page = await reader.findUsers({}); + const args = prisma.user.findMany.mock.calls[0]?.[0] as { + take: number; + skip: number; + }; + expect(args.take).toBe(50); + expect(args.skip).toBe(0); + expect(page.limit).toBe(50); + expect(page.offset).toBe(0); + }); + + it('caps limit at MAX_LIMIT (200) even when a caller bypasses the DTO', async () => { + const prisma = buildPrisma(); + const reader = await createSubject(prisma); + await reader.findUsers({ limit: 1000 }); + const args = prisma.user.findMany.mock.calls[0]?.[0] as { take: number }; + expect(args.take).toBe(200); + }); + + it('emits no WHERE clause when no filter is provided', async () => { + const prisma = buildPrisma(); + const reader = await createSubject(prisma); + await reader.findUsers({}); + const args = prisma.user.findMany.mock.calls[0]?.[0] as { where: object }; + expect(args.where).toEqual({}); + }); +}); diff --git a/apps/portal-bff/src/admin/admin-users-reader.service.ts b/apps/portal-bff/src/admin/admin-users-reader.service.ts new file mode 100644 index 0000000..62283b5 --- /dev/null +++ b/apps/portal-bff/src/admin/admin-users-reader.service.ts @@ -0,0 +1,118 @@ +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from 'nestjs-prisma'; +import { DEFAULT_LIMIT, MAX_LIMIT, type AdminUsersQueryDto } from './users-query.dto'; + +/** + * SPA-facing projection of a row from `public.users`. Mirrors the + * Prisma model but exposes ISO-string timestamps so the SPA never + * has to know about `Date` serialisation conventions. + */ +export interface AdminUserDto { + readonly oid: string; + readonly tid: string; + readonly audience: string; + readonly username: string; + readonly displayName: string; + readonly firstSeenAt: string; + readonly lastSeenAt: string; +} + +export interface AdminUsersPage { + readonly items: readonly AdminUserDto[]; + readonly total: number; + readonly limit: number; + readonly offset: number; +} + +/** + * `AdminUsersReader` — query side of the `public.users` directory + * per ADR-0020 §"v1 scope — User list (read-only)". Drives the + * `GET /api/admin/users` admin endpoint. + * + * Uses Prisma's typed client directly — unlike `AuditReader`, no + * `SET LOCAL ROLE` is needed because `public.users` has no + * role-based privilege gate (it's a regular business table; the + * trust boundary is the `@RequireAdmin` guard on the controller). + * + * Default order: `last_seen_at DESC` — the "most recently active" + * sort the admin UI most often wants. Falls back to `oid ASC` as + * a tie-breaker so pagination stays deterministic when two rows + * share a timestamp (rare, but possible during a sign-in burst). + */ +@Injectable() +export class AdminUsersReader { + constructor(private readonly prisma: PrismaService) {} + + async findUsers(filters: AdminUsersQueryDto): Promise { + const limit = Math.min(filters.limit ?? DEFAULT_LIMIT, MAX_LIMIT); + const offset = filters.offset ?? 0; + const where = buildWhere(filters); + + // Run COUNT + SELECT in a single Prisma transaction so the + // `total` reported to the SPA matches what's on the page — + // a concurrent sign-in landing between the two queries would + // otherwise produce an off-by-one between the count and the + // items. + const [total, items] = await this.prisma.$transaction([ + this.prisma.user.count({ where }), + this.prisma.user.findMany({ + where, + orderBy: [{ lastSeenAt: 'desc' }, { oid: 'asc' }], + take: limit, + skip: offset, + }), + ]); + + return { + items: items.map(toDto), + total, + limit, + offset, + }; + } +} + +function buildWhere(filters: AdminUsersQueryDto): Prisma.UserWhereInput { + const where: Prisma.UserWhereInput = {}; + if (filters.username !== undefined) { + where.username = { startsWith: filters.username }; + } + if (filters.displayName !== undefined) { + // Display names have natural casing variation ("Jane Doe" vs + // "jane doe") so the admin filter matches case-insensitively. + where.displayName = { contains: filters.displayName, mode: 'insensitive' }; + } + if (filters.audience !== undefined) { + where.audience = filters.audience; + } + if (filters.lastSeenAtFrom !== undefined || filters.lastSeenAtTo !== undefined) { + where.lastSeenAt = { + ...(filters.lastSeenAtFrom !== undefined ? { gte: new Date(filters.lastSeenAtFrom) } : {}), + ...(filters.lastSeenAtTo !== undefined ? { lt: new Date(filters.lastSeenAtTo) } : {}), + }; + } + return where; +} + +interface UserRow { + oid: string; + tid: string; + audience: string; + username: string; + displayName: string; + firstSeenAt: Date; + lastSeenAt: Date; +} + +function toDto(row: UserRow): AdminUserDto { + return { + oid: row.oid, + tid: row.tid, + audience: row.audience, + username: row.username, + displayName: row.displayName, + firstSeenAt: row.firstSeenAt.toISOString(), + lastSeenAt: row.lastSeenAt.toISOString(), + }; +} diff --git a/apps/portal-bff/src/admin/admin-users.controller.spec.ts b/apps/portal-bff/src/admin/admin-users.controller.spec.ts new file mode 100644 index 0000000..9783385 --- /dev/null +++ b/apps/portal-bff/src/admin/admin-users.controller.spec.ts @@ -0,0 +1,91 @@ +import type { Request } from 'express'; +import type { AuditWriter } from '../audit/audit.service'; +import { AdminUsersController } from './admin-users.controller'; +import type { AdminUsersReader, AdminUsersPage } from './admin-users-reader.service'; +import type { AdminUsersQueryDto } from './users-query.dto'; + +const PAGE: AdminUsersPage = { + items: [ + { + oid: 'user-oid', + tid: 'tenant-1', + audience: 'workforce', + username: 'jane.doe@apf.example', + displayName: 'Jane Doe', + firstSeenAt: '2026-05-10T10:00:00.000Z', + lastSeenAt: '2026-05-14T08:30:00.000Z', + }, + ], + total: 137, + limit: 50, + offset: 0, +}; + +function makeReq(user?: { oid: string }): Request { + return { session: user !== undefined ? { user } : {} } as unknown as Request; +} + +function makeFixture() { + const usersReader = { findUsers: jest.fn().mockResolvedValue(PAGE) }; + const auditWriter = { adminUsersQuery: jest.fn().mockResolvedValue(undefined) }; + const controller = new AdminUsersController( + usersReader as unknown as AdminUsersReader, + auditWriter as unknown as AuditWriter, + ); + return { controller, usersReader, auditWriter }; +} + +describe('AdminUsersController.list', () => { + it('returns the page from AdminUsersReader', async () => { + const { controller } = makeFixture(); + const page = await controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminUsersQueryDto); + expect(page).toBe(PAGE); + }); + + it('forwards the validated DTO to AdminUsersReader as-is', async () => { + const { controller, usersReader } = makeFixture(); + const filters: AdminUsersQueryDto = { + username: 'jane', + audience: 'workforce', + lastSeenAtFrom: '2026-05-01T00:00:00.000Z', + limit: 25, + offset: 0, + }; + await controller.list(makeReq({ oid: 'admin-oid' }), filters); + expect(usersReader.findUsers).toHaveBeenCalledWith(filters); + }); + + it('emits admin.users.query with filters + result count', async () => { + const { controller, auditWriter } = makeFixture(); + const filters: AdminUsersQueryDto = { username: 'jane', limit: 50 }; + await controller.list(makeReq({ oid: 'admin-oid' }), filters); + expect(auditWriter.adminUsersQuery).toHaveBeenCalledWith({ + actor: { oid: 'admin-oid' }, + filters: { username: 'jane', limit: 50 }, + resultCount: PAGE.items.length, + }); + }); + + it('captures an empty filter object verbatim', async () => { + const { controller, auditWriter } = makeFixture(); + await controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminUsersQueryDto); + expect(auditWriter.adminUsersQuery).toHaveBeenCalledWith( + expect.objectContaining({ filters: {} }), + ); + }); + + it('still returns the page when no session.user (defensive — guard normally catches)', async () => { + const { controller, auditWriter } = makeFixture(); + const page = await controller.list(makeReq(), {} as AdminUsersQueryDto); + expect(page).toBe(PAGE); + expect(auditWriter.adminUsersQuery).not.toHaveBeenCalled(); + }); + + it('propagates audit write failures (blocking per ADR-0013)', async () => { + const { controller, auditWriter } = makeFixture(); + auditWriter.adminUsersQuery.mockRejectedValueOnce(new Error('audit_writer denied')); + await expect( + controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminUsersQueryDto), + ).rejects.toThrow('audit_writer denied'); + }); +}); diff --git a/apps/portal-bff/src/admin/admin-users.controller.ts b/apps/portal-bff/src/admin/admin-users.controller.ts new file mode 100644 index 0000000..e0941a8 --- /dev/null +++ b/apps/portal-bff/src/admin/admin-users.controller.ts @@ -0,0 +1,42 @@ +import { Controller, Get, Query, Req } from '@nestjs/common'; +import type { Request } from 'express'; +import { AuditWriter } from '../audit/audit.service'; +import { AdminUsersReader, type AdminUsersPage } from './admin-users-reader.service'; +import { RequireAdmin } from './require-admin.decorator'; +import { AdminUsersQueryDto } from './users-query.dto'; + +/** + * `GET /api/admin/users` — paginated, filterable view of the BFF's + * user directory per ADR-0020's v1 admin catalogue. Gated by + * `@RequireAdmin` and emits `admin.users.query` on every read so + * the directory browsing surface is itself auditable. + * + * Mirrors `AdminAuditController`'s shape so the SPA's data flow + * (filters → service → audit emit → response) follows the same + * pattern across admin modules. + */ +@Controller('admin/users') +@RequireAdmin() +export class AdminUsersController { + constructor( + private readonly usersReader: AdminUsersReader, + private readonly audit: AuditWriter, + ) {} + + @Get() + async list(@Req() req: Request, @Query() filters: AdminUsersQueryDto): Promise { + const page = await this.usersReader.findUsers(filters); + + // Guard guarantees `req.session.user`; defensively narrow. + const actorOid = req.session.user?.oid; + if (actorOid !== undefined) { + await this.audit.adminUsersQuery({ + actor: { oid: actorOid }, + filters: { ...filters }, + resultCount: page.items.length, + }); + } + + return page; + } +} diff --git a/apps/portal-bff/src/admin/admin.module.ts b/apps/portal-bff/src/admin/admin.module.ts index a1f2e9e..f01d183 100644 --- a/apps/portal-bff/src/admin/admin.module.ts +++ b/apps/portal-bff/src/admin/admin.module.ts @@ -4,6 +4,8 @@ import { AdminAuditController } from './admin-audit.controller'; import { AdminAuthController } from './admin-auth.controller'; import { AdminController } from './admin.controller'; import { AdminRoleGuard } from './admin-role.guard'; +import { AdminUsersController } from './admin-users.controller'; +import { AdminUsersReader } from './admin-users-reader.service'; import { AuditReader } from './audit-reader.service'; /** @@ -30,7 +32,7 @@ import { AuditReader } from './audit-reader.service'; */ @Module({ imports: [AuthModule], - controllers: [AdminController, AdminAuthController, AdminAuditController], - providers: [AdminRoleGuard, AuditReader], + controllers: [AdminController, AdminAuthController, AdminAuditController, AdminUsersController], + providers: [AdminRoleGuard, AuditReader, AdminUsersReader], }) export class AdminModule {} diff --git a/apps/portal-bff/src/admin/users-query.dto.spec.ts b/apps/portal-bff/src/admin/users-query.dto.spec.ts new file mode 100644 index 0000000..51386f2 --- /dev/null +++ b/apps/portal-bff/src/admin/users-query.dto.spec.ts @@ -0,0 +1,60 @@ +import { ValidationPipe } from '@nestjs/common'; +import { AdminUsersQueryDto, MAX_LIMIT } from './users-query.dto'; + +const pipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, +}); + +async function transform(input: unknown): Promise { + return (await pipe.transform(input, { + type: 'query', + metatype: AdminUsersQueryDto, + })) as AdminUsersQueryDto; +} + +describe('AdminUsersQueryDto', () => { + it('accepts an empty query', async () => { + await expect(transform({})).resolves.toEqual({}); + }); + + it('coerces numeric strings (URL query format) into numbers', async () => { + const out = await transform({ limit: '25', offset: '50' }); + expect(out.limit).toBe(25); + expect(out.offset).toBe(50); + }); + + it('rejects a limit above MAX_LIMIT', async () => { + await expect(transform({ limit: String(MAX_LIMIT + 1) })).rejects.toThrow(); + }); + + it('rejects a negative offset', async () => { + await expect(transform({ offset: '-1' })).rejects.toThrow(); + }); + + it('accepts a valid audience enum value', async () => { + await expect(transform({ audience: 'workforce' })).resolves.toMatchObject({ + audience: 'workforce', + }); + await expect(transform({ audience: 'customer' })).resolves.toMatchObject({ + audience: 'customer', + }); + }); + + it('rejects an unknown audience', async () => { + await expect(transform({ audience: 'admin' })).rejects.toThrow(); + }); + + it('rejects an ill-formed lastSeenAtFrom', async () => { + await expect(transform({ lastSeenAtFrom: 'yesterday' })).rejects.toThrow(); + }); + + it('rejects an unknown query key (forbidNonWhitelisted)', async () => { + await expect(transform({ password: 'leak' })).rejects.toThrow(); + }); + + it('rejects an over-long username', async () => { + await expect(transform({ username: 'a'.repeat(129) })).rejects.toThrow(); + }); +}); diff --git a/apps/portal-bff/src/admin/users-query.dto.ts b/apps/portal-bff/src/admin/users-query.dto.ts new file mode 100644 index 0000000..632fed5 --- /dev/null +++ b/apps/portal-bff/src/admin/users-query.dto.ts @@ -0,0 +1,73 @@ +import { Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsISO8601, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; + +/** + * Query parameters for `GET /api/admin/users`. Mirrors the + * audit-viewer DTO's pagination posture per ADR-0020 §"User list" — + * offset/limit with hard caps, every filter optional. Bound from + * the URL via Nest's `@Query()` + global ValidationPipe so unknown + * keys are rejected by `forbidNonWhitelisted` before reaching the + * handler. + * + * Filters target the `public.users` directory (PR #140). The + * `username` filter is exact-prefix match (Prisma `startsWith`) + * because audit users typically search by exact known prefix — + * `jane.doe`, `admin`, etc. `displayName` filter uses + * case-insensitive contains since display names have natural + * variation in casing. + */ +export const MAX_LIMIT = 200; +export const DEFAULT_LIMIT = 50; + +const ADMIN_USERS_AUDIENCES = ['workforce', 'customer'] as const; +type AudienceFilter = (typeof ADMIN_USERS_AUDIENCES)[number]; + +export class AdminUsersQueryDto { + /** Exact-prefix match on `username`. */ + @IsOptional() + @IsString() + @MaxLength(128) + username?: string; + + /** Case-insensitive `contains` match on `display_name`. */ + @IsOptional() + @IsString() + @MaxLength(128) + displayName?: string; + + @IsOptional() + @IsEnum(ADMIN_USERS_AUDIENCES) + audience?: AudienceFilter; + + /** ISO-8601 lower bound on `last_seen_at` (inclusive). */ + @IsOptional() + @IsISO8601() + lastSeenAtFrom?: string; + + /** ISO-8601 upper bound on `last_seen_at` (exclusive). */ + @IsOptional() + @IsISO8601() + lastSeenAtTo?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(MAX_LIMIT) + limit?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset?: number; +} diff --git a/apps/portal-bff/src/audit/audit.service.spec.ts b/apps/portal-bff/src/audit/audit.service.spec.ts index 4ae700d..7b495a8 100644 --- a/apps/portal-bff/src/audit/audit.service.spec.ts +++ b/apps/portal-bff/src/audit/audit.service.spec.ts @@ -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(); diff --git a/apps/portal-bff/src/audit/audit.service.ts b/apps/portal-bff/src/audit/audit.service.ts index cdcc184..5c62b6c 100644 --- a/apps/portal-bff/src/audit/audit.service.ts +++ b/apps/portal-bff/src/audit/audit.service.ts @@ -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 { + 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`. diff --git a/apps/portal-bff/src/audit/audit.types.ts b/apps/portal-bff/src/audit/audit.types.ts index 235df5c..818afe7 100644 --- a/apps/portal-bff/src/audit/audit.types.ts +++ b/apps/portal-bff/src/audit/audit.types.ts @@ -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; + 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';