phase-6: the staff console, and a log that says who did what
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6620650e9e
commit
4c39926483
@@ -0,0 +1,224 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user