From bb24ad5c149028fe6613320b128447558413f134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:27:40 +0200 Subject: [PATCH] feat(admin): allow a guarded key-rotation run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/admin/encryption/rotate/route.ts | 73 +++++++++++++++ src/lib/jobs/encryption-key-rotate.ts | 92 +++++++++++++++++++ src/lib/jobs/reminder/register-maintenance.ts | 18 ++++ 3 files changed, 183 insertions(+) create mode 100644 src/app/api/admin/encryption/rotate/route.ts create mode 100644 src/lib/jobs/encryption-key-rotate.ts diff --git a/src/app/api/admin/encryption/rotate/route.ts b/src/app/api/admin/encryption/rotate/route.ts new file mode 100644 index 000000000..0b6c23bbc --- /dev/null +++ b/src/app/api/admin/encryption/rotate/route.ts @@ -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 }); +}); diff --git a/src/lib/jobs/encryption-key-rotate.ts b/src/lib/jobs/encryption-key-rotate.ts new file mode 100644 index 000000000..be6652341 --- /dev/null +++ b/src/lib/jobs/encryption-key-rotate.ts @@ -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[], +) { + 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; + } + }); +} diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index b5ca7874e..3e3b7c765 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -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( + ENCRYPTION_KEY_ROTATE_QUEUE, + { localConcurrency: ENCRYPTION_KEY_ROTATE_CONCURRENCY }, + handleEncryptionKeyRotate, + ); + return allQueues; }