Flow H, three screens behind a role check: find an account, work the ingestion error queue, read and export the audit log. Superadmins can change a role, never their own. The error queue merges ingest errors and dead jobs into one table with a cursor that pages both sources; only a job can be retried and only an ingest row resolved, with a note that migration 003 gives it somewhere to live. writeAudit no longer defaults a missing subject to the actor, which had been recording a user search as staff looking themselves up. Omitting the subject still means acting on yourself; null now means the action has no subject, which is what a search, a retry and an export are. Reading the log is not audited. Exporting it is: a copy leaving the building is a different act from looking. e2e/global-setup.ts asks for every screen once before the suite starts, so a dev server's first-request compile is paid before the first test rather than by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
6.4 KiB
TypeScript
225 lines
6.4 KiB
TypeScript
import {
|
|
AdminAuditQuery,
|
|
AdminErrorListQuery,
|
|
ResolveErrorInput,
|
|
RoleChangeInput,
|
|
} from '@impuestos/contracts';
|
|
import { Hono } from 'hono';
|
|
import type { z } from 'zod';
|
|
import { ADMIN_ROLES } from '../../auth/options';
|
|
import {
|
|
auditRowsForExport,
|
|
changeRole,
|
|
listAdminErrors,
|
|
listAudit,
|
|
resolveIngestError,
|
|
retryJob,
|
|
searchUsers,
|
|
toCsv,
|
|
userOverview,
|
|
} from '../../modules/admin';
|
|
import { type AuditAction, writeAudit } from '../../modules/audit';
|
|
import type { AppDeps, AppEnv, SessionUser } from '../context';
|
|
import { HttpError } from '../errors';
|
|
import { requireRole } from '../middleware';
|
|
|
|
const SUPERADMIN_ONLY = ['superadmin'] as const;
|
|
|
|
/**
|
|
* FLOWS.md Flow H. Two rules hold for every handler here: the role is checked in the
|
|
* handler and not only at the router, and anything that reads or changes a user's data
|
|
* writes an audit row before it answers.
|
|
*/
|
|
export function adminRoutes(deps: AppDeps): Hono<AppEnv> {
|
|
const routes = new Hono<AppEnv>();
|
|
const db = deps.handle.db;
|
|
|
|
routes.get('/users/search', async (c) => {
|
|
const staff = requireRole(c, ADMIN_ROLES);
|
|
const term = c.req.query('q') ?? '';
|
|
const items = await searchUsers(db, term);
|
|
|
|
await audit(c, deps, staff, {
|
|
action: 'admin.user_search',
|
|
subjectUserId: null,
|
|
resource: 'user',
|
|
detail: { q: term, results: items.length },
|
|
});
|
|
|
|
return c.json({ items });
|
|
});
|
|
|
|
routes.get('/users/:id/overview', async (c) => {
|
|
const staff = requireRole(c, ADMIN_ROLES);
|
|
const id = c.req.param('id');
|
|
const overview = await userOverview(db, id);
|
|
if (!overview) throw new HttpError('not_found');
|
|
|
|
// CONTRACTS.md section 5.5 pins this: one view, exactly one `admin.user_lookup` row.
|
|
await audit(c, deps, staff, {
|
|
action: 'admin.user_lookup',
|
|
subjectUserId: id,
|
|
resource: `user/${id}`,
|
|
});
|
|
|
|
return c.json(overview);
|
|
});
|
|
|
|
routes.post('/users/:id/role', async (c) => {
|
|
const actor = requireRole(c, SUPERADMIN_ONLY);
|
|
const id = c.req.param('id');
|
|
const input = parse(RoleChangeInput, await body(c));
|
|
|
|
const result = await changeRole(db, {
|
|
actorUserId: actor.id,
|
|
targetUserId: id,
|
|
role: input.role,
|
|
});
|
|
if (!result.ok) {
|
|
if (result.reason === 'not_found') throw new HttpError('not_found');
|
|
throw new HttpError('conflict', { field: 'role', detail: { reason: result.reason } });
|
|
}
|
|
|
|
await audit(c, deps, actor, {
|
|
action: 'admin.role_change',
|
|
subjectUserId: id,
|
|
resource: `user/${id}`,
|
|
detail: { from: result.previous, to: input.role },
|
|
});
|
|
|
|
return c.json({ ok: true } as const);
|
|
});
|
|
|
|
routes.get('/errors', async (c) => {
|
|
requireRole(c, ADMIN_ROLES);
|
|
const query = parse(AdminErrorListQuery, {
|
|
stage: c.req.query('stage'),
|
|
status: c.req.query('status'),
|
|
cursor: c.req.query('cursor'),
|
|
});
|
|
return c.json(await listAdminErrors(db, query));
|
|
});
|
|
|
|
routes.post('/errors/:id/resolve', async (c) => {
|
|
const staff = requireRole(c, ADMIN_ROLES);
|
|
const id = c.req.param('id');
|
|
const input = parse(ResolveErrorInput, await body(c));
|
|
|
|
const result = await resolveIngestError(db, id, { userId: staff.id, note: input.note });
|
|
if (!result.ok) {
|
|
if (result.reason === 'not_found') throw new HttpError('not_found');
|
|
throw new HttpError('conflict', { detail: { reason: result.reason } });
|
|
}
|
|
|
|
await audit(c, deps, staff, {
|
|
action: 'admin.error_resolve',
|
|
subjectUserId: null,
|
|
resource: `ingest_errors/${id}`,
|
|
detail: { note: input.note },
|
|
});
|
|
|
|
return c.json({ ok: true } as const);
|
|
});
|
|
|
|
routes.post('/jobs/:id/retry', async (c) => {
|
|
const staff = requireRole(c, ADMIN_ROLES);
|
|
const id = c.req.param('id');
|
|
|
|
const result = await retryJob(db, id);
|
|
if (!result.ok) {
|
|
if (result.reason === 'not_found') throw new HttpError('not_found');
|
|
throw new HttpError('conflict', { detail: { reason: result.reason } });
|
|
}
|
|
|
|
await audit(c, deps, staff, {
|
|
action: 'admin.job_retry',
|
|
subjectUserId: null,
|
|
resource: `jobs/${id}`,
|
|
});
|
|
|
|
return c.json({ ok: true } as const);
|
|
});
|
|
|
|
/**
|
|
* Reading the log is not itself audited. A row for every scroll of the audit screen
|
|
* would bury the accesses that matter under the act of looking for them. Taking a copy
|
|
* out of the building is a different thing, so the CSV export below is audited.
|
|
*/
|
|
routes.get('/audit', async (c) => {
|
|
requireRole(c, ADMIN_ROLES);
|
|
return c.json(await listAudit(db, auditQuery(c)));
|
|
});
|
|
|
|
routes.get('/audit/export.csv', async (c) => {
|
|
const staff = requireRole(c, ADMIN_ROLES);
|
|
const query = auditQuery(c);
|
|
const rows = await auditRowsForExport(db, query);
|
|
|
|
await audit(c, deps, staff, {
|
|
action: 'admin.audit_export',
|
|
subjectUserId: null,
|
|
resource: 'audit_log',
|
|
detail: { rows: rows.length, ...query },
|
|
});
|
|
|
|
return new Response(toCsv(rows), {
|
|
headers: {
|
|
'content-type': 'text/csv; charset=utf-8',
|
|
'content-disposition': `attachment; filename="audit-${new Date().toISOString().slice(0, 10)}.csv"`,
|
|
},
|
|
});
|
|
});
|
|
|
|
return routes;
|
|
}
|
|
|
|
function auditQuery(c: { req: { query: (name: string) => string | undefined } }): AdminAuditQuery {
|
|
return parse(AdminAuditQuery, {
|
|
actor: c.req.query('actor'),
|
|
action: c.req.query('action'),
|
|
subject: c.req.query('subject'),
|
|
from: c.req.query('from'),
|
|
to: c.req.query('to'),
|
|
cursor: c.req.query('cursor'),
|
|
});
|
|
}
|
|
|
|
async function body(c: { req: { json: () => Promise<unknown> } }): Promise<unknown> {
|
|
try {
|
|
return await c.req.json();
|
|
} catch {
|
|
throw new HttpError('validation_error');
|
|
}
|
|
}
|
|
|
|
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
|
|
const result = schema.safeParse(value);
|
|
if (!result.success) {
|
|
const issue = result.error.issues[0];
|
|
throw new HttpError('validation_error', {
|
|
...(issue?.path.length ? { field: issue.path.join('.') } : {}),
|
|
detail: result.error.issues,
|
|
});
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
function audit(
|
|
c: { req: { header: (name: string) => string | undefined } },
|
|
deps: AppDeps,
|
|
actor: SessionUser,
|
|
entry: {
|
|
action: AuditAction;
|
|
subjectUserId: string | null;
|
|
resource: string;
|
|
detail?: Record<string, unknown>;
|
|
},
|
|
): Promise<void> {
|
|
return writeAudit(deps.handle.db, {
|
|
actorUserId: actor.id,
|
|
actorRole: actor.role,
|
|
ip: c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null,
|
|
...entry,
|
|
});
|
|
}
|