Files
HealthLog/src/app/api/admin/encryption/status/route.ts
T
Marc-André Bombeck e5f9fb7c4a feat(admin): show encryption coverage and rotation progress
Add a read-only admin Encryption section that surfaces what the rotation
tooling computes: per encrypted-column rows-per-key-id, legacy-key row
counts, and overall rotation progress, plus a 'safe to drop the legacy
key' state once every column sits on the active key. GET
/api/admin/encryption/status drives it and returns only key ids and
counts — never key material. A shared scan over the canonical encrypted-
column registry backs both this view and the rotation run. Cookie-only
admin.
2026-06-27 20:27:31 +02:00

138 lines
4.1 KiB
TypeScript

/**
* GET /api/admin/encryption/status
*
* v1.23 — read-only admin view of encryption coverage + rotation progress.
* Buckets every registered encrypted column's rows by key id (the same scan the
* rotation tooling does), so an operator can SEE whether a rotation finished
* before dropping a legacy key, instead of guessing.
*
* Returns ONLY key IDS (operator-chosen labels like `v1` / `v2`) and ROW
* COUNTS — never key material. The configured-key set is surfaced as a count.
*
* Auth: cookie-only `requireAdmin` (a Bearer token can never reach admin). The
* scan is on-demand with a short in-process cache so repeated panel renders
* don't re-scan the corpus each time; the rotation run state is read from the
* audit trail (DB-backed, so it is correct across the web/worker processes).
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getConfiguredKeyIds } from "@/lib/crypto";
import {
scanCorpus,
type CorpusClient,
type CorpusScan,
} from "@/lib/crypto/encryption-corpus";
const SCAN_CACHE_TTL_MS = 15_000;
let scanCache: { at: number; scan: CorpusScan } | null = null;
interface RotationState {
state: "idle" | "running" | "completed" | "failed";
lastRequestedAt: string | null;
lastCompletedAt: string | null;
lastResult: { scanned: number; rotated: number; errors: number } | null;
}
async function readRotationState(): Promise<RotationState> {
const rows = await prisma.auditLog.findMany({
where: {
action: {
in: [
"admin.encryption.rotate.requested",
"admin.encryption.rotate.completed",
"admin.encryption.rotate.failed",
],
},
},
orderBy: { createdAt: "desc" },
take: 10,
select: { action: true, createdAt: true, details: true },
});
const requested = rows.find(
(r) => r.action === "admin.encryption.rotate.requested",
);
const finished = rows.find(
(r) =>
r.action === "admin.encryption.rotate.completed" ||
r.action === "admin.encryption.rotate.failed",
);
let lastResult: RotationState["lastResult"] = null;
if (
finished?.action === "admin.encryption.rotate.completed" &&
finished.details
) {
try {
const d = JSON.parse(finished.details) as {
scanned?: number;
rotated?: number;
errors?: number;
};
lastResult = {
scanned: d.scanned ?? 0,
rotated: d.rotated ?? 0,
errors: d.errors ?? 0,
};
} catch {
lastResult = null;
}
}
// "running" iff the most recent request is newer than the most recent
// completion/failure.
let state: RotationState["state"] = "idle";
if (requested && (!finished || requested.createdAt > finished.createdAt)) {
state = "running";
} else if (finished?.action === "admin.encryption.rotate.completed") {
state = "completed";
} else if (finished?.action === "admin.encryption.rotate.failed") {
state = "failed";
}
return {
state,
lastRequestedAt: requested?.createdAt.toISOString() ?? null,
lastCompletedAt: finished?.createdAt.toISOString() ?? null,
lastResult,
};
}
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.encryption.status" } });
const now = Date.now();
let scan: CorpusScan;
if (scanCache && now - scanCache.at < SCAN_CACHE_TTL_MS) {
scan = scanCache.scan;
} else {
scan = await scanCorpus(prisma as unknown as CorpusClient);
scanCache = { at: now, scan };
}
const rotation = await readRotationState();
annotate({
meta: {
encryption_total_rows: scan.totalRows,
encryption_stale_rows: scan.staleRows,
encryption_rotation_complete: scan.rotationComplete,
},
});
return apiSuccess({
activeKeyId: scan.activeKeyId,
// COUNT only — never the key material.
configuredKeyCount: getConfiguredKeyIds().length,
rotationComplete: scan.rotationComplete,
totalRows: scan.totalRows,
activeRows: scan.activeRows,
staleRows: scan.staleRows,
columns: scan.columns,
rotation,
});
});