fix(recovery): the database dumps count in the keys the backups still need

The recovery kit, "Keys the backups still need" and safeToDropRetiredKeys
read only the stored copies and the off-host account objects. After a
rotation the page called the old key safe to drop while every dump taken
before it, the pre-migration dumps that are the way back from an update
among them, could only be restored with that key: db-restore refuses a dump
whose active key is missing.

A dump now counts for the key its rows were written under, the manifest's
active key: a dump on this server while it is kept (read from the manifests),
an off-host one until the bucket's rule for database/ expires it, or
indefinitely when that rule cannot be read. The nightly run records the key
on its ledger row (migration 0489, database_backup_runs.active_key_id). The
encryption view and the kit list the dumps per key, in every locale, and the
rotation and backup runbooks say so.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 17:20:16 +01:00
1 parent 40c310f0ad
commit 26a9efdf52
24 files changed
+422 -45

No files matched your search

@@ -44,6 +44,7 @@ function statusWith(configuredKeyCount: number) {
stored: [],
unrecorded: { copies: 0, oldestAt: null },
offhost: [],
dumps: [],
offhostExpirationDays: null,
retiredKeysStillNeeded: [],
},
@@ -40,6 +40,8 @@ const need = (over: Partial<KitKey["need"]> = {}): KitKey["need"] => ({
configured: true,
storedCopies: 0,
offhostUntil: null,
localDumps: 0,
offhostDumpsUntil: null,
...over,
});
+36 -1
View File
@@ -88,6 +88,11 @@ interface EncryptionStatus {
lastWrittenAt: string;
neededUntil: string | null;
}>;
dumps: Array<{
keyId: string;
local: number;
offhost: { lastWrittenAt: string; neededUntil: string | null } | null;
}>;
offhostExpirationDays: number | null;
retiredKeysStillNeeded: string[];
};
@@ -306,9 +311,39 @@ export function EncryptionSection() {
})}
</li>
))}
{s.backups.dumps.map((row) => (
<li key={`dump-${row.keyId}`}>
{[
row.local > 0
? t("admin.section.encryption.backupsDumpLocalLine", {
key: row.keyId,
count: row.local,
})
: null,
row.offhost
? row.offhost.neededUntil
? t("admin.section.encryption.backupsDumpOffhostLine", {
key: row.keyId,
when: fmt.dateTime(row.offhost.lastWrittenAt),
until: fmt.dateTime(row.offhost.neededUntil),
})
: t(
"admin.section.encryption.backupsDumpOffhostLineUnknown",
{
key: row.keyId,
when: fmt.dateTime(row.offhost.lastWrittenAt),
},
)
: null,
]
.filter((line): line is string => line !== null)
.join(" ")}
</li>
))}
{s.backups.stored.length === 0 &&
s.backups.unrecorded.copies === 0 &&
s.backups.offhost.length === 0 ? (
s.backups.offhost.length === 0 &&
s.backups.dumps.length === 0 ? (
<li className="text-muted-foreground">
{t("admin.section.encryption.backupsNone")}
</li>
@@ -138,6 +138,20 @@ function KeyNeeds({ k }: { k: KitKey }) {
}),
);
}
if (k.need.localDumps > 0) {
lines.push(
t("admin.recoveryKit.need.localDumps", { count: k.need.localDumps }),
);
}
if (k.need.offhostDumpsUntil === "unknown") {
lines.push(t("admin.recoveryKit.need.offhostDumpsUnknown"));
} else if (k.need.offhostDumpsUntil) {
lines.push(
t("admin.recoveryKit.need.offhostDumpsUntil", {
date: fmt.date(new Date(k.need.offhostDumpsUntil)),
}),
);
}
}
lines.push(t("admin.recoveryKit.source", { variable: k.source }));
return <p className="text-muted-foreground text-xs">{lines.join(" ")}</p>;