mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
feat(admin): replace the key backup step with a recovery kit
The step confirmed one key, the active application key. A restore needs more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy written before a rotation needs the retired application key it was written under, and every session, token and share link hangs off API_TOKEN_HMAC_KEY. An operator could do everything the step asked and still be unable to read a single backup. The recovery kit lists every key a restore of this server needs, by id and fingerprint, never key material: - each id in ENCRYPTION_KEYS, plus any retired id a stored copy (innerKeyIds) or an off-host object inside the bucket's retention (OffhostBackupKeyUse) still lists, also once the server dropped it; - BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry, named by the id their objects record; - API_TOKEN_HMAC_KEY. Confirming records the set the admin was shown and refuses a stale one with 409 recovery.kit.stale. The kit is due again as soon as a key it lists is not in that set (a rotation, a new backup key, a re-keyed install); a key that merely drops out does not make it due. "Check my copy" now checks any key in the kit. Migration 0487 carries a confirmation given under the old step forward as a kit holding that key, so that part stays confirmed, and drops the four app_settings columns. It also creates the two tables the readiness evaluation uses (restore_drill_outcomes, recovery_readiness_notices). The routes move to /api/admin/recovery-kit; the reader of the keys the backups still need moves out of the encryption status route into src/lib/recovery/backup-key-needs.ts so the kit and the status view share one definition.
This commit is contained in:
1 parent
953c64f7a4
commit
d3c672cc3f
56 files changed
+3042
-1611
No files matched your search
+12
-11
@@ -1,38 +1,39 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Dashboard banner for an admin while the encryption key backup step is due.
|
||||
* Not dismissable by design: it goes away when the step is done, and comes
|
||||
* back when the key changes. Renders nothing for anyone who is not an admin,
|
||||
* and asks the server nothing for them either.
|
||||
* Dashboard banner for an admin while the recovery kit is due: never
|
||||
* confirmed, or listing a key the confirmation does not cover (a rotation, a
|
||||
* new backup key). Not dismissable by design: it goes away when the kit is
|
||||
* confirmed, and comes back when a key changes. Renders nothing for anyone
|
||||
* who is not an admin, and asks the server nothing for them either.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useKeyBackupStatus } from "@/components/admin/use-key-backup-status";
|
||||
import { useRecoveryKit } from "@/components/admin/use-recovery-kit";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
|
||||
export function RecoveryKitBanner({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { data } = useKeyBackupStatus(isAdmin);
|
||||
const { data } = useRecoveryKit(isAdmin);
|
||||
if (!isAdmin || !data?.due) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="key-backup-banner"
|
||||
data-testid="recovery-kit-banner"
|
||||
className="border-warning/40 bg-warning/10 flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center"
|
||||
>
|
||||
<KeyRound className="text-warning h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-semibold">
|
||||
{t("dashboard.keyBackupBanner.title")}
|
||||
{t("dashboard.recoveryKitBanner.title")}
|
||||
</p>
|
||||
<p className="text-sm">{t("dashboard.keyBackupBanner.body")}</p>
|
||||
<p className="text-sm">{t("dashboard.recoveryKitBanner.body")}</p>
|
||||
</div>
|
||||
<InlineAction asChild size="sm" className="min-h-11 font-medium">
|
||||
<Link href="/admin/encryption">
|
||||
{t("dashboard.keyBackupBanner.action")}
|
||||
{t("dashboard.recoveryKitBanner.action")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</div>
|
||||
Reference in new issue
Block a user