mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* A stored backup records the keys its content needs, and the encryption view
|
||||
* says a retired key is still needed while any backup lists it.
|
||||
*
|
||||
* Both ends and the pipe: the copy is written by the real `storeBackupBlob`,
|
||||
* the key ids are read from the JSON on its way into the pieces, and the
|
||||
* admin status route reads them back. The case the view used to get wrong is
|
||||
* the one asserted: every row rotated, the badge said the old key was safe to
|
||||
* drop, and a backup still needed it.
|
||||
*
|
||||
* Mutations that must turn this red: drop the scanner from `storeBackupBlob`
|
||||
* (nothing is recorded, the view has no key to name), or leave
|
||||
* `retiredKeysStillNeeded` out of `safeToDropRetiredKeys` (the badge says
|
||||
* safe again).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const saved = {
|
||||
key: process.env.ENCRYPTION_KEY,
|
||||
keys: process.env.ENCRYPTION_KEYS,
|
||||
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
|
||||
};
|
||||
|
||||
function useKeys(active: string) {
|
||||
process.env.ENCRYPTION_KEY = "";
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify({
|
||||
old: "11".repeat(32),
|
||||
cur: "22".repeat(32),
|
||||
});
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ENCRYPTION_KEY = saved.key ?? "";
|
||||
process.env.ENCRYPTION_KEYS = saved.keys ?? "";
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = saved.active ?? "";
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
describe("the keys stored backups still need", () => {
|
||||
it("names a retired key a backup's content was written under", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await prisma.user.create({
|
||||
data: { username: "keys-admin", email: "k@example.test", role: "ADMIN" },
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
const { storeBackupBlob } = await import("@/lib/export/store-backup-blob");
|
||||
// A copy taken before the rotation: its note was written under "old".
|
||||
useKeys("old");
|
||||
const note = Buffer.from(encrypt("before"), "utf8").toString("base64");
|
||||
// The copy itself is stored after the rotation, so its envelope is
|
||||
// sealed under "cur" and only its content needs "old".
|
||||
useKeys("cur");
|
||||
await storeBackupBlob(
|
||||
prisma,
|
||||
{ userId: admin.id, type: "MANUAL_UPLOAD_KEYS" },
|
||||
async (write) => {
|
||||
await write('{"userId":"x","measurements":[{"notesEncrypted":');
|
||||
await write(`"${note}"}]}`);
|
||||
},
|
||||
);
|
||||
const row = await prisma.dataBackup.findFirstOrThrow({
|
||||
where: { type: "MANUAL_UPLOAD_KEYS" },
|
||||
});
|
||||
expect(row.innerKeyIds).toEqual(["old"]);
|
||||
expect(row.innerKeyIdsRecorded).toBe(true);
|
||||
|
||||
const { GET } = await import("@/app/api/admin/encryption/status/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()).data as {
|
||||
rotationComplete: boolean;
|
||||
safeToDropRetiredKeys: boolean;
|
||||
backups: {
|
||||
stored: Array<{ keyId: string; copies: number }>;
|
||||
retiredKeysStillNeeded: string[];
|
||||
};
|
||||
};
|
||||
expect(body.backups.stored).toEqual([
|
||||
expect.objectContaining({ keyId: "old", copies: 1 }),
|
||||
]);
|
||||
expect(body.backups.retiredKeysStillNeeded).toEqual(["old"]);
|
||||
expect(body.safeToDropRetiredKeys).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Deleting an account, or wiping its data, takes its off-host copies out of
|
||||
* the bucket.
|
||||
*
|
||||
* The bucket is replaced at the client boundary (`getS3Client`) by an
|
||||
* in-memory map; everything above it is real: the route, its transaction, the
|
||||
* request row it commits and the purge job that works it. Mutations that must
|
||||
* turn this red: drop `requestOffhostPurge` from either route (no request is
|
||||
* written and the objects stay), or write the request outside the deletion's
|
||||
* transaction and let it fail (the deletion commits without one).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const bucket = vi.hoisted(() => new Set<string>());
|
||||
|
||||
vi.mock("@/lib/jobs/offhost-backup", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/jobs/offhost-backup")>();
|
||||
return {
|
||||
...actual,
|
||||
getS3Client: vi.fn(async () => ({
|
||||
putObject: vi.fn(),
|
||||
putStream: vi.fn(),
|
||||
getObject: vi.fn(),
|
||||
headObject: vi.fn(),
|
||||
listObjects: vi.fn(async (prefix: string) =>
|
||||
[...bucket].filter((k) => k.startsWith(prefix)).map((key) => ({ key })),
|
||||
),
|
||||
deleteObject: vi.fn(async (key: string) => {
|
||||
bucket.delete(key);
|
||||
}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
bucket.clear();
|
||||
process.env.BACKUP_S3_ENDPOINT = "https://r2.example";
|
||||
process.env.BACKUP_S3_BUCKET = "hl-backups";
|
||||
process.env.BACKUP_S3_ACCESS_KEY = "AKIA";
|
||||
process.env.BACKUP_S3_SECRET_KEY = "secret";
|
||||
process.env.BACKUP_ENCRYPTION_KEY = "ab".repeat(32);
|
||||
});
|
||||
|
||||
async function seedUserWithSession(username: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: { username, email: `${username}@example.test` },
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
function seedBucket(userId: string, otherId: string) {
|
||||
for (const day of ["2026-09-01", "2026-09-02", "2026-09-03"]) {
|
||||
bucket.add(`${day}/user-${userId}.json.enc`);
|
||||
bucket.add(`${day}/user-${otherId}.json.enc`);
|
||||
}
|
||||
bucket.add("_healthcheck/1.bin");
|
||||
}
|
||||
|
||||
describe("off-host copies leave with the account", () => {
|
||||
it("account deletion removes every copy of that account", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const other = await prisma.user.create({
|
||||
data: { username: "neighbour", email: "neighbour@example.test" },
|
||||
});
|
||||
const userId = await seedUserWithSession("leaving");
|
||||
seedBucket(userId, other.id);
|
||||
|
||||
const { DELETE } = await import("@/app/api/settings/account/route");
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/settings/account", {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const pending = await prisma.offhostPurgeRequest.findMany();
|
||||
expect(pending.map((r) => [r.subjectId, r.reason])).toEqual([
|
||||
[userId, "account_deleted"],
|
||||
]);
|
||||
|
||||
const { processOffhostPurges } = await import("@/lib/jobs/offhost-purge");
|
||||
const report = await processOffhostPurges(prisma);
|
||||
expect(report).toMatchObject({ completed: 1, objectsDeleted: 3 });
|
||||
expect([...bucket].filter((k) => k.includes(userId))).toEqual([]);
|
||||
expect([...bucket].filter((k) => k.includes(other.id))).toHaveLength(3);
|
||||
expect(bucket.has("_healthcheck/1.bin")).toBe(true);
|
||||
expect(await prisma.offhostPurgeRequest.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("delete-all-data removes every copy and leaves the receipt", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const other = await prisma.user.create({
|
||||
data: { username: "neighbour2", email: "neighbour2@example.test" },
|
||||
});
|
||||
const userId = await seedUserWithSession("wiping");
|
||||
seedBucket(userId, other.id);
|
||||
|
||||
const { DELETE } = await import("@/app/api/settings/data/route");
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/settings/data", {
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE" }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const { processOffhostPurges } = await import("@/lib/jobs/offhost-purge");
|
||||
await processOffhostPurges(prisma);
|
||||
expect([...bucket].filter((k) => k.includes(userId))).toEqual([]);
|
||||
expect([...bucket].filter((k) => k.includes(other.id))).toHaveLength(3);
|
||||
const receipt = await prisma.auditLog.findFirst({
|
||||
where: { action: "offhost.backup.purged", userId },
|
||||
});
|
||||
expect(receipt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user