mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
138 lines
4.1 KiB
TypeScript
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,
|
|
});
|
|
});
|