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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:27:40 +02:00
1 parent e5f9fb7c4a
commit bb24ad5c14
3 files changed
+183

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 });
});
+92
View File
@@ -0,0 +1,92 @@
/**
* v1.23 — admin-triggered encryption-key rotation, on pg-boss.
*
* Re-encrypts every registered encrypted column to the configured ACTIVE key
* id, reusing the canonical column registry via `rotateCorpus()`. This is the
* in-app convenience the admin panel triggers; the CLI
* (`scripts/rotate-encryption-key.ts`) remains the documented canonical path.
* pg-boss is the right home because the standalone production image strips
* `tsx`, so a button that ran the CLI inside the container would fail.
*
* SAFETY (security-review surface):
* - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: `rotateCorpus()` only ever
* writes the active key and never touches the env key map. Dropping a key
* stays an operator env + redeploy act.
* - IDEMPOTENT: rows already on the active key are skipped, so re-running (or
* the singleton coalescing a duplicate trigger) re-encrypts nothing.
* - FAIL-CLOSED per row: a row under a no-longer-configured key is counted as
* an error and left untouched, never dropped.
*
* On-demand only — no cron. The admin POST enqueues with a fixed singletonKey
* so two concurrent triggers collapse into one run. Completion is recorded both
* as a background wide-event and an `admin.encryption.rotate.completed` audit
* entry that the status view reads to show the last run.
*/
import { type Job } from "pg-boss";
import { withBackgroundEvent } from "@/lib/logging/background";
import { auditLog } from "@/lib/auth/audit";
import {
rotateCorpus,
type CorpusClient,
} from "@/lib/crypto/encryption-corpus";
import { getWorkerPrisma } from "@/lib/jobs/reminder/shared";
export const ENCRYPTION_KEY_ROTATE_QUEUE = "encryption-key-rotate";
export const ENCRYPTION_KEY_ROTATE_CONCURRENCY = 1;
/** Fixed key so duplicate admin triggers coalesce into one queued run. */
export const ENCRYPTION_KEY_ROTATE_SINGLETON = "encryption-key-rotate";
export interface EncryptionKeyRotatePayload {
/** The admin who triggered the run (for the audit trail). */
requestedByUserId?: string;
enqueuedAt?: string;
}
export async function runEncryptionKeyRotation(): Promise<{
activeKeyId: string;
totalScanned: number;
totalRotated: number;
totalErrors: number;
}> {
const prisma = getWorkerPrisma();
const out = await rotateCorpus(prisma as unknown as CorpusClient);
return {
activeKeyId: out.activeKeyId,
totalScanned: out.totalScanned,
totalRotated: out.totalRotated,
totalErrors: out.totalErrors,
};
}
export async function handleEncryptionKeyRotate(
jobs: Job<EncryptionKeyRotatePayload>[],
) {
await withBackgroundEvent("job.encryption_key_rotate", async (evt) => {
const requestedBy = jobs[0]?.data?.requestedByUserId ?? null;
try {
const result = await runEncryptionKeyRotation();
evt.addMeta("rotate_active_key_id", result.activeKeyId);
evt.addMeta("rotate_scanned", result.totalScanned);
evt.addMeta("rotate_rotated", result.totalRotated);
evt.addMeta("rotate_errors", result.totalErrors);
await auditLog("admin.encryption.rotate.completed", {
userId: requestedBy,
details: {
activeKeyId: result.activeKeyId,
scanned: result.totalScanned,
rotated: result.totalRotated,
errors: result.totalErrors,
},
});
} catch (err) {
evt.addWarning(`encryption-key-rotate failed: ${err}`);
await auditLog("admin.encryption.rotate.failed", {
userId: requestedBy,
details: { message: err instanceof Error ? err.message : String(err) },
});
// Re-throw so pg-boss records the failure (the run is idempotent, so a
// retry is safe).
throw err;
}
});
}
@@ -62,6 +62,12 @@ import {
enqueueBootTimeNoteEncryptionBackfill,
type NoteEncryptionBackfillPayload,
} from "@/lib/jobs/note-encryption-backfill";
import {
ENCRYPTION_KEY_ROTATE_QUEUE,
ENCRYPTION_KEY_ROTATE_CONCURRENCY,
handleEncryptionKeyRotate,
type EncryptionKeyRotatePayload,
} from "@/lib/jobs/encryption-key-rotate";
import { recordError } from "@/lib/jobs/worker-status";
import { workerLog } from "./shared";
import { createAndSchedule, type ScheduleEntry } from "./registrar-shared";
@@ -228,6 +234,11 @@ const allQueues = [
// plaintext note; without this entry pg-boss never provisions the queue and
// both the boot enqueue and the cron silently no-op.
NOTE_ENCRYPTION_BACKFILL_QUEUE,
// v1.23 — admin-triggered encryption-key rotation. On-demand only (no
// cron): the admin panel enqueues a singleton run that re-encrypts the
// corpus to the active key. Without this entry pg-boss never provisions the
// queue and the admin trigger silently no-ops.
ENCRYPTION_KEY_ROTATE_QUEUE,
];
const schedules: ScheduleEntry[] = [
@@ -490,6 +501,13 @@ export async function registerMaintenanceQueues(
},
);
// v1.23 — admin-triggered encryption-key rotation (on-demand, singleton).
await boss.work<EncryptionKeyRotatePayload>(
ENCRYPTION_KEY_ROTATE_QUEUE,
{ localConcurrency: ENCRYPTION_KEY_ROTATE_CONCURRENCY },
handleEncryptionKeyRotate,
);
return allQueues;
}