feat(portal-bff): admin user-directory read endpoint (#141)
CI / check (push) Successful in 3m1s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m34s
CI / a11y (push) Successful in 2m35s
CI / perf (push) Successful in 5m7s

## 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:
2026-05-14 20:01:38 +02:00
parent aca9e8d155
commit 1df99cd800
10 changed files with 607 additions and 2 deletions
@@ -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<unknown>>) => Promise.all(promises)),
};
}
async function createSubject(prisma: MockPrisma): Promise<AdminUsersReader> {
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<Record<string, string>>;
};
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({});
});
});
@@ -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<AdminUsersPage> {
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(),
};
}
@@ -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');
});
});
@@ -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<AdminUsersPage> {
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;
}
}
+4 -2
View File
@@ -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 {}
@@ -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<AdminUsersQueryDto> {
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();
});
});
@@ -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;
}
@@ -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`.
+15
View File
@@ -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';