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
@@ -29,12 +29,23 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: (...a: unknown[]) => queryRawMock(...a),
|
||||
user: { findMany: (...a: unknown[]) => findManyMock(...a) },
|
||||
offhostPurgeRequest: {
|
||||
count: async () => 2,
|
||||
findFirst: async () => ({
|
||||
requestedAt: new Date("2026-09-20T10:00:00.000Z"),
|
||||
lastFailure: "AccessDenied",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const configuredMock = vi.fn(() => true);
|
||||
vi.mock("@/lib/jobs/offhost-backup", () => ({
|
||||
offhostBackupConfigured: () => configuredMock(),
|
||||
probeOffhostLifecycle: async () => ({
|
||||
state: "missing",
|
||||
expirationDays: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
@@ -139,10 +150,12 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
|
||||
const body = await read();
|
||||
|
||||
expect(body.offhost).toEqual({
|
||||
expect(body.offhost).toMatchObject({
|
||||
configured: false,
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
// No bucket to ask on a host that is not configured.
|
||||
lifecycle: { state: "unknown", expirationDays: null },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,4 +209,20 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
]);
|
||||
expect(JSON.stringify(body.offhost)).not.toMatch(/bucket|endpoint|secret/i);
|
||||
});
|
||||
|
||||
it("carries the bucket's lifecycle verdict and the deletions it still owes", async () => {
|
||||
configuredMock.mockReturnValue(true);
|
||||
queryRawMock.mockResolvedValue([]);
|
||||
findManyMock.mockResolvedValue([]);
|
||||
const body = await read();
|
||||
expect(body.offhost.lifecycle).toEqual({
|
||||
state: "missing",
|
||||
expirationDays: null,
|
||||
});
|
||||
expect(body.offhost.pendingDeletions).toEqual({
|
||||
count: 2,
|
||||
oldestRequestedAt: "2026-09-20T10:00:00.000Z",
|
||||
lastFailure: "AccessDenied",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
|
||||
import { readLastQueueRun } from "@/lib/jobs/job-failures";
|
||||
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
|
||||
import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup";
|
||||
import {
|
||||
offhostBackupConfigured,
|
||||
probeOffhostLifecycle,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
import {
|
||||
classifyOffhostBackup,
|
||||
OFFHOST_BACKUP_PERIOD_HOURS,
|
||||
@@ -164,6 +167,17 @@ export const GET = apiHandler(async () => {
|
||||
a.username.localeCompare(b.username),
|
||||
);
|
||||
|
||||
const [purgeCount, oldestPurge, lifecycle] = await Promise.all([
|
||||
prisma.offhostPurgeRequest.count(),
|
||||
prisma.offhostPurgeRequest.findFirst({
|
||||
orderBy: { requestedAt: "asc" },
|
||||
select: { requestedAt: true, lastFailure: true },
|
||||
}),
|
||||
configured
|
||||
? probeOffhostLifecycle()
|
||||
: Promise.resolve({ state: "unknown" as const, expirationDays: null }),
|
||||
]);
|
||||
|
||||
const payload: BackupsList = {
|
||||
rows: list,
|
||||
// Matches the retention window the backup-prune job enforces so the
|
||||
@@ -182,6 +196,12 @@ export const GET = apiHandler(async () => {
|
||||
// An unconfigured host has nothing to list, and the card says so rather
|
||||
// than painting an empty table that reads as "no problems".
|
||||
rows: configured ? offhostRows : [],
|
||||
pendingDeletions: {
|
||||
count: purgeCount,
|
||||
oldestRequestedAt: oldestPurge?.requestedAt.toISOString() ?? null,
|
||||
lastFailure: oldestPurge?.lastFailure ?? null,
|
||||
},
|
||||
lifecycle,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
kickOffhostPurge,
|
||||
PURGE_ALL_SUBJECTS,
|
||||
requestOffhostPurge,
|
||||
} from "@/lib/jobs/offhost-purge";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -108,6 +113,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// columns does not. Same classification contract as the model list.
|
||||
await tx.user.updateMany({ data: USER_RESET });
|
||||
|
||||
// Every account's nightly off-host copies go too.
|
||||
await requestOffhostPurge(tx, PURGE_ALL_SUBJECTS, "admin_wipe");
|
||||
|
||||
return perModel;
|
||||
},
|
||||
{
|
||||
@@ -138,6 +146,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// v1.16.9 — the wipe touched every user's rows; clear every cache
|
||||
// bucket so no per-user payload survives the reset.
|
||||
invalidateAllCaches();
|
||||
await kickOffhostPurge();
|
||||
|
||||
return apiSuccess({ cleared: true, deletedRows, models: counts });
|
||||
});
|
||||
@@ -19,6 +19,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getConfiguredKeyIds } from "@/lib/crypto";
|
||||
import {
|
||||
offhostBackupConfigured,
|
||||
probeOffhostLifecycle,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
import {
|
||||
scanCorpus,
|
||||
type CorpusClient,
|
||||
@@ -100,6 +104,100 @@ async function readRotationState(): Promise<RotationState> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which keys the backups still need.
|
||||
*
|
||||
* Rotation re-encrypts every row in the database and re-seals every stored
|
||||
* backup's envelope, but the content INSIDE a backup keeps the key it was
|
||||
* written under: a restore writes it back verbatim. So "every row is on the
|
||||
* active key" is not the same as "the old key can go". This answers the second
|
||||
* question per key id, from what each copy recorded as it was written: the
|
||||
* stored copies that need it and the oldest of them, and for the off-host
|
||||
* bucket the last night an object needing it went in, plus how long the
|
||||
* bucket's lifecycle rule keeps objects after that.
|
||||
*/
|
||||
export interface BackupKeyNeeds {
|
||||
stored: Array<{ keyId: string; copies: number; oldestAt: string }>;
|
||||
/** Copies written before v1.39.3, which recorded no key ids. */
|
||||
unrecorded: { copies: number; oldestAt: string | null };
|
||||
offhost: Array<{
|
||||
keyId: string;
|
||||
firstWrittenAt: string;
|
||||
lastWrittenAt: string;
|
||||
/** When the last object needing this key expires; null when unknown. */
|
||||
neededUntil: string | null;
|
||||
}>;
|
||||
/** The bucket's expiry, when it has one the credential may read. */
|
||||
offhostExpirationDays: number | null;
|
||||
/** Retired key ids some backup still needs (never the active one). */
|
||||
retiredKeysStillNeeded: string[];
|
||||
}
|
||||
|
||||
async function readBackupKeyNeeds(
|
||||
activeKeyId: string,
|
||||
now: Date,
|
||||
): Promise<BackupKeyNeeds> {
|
||||
const [stored, unrecorded, offhostRows] = await Promise.all([
|
||||
prisma.$queryRaw<
|
||||
Array<{ key_id: string; copies: number; oldest_at: Date }>
|
||||
>`
|
||||
SELECT k AS key_id, count(*)::int AS copies, min(created_at) AS oldest_at
|
||||
FROM data_backups, unnest(inner_key_ids) AS k
|
||||
WHERE inner_key_ids_recorded
|
||||
GROUP BY k
|
||||
ORDER BY k
|
||||
`,
|
||||
prisma.dataBackup.aggregate({
|
||||
where: { innerKeyIdsRecorded: false },
|
||||
_count: { _all: true },
|
||||
_min: { createdAt: true },
|
||||
}),
|
||||
prisma.offhostBackupKeyUse.findMany({ orderBy: { keyId: "asc" } }),
|
||||
]);
|
||||
const lifecycle =
|
||||
offhostRows.length > 0 && offhostBackupConfigured()
|
||||
? await probeOffhostLifecycle()
|
||||
: null;
|
||||
const expirationDays =
|
||||
lifecycle?.state === "configured" ? lifecycle.expirationDays : null;
|
||||
|
||||
const offhost = offhostRows.map((row) => ({
|
||||
keyId: row.keyId,
|
||||
firstWrittenAt: row.firstWrittenAt.toISOString(),
|
||||
lastWrittenAt: row.lastWrittenAt.toISOString(),
|
||||
neededUntil:
|
||||
expirationDays === null
|
||||
? null
|
||||
: new Date(
|
||||
row.lastWrittenAt.getTime() + (expirationDays + 1) * 86_400_000,
|
||||
).toISOString(),
|
||||
}));
|
||||
|
||||
const needed = new Set<string>();
|
||||
for (const row of stored) needed.add(row.key_id);
|
||||
for (const row of offhost) {
|
||||
if (row.neededUntil === null || Date.parse(row.neededUntil) > +now) {
|
||||
needed.add(row.keyId);
|
||||
}
|
||||
}
|
||||
needed.delete(activeKeyId);
|
||||
|
||||
return {
|
||||
stored: stored.map((row) => ({
|
||||
keyId: row.key_id,
|
||||
copies: row.copies,
|
||||
oldestAt: row.oldest_at.toISOString(),
|
||||
})),
|
||||
unrecorded: {
|
||||
copies: unrecorded._count._all,
|
||||
oldestAt: unrecorded._min.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
offhost,
|
||||
offhostExpirationDays: expirationDays,
|
||||
retiredKeysStillNeeded: [...needed].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.status" } });
|
||||
@@ -114,6 +212,7 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
|
||||
const rotation = await readRotationState();
|
||||
const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -133,5 +232,14 @@ export const GET = apiHandler(async () => {
|
||||
staleRows: scan.staleRows,
|
||||
columns: scan.columns,
|
||||
rotation,
|
||||
backups,
|
||||
// Every row rotated AND no backup still needs a retired key: the one
|
||||
// state in which dropping an old key loses nothing. Copies that recorded
|
||||
// no key ids count against it, because they may hold any key that
|
||||
// existed when they were written.
|
||||
safeToDropRetiredKeys:
|
||||
scan.rotationComplete &&
|
||||
backups.retiredKeysStillNeeded.length === 0 &&
|
||||
backups.unrecorded.copies === 0,
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
kickOffhostPurge,
|
||||
requestOffhostPurge,
|
||||
} from "@/lib/jobs/offhost-purge";
|
||||
import {
|
||||
deleteGuardianAccountWithLifecycle,
|
||||
LastManagedGuardianError,
|
||||
@@ -101,6 +105,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// genuinely complete erasure.
|
||||
await tx.auditLog.deleteMany({ where: { userId } });
|
||||
|
||||
// The nightly off-host copies go with the account. The request commits
|
||||
// with the deletion; the purge job deletes the objects.
|
||||
await requestOffhostPurge(tx, userId, "account_deleted");
|
||||
|
||||
// Delete user — all other related data is removed via onDelete: Cascade.
|
||||
await tx.user.delete({ where: { id: userId } });
|
||||
});
|
||||
@@ -115,6 +123,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await kickOffhostPurge();
|
||||
|
||||
annotate({
|
||||
action: { name: "settings.account.delete" },
|
||||
meta: { username },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
kickOffhostPurge,
|
||||
requestOffhostPurge,
|
||||
} from "@/lib/jobs/offhost-purge";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
@@ -101,6 +105,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// does not. Same classification contract as the model list.
|
||||
await tx.user.update({ where: { id: userId }, data: USER_RESET });
|
||||
|
||||
// The nightly off-host copies of the record are part of "all your
|
||||
// data". The request commits with the wipe; the purge job deletes
|
||||
// the objects and removes it.
|
||||
await requestOffhostPurge(tx, userId, "data_wiped");
|
||||
|
||||
return perModel;
|
||||
},
|
||||
{
|
||||
@@ -144,6 +153,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// v1.16.9 — every cached payload was built on the rows just deleted;
|
||||
// hard-evict the user's buckets so no surface serves pre-wipe data.
|
||||
invalidateUserData(userId);
|
||||
await kickOffhostPurge();
|
||||
|
||||
return apiSuccess({ cleared: true, deletedRows, models: counts });
|
||||
});
|
||||
Reference in new issue
Block a user