merge: integrate the v1.23 data export and encryption-status wave

This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:46:08 +02:00
commit 268c5cef05
29 files changed
+2346 -125

No files matched your search

@@ -0,0 +1,73 @@
/**
* POST /api/admin/encryption/rotate
*
* v1.23 — admin-triggered encryption-key rotation. Enqueues the
* `encryption-key-rotate` pg-boss job, which re-encrypts the whole encrypted-
* column corpus to the configured ACTIVE key id. The CLI
* (`scripts/rotate-encryption-key.ts`) stays the canonical path; this is a
* convenience that reuses the same column registry and is safe by construction:
*
* - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: the job only writes the active
* key and never touches `ENCRYPTION_KEYS`. Dropping a key remains an env +
* redeploy act.
* - IDEMPOTENT: rows already on the active key are skipped; the singleton key
* coalesces duplicate triggers into one queued run.
*
* Auth: cookie-only `requireAdmin` (Bearer can never reach admin) PLUS
* `requireFreshMfa` step-up — re-encrypting the whole sensitive corpus is a
* high-blast-radius action. An admin without a second factor cannot use the
* button and falls back to the CLI (this is intentional: step-up cannot be
* softened, and the CLI is the documented canonical path).
*/
import {
apiHandler,
requireAdmin,
requireFreshMfa,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
ENCRYPTION_KEY_ROTATE_QUEUE,
ENCRYPTION_KEY_ROTATE_SINGLETON,
} from "@/lib/jobs/encryption-key-rotate";
import { getActiveKeyId } from "@/lib/crypto";
import { NextRequest } from "next/server";
export const POST = apiHandler(async (request: NextRequest) => {
// Cookie-only admin, then a fresh second factor. Both resolution paths are
// cookie-only, so a Bearer token cannot reach either.
await requireAdmin();
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
annotate({ action: { name: "admin.encryption.rotate.requested" } });
const boss = getGlobalBoss();
if (!boss) {
return apiError("Background worker is not available", 503);
}
const jobId = await boss.send(
ENCRYPTION_KEY_ROTATE_QUEUE,
{ requestedByUserId: user.id, enqueuedAt: new Date().toISOString() },
{
// Coalesce duplicate triggers: at most one queued run at a time.
singletonKey: ENCRYPTION_KEY_ROTATE_SINGLETON,
retryLimit: 2,
retryDelay: 60,
},
);
await auditLog("admin.encryption.rotate.requested", {
userId: user.id,
ipAddress: getClientIp(request),
details: { activeKeyId: getActiveKeyId(), enqueued: Boolean(jobId) },
});
annotate({ meta: { rotate_enqueued: Boolean(jobId) } });
// A null jobId means the singleton coalesced this trigger onto an already-
// queued run — still "accepted" from the operator's point of view.
return apiSuccess({ accepted: true, alreadyQueued: !jobId });
});
@@ -0,0 +1,137 @@
/**
* 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,
});
});