fix(backup): take off-host copies out with the account, and say which keys backups still need

Deleting an account, deleting a managed profile, "Delete all data" and the
admin wipe now write an off-host deletion request in the same transaction
as the deletion. A new job (offhost-backup-purge, kicked straight away and
run nightly as a backstop) lists the bucket once, deletes every
<date>/user-<id>.json.enc object of each pending account and removes the
request only when nothing of the account is left. A refused delete keeps
the request with the bucket's answer and is retried; the off-host card
shows how many deletions are outstanding and why. A copy the nightly run
was writing while the account was deleted is taken back after the upload.

The off-host card also reads the bucket's lifecycle rule and says plainly
when there is none (every nightly copy kept forever) or when the
credential may not read it.

Every stored backup now records the key ids its content was written
under, and every off-host upload records the same per key. The admin
encryption view lists, per key, the stored copies that need it with the
oldest date, and when the last off-host copy needing it expires. The
"safe to drop" badge now requires that no backup still needs a retired
key, instead of only that every row was rotated.

Migration 0358 adds data_backups.inner_key_ids, the key-use table and the
deletion requests.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 19:54:40 +02:00
1 parent 405c996b6a
commit 1c7785c23b
32 files changed
+1590 -42

No files matched your search

+19
View File
@@ -58,6 +58,25 @@ export interface OffhostBackupOverview {
periodHours: number;
/** One row per account. Empty when off-host backup is not configured. */
rows: OffhostAccountRow[];
/**
* Deleted or wiped accounts whose copies are still in the bucket. A count
* above zero with an error means the bucket refused the deletion (most
* often a credential without DeleteObject); the job retries nightly.
*/
pendingDeletions: {
count: number;
oldestRequestedAt: string | null;
lastFailure: string | null;
};
/**
* Whether the bucket retires old copies by itself. Read from the bucket
* (`GetBucketLifecycleConfiguration`), cached for ten minutes; `unknown`
* when the credential may not read it.
*/
lifecycle: {
state: "configured" | "missing" | "unknown";
expirationDays: number | null;
};
}
export interface BackupsList {