Files
HealthLog/src/app/api/admin/encryption/rotate/route.ts
T
Marc-André Bombeck bb24ad5c14 feat(admin): allow a guarded key-rotation run
Add an optional admin-triggered key rotation on pg-boss that re-encrypts
the whole encrypted-column corpus to the active key, reusing the same
column registry as the command-line tool (which stays the canonical path).
POST /api/admin/encryption/rotate enqueues a singleton run and is gated by
cookie-only admin plus a fresh second factor. The run is idempotent
(rows already on the active key are skipped), active-key-only, and never
adds or drops a key — the env key map stays operator-owned, so a key drop
remains a deliberate redeploy. It fails closed per row and reports totals
into the audit trail the status view reads.
2026-06-27 20:27:40 +02:00

74 lines
2.9 KiB
TypeScript

/**
* 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 });
});