feat(portal-bff): admin user-directory read endpoint
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.
This commit is contained in:
@@ -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,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`.
|
||||
|
||||
@@ -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