mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
405c996b6a
commit
1c7785c23b
32 files changed
+1590
-42
No files matched your search
@@ -23,6 +23,11 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { OffhostBackupsSection } from "../offhost-backups-section";
|
||||
|
||||
const QUIET = {
|
||||
pendingDeletions: { count: 0, oldestRequestedAt: null, lastFailure: null },
|
||||
lifecycle: { state: "configured" as const, expirationDays: 30 },
|
||||
};
|
||||
|
||||
function render(data: BackupsList["offhost"] | null): string {
|
||||
queryResult.current = {
|
||||
data:
|
||||
@@ -46,7 +51,12 @@ function render(data: BackupsList["offhost"] | null): string {
|
||||
|
||||
describe("<OffhostBackupsSection>", () => {
|
||||
it("says off-host backup is not configured rather than showing an empty list", () => {
|
||||
const html = render({ configured: false, periodHours: 24, rows: [] });
|
||||
const html = render({
|
||||
configured: false,
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
...QUIET,
|
||||
});
|
||||
expect(html).toContain("Off-host backup is not configured");
|
||||
expect(html).not.toContain('data-slot="offhost-backup-rows"');
|
||||
});
|
||||
@@ -55,6 +65,7 @@ describe("<OffhostBackupsSection>", () => {
|
||||
const html = render({
|
||||
configured: true,
|
||||
periodHours: 24,
|
||||
...QUIET,
|
||||
rows: [
|
||||
{
|
||||
userId: "u1",
|
||||
@@ -109,4 +120,33 @@ describe("<OffhostBackupsSection>", () => {
|
||||
// Never the bucket's own coordinates.
|
||||
expect(html).not.toMatch(/secret|access[- ]key/i);
|
||||
});
|
||||
|
||||
it("says when the bucket keeps copies forever, and when deletions are stuck", () => {
|
||||
const html = render({
|
||||
configured: true,
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
pendingDeletions: {
|
||||
count: 2,
|
||||
oldestRequestedAt: "2026-09-20T10:00:00.000Z",
|
||||
lastFailure: "AccessDenied",
|
||||
},
|
||||
lifecycle: { state: "missing", expirationDays: null },
|
||||
});
|
||||
expect(html).toContain('data-lifecycle-state="missing"');
|
||||
expect(html).toContain("no lifecycle rule");
|
||||
expect(html).toContain('data-slot="offhost-pending-deletions"');
|
||||
expect(html).toContain("AccessDenied");
|
||||
});
|
||||
|
||||
it("states the expiry the bucket enforces", () => {
|
||||
const html = render({
|
||||
configured: true,
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
...QUIET,
|
||||
});
|
||||
expect(html).toContain("after 30 days");
|
||||
expect(html).not.toContain('data-slot="offhost-pending-deletions"');
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,13 @@
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2, RotateCw, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
Archive,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -32,7 +38,7 @@ import { StatTile } from "@/components/admin/_stat-tile";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
@@ -60,10 +66,25 @@ interface EncryptionStatus {
|
||||
lastCompletedAt: string | null;
|
||||
lastResult: { scanned: number; rotated: number; errors: number } | null;
|
||||
};
|
||||
/** Which keys the backups still need; see the status route. */
|
||||
backups: {
|
||||
stored: Array<{ keyId: string; copies: number; oldestAt: string }>;
|
||||
unrecorded: { copies: number; oldestAt: string | null };
|
||||
offhost: Array<{
|
||||
keyId: string;
|
||||
firstWrittenAt: string;
|
||||
lastWrittenAt: string;
|
||||
neededUntil: string | null;
|
||||
}>;
|
||||
offhostExpirationDays: number | null;
|
||||
retiredKeysStillNeeded: string[];
|
||||
};
|
||||
safeToDropRetiredKeys: boolean;
|
||||
}
|
||||
|
||||
export function EncryptionSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
@@ -166,10 +187,20 @@ export function EncryptionSection() {
|
||||
value={s.staleRows.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
{s.rotationComplete ? (
|
||||
{s.safeToDropRetiredKeys ? (
|
||||
<Badge className="border-success/40 bg-success/15 text-success">
|
||||
{t("admin.section.encryption.safeToDropLegacy")}
|
||||
</Badge>
|
||||
) : s.rotationComplete ? (
|
||||
// Every row is on the active key, and that is not the whole
|
||||
// answer: a backup keeps the key its content was written under.
|
||||
<Badge variant="secondary" data-slot="encryption-backups-need-keys">
|
||||
{s.backups.retiredKeysStillNeeded.length > 0
|
||||
? t("admin.section.encryption.backupsStillNeed", {
|
||||
keys: s.backups.retiredKeysStillNeeded.join(", "),
|
||||
})
|
||||
: t("admin.section.encryption.backupsUnrecordedBadge")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
{t("admin.section.encryption.rotationIncomplete", {
|
||||
@@ -179,6 +210,56 @@ export function EncryptionSection() {
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Keys the backups still need ───────────────────────────── */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Archive}
|
||||
title={t("admin.section.encryption.backupsTitle")}
|
||||
description={t("admin.section.encryption.backupsDescription")}
|
||||
/>
|
||||
<p className="text-sm">{t("admin.section.encryption.backupsDetail")}</p>
|
||||
<ul className="space-y-1 text-sm" data-slot="encryption-backup-keys">
|
||||
{s.backups.stored.map((row) => (
|
||||
<li key={`stored-${row.keyId}`}>
|
||||
{t("admin.section.encryption.backupsStoredLine", {
|
||||
key: row.keyId,
|
||||
count: row.copies,
|
||||
when: fmt.dateTime(row.oldestAt),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
{s.backups.unrecorded.copies > 0 && s.backups.unrecorded.oldestAt ? (
|
||||
<li>
|
||||
{t("admin.section.encryption.backupsUnrecordedLine", {
|
||||
count: s.backups.unrecorded.copies,
|
||||
when: fmt.dateTime(s.backups.unrecorded.oldestAt),
|
||||
})}
|
||||
</li>
|
||||
) : null}
|
||||
{s.backups.offhost.map((row) => (
|
||||
<li key={`offhost-${row.keyId}`}>
|
||||
{row.neededUntil
|
||||
? t("admin.section.encryption.backupsOffhostLine", {
|
||||
key: row.keyId,
|
||||
when: fmt.dateTime(row.lastWrittenAt),
|
||||
until: fmt.dateTime(row.neededUntil),
|
||||
})
|
||||
: t("admin.section.encryption.backupsOffhostLineUnknown", {
|
||||
key: row.keyId,
|
||||
when: fmt.dateTime(row.lastWrittenAt),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
{s.backups.stored.length === 0 &&
|
||||
s.backups.unrecorded.copies === 0 &&
|
||||
s.backups.offhost.length === 0 ? (
|
||||
<li className="text-muted-foreground">
|
||||
{t("admin.section.encryption.backupsNone")}
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Rotation status + trigger ──────────────────────────────── */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
* been refusing the worker's signature since March.
|
||||
*
|
||||
* Every verdict is computed server-side from the ledger the worker writes. The
|
||||
* page never lists the bucket: the worker's grant is deliberately PutObject,
|
||||
* GetObject and AbortMultipartUpload — the three the runbook documents, the
|
||||
* abort being what the multipart path needs — and putting a credentialed
|
||||
* listing call on the render path of an admin page would be a poor trade for
|
||||
* a number the database already holds.
|
||||
* page never lists the bucket: putting a credentialed listing call on the
|
||||
* render path of an admin page would be a poor trade for a number the database
|
||||
* already holds. It does read the bucket's lifecycle rule (one small call,
|
||||
* cached for ten minutes on the server), because whether old copies expire at
|
||||
* all is something only the bucket knows, and it states deletions of deleted
|
||||
* or wiped accounts that the bucket has not carried out yet.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -191,6 +192,35 @@ export function OffhostBackupsSection() {
|
||||
hours: offhost.periodHours,
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs"
|
||||
data-slot="offhost-lifecycle"
|
||||
data-lifecycle-state={offhost.lifecycle.state}
|
||||
>
|
||||
{offhost.lifecycle.state === "configured"
|
||||
? t("admin.section.backups.offhost.lifecycleConfigured", {
|
||||
days: offhost.lifecycle.expirationDays ?? 0,
|
||||
})
|
||||
: offhost.lifecycle.state === "missing"
|
||||
? t("admin.section.backups.offhost.lifecycleMissing")
|
||||
: t("admin.section.backups.offhost.lifecycleUnknown")}
|
||||
</p>
|
||||
{offhost.pendingDeletions.count > 0 ? (
|
||||
<p
|
||||
role="status"
|
||||
className="text-xs"
|
||||
data-slot="offhost-pending-deletions"
|
||||
>
|
||||
{offhost.pendingDeletions.lastFailure
|
||||
? t("admin.section.backups.offhost.pendingDeletionsFailing", {
|
||||
count: offhost.pendingDeletions.count,
|
||||
error: offhost.pendingDeletions.lastFailure,
|
||||
})
|
||||
: t("admin.section.backups.offhost.pendingDeletions", {
|
||||
count: offhost.pendingDeletions.count,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
|
||||
Reference in new issue
Block a user