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 { const routes = new Hono(); 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 } }): Promise { try { return await c.req.json(); } catch { throw new HttpError('validation_error'); } } function parse(schema: z.ZodType, 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; }, ): Promise { return writeAudit(deps.handle.db, { actorUserId: actor.id, actorRole: actor.role, ip: c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null, ...entry, }); }