mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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 });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user