mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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,37 @@
|
||||
-- Backups: which keys their content needs, and off-host copies that leave
|
||||
-- with the account.
|
||||
--
|
||||
-- 1. A stored backup carries encrypted columns as the stored ciphertext, and
|
||||
-- key rotation cannot reach inside a copy. Each copy now records the key
|
||||
-- ids its content was written under, so the admin encryption view can say
|
||||
-- which retired key is still needed and by how old a copy. Copies written
|
||||
-- before this release are marked unrecorded rather than assumed clean.
|
||||
ALTER TABLE "data_backups"
|
||||
ADD COLUMN "inner_key_ids" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "inner_key_ids_recorded" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- 2. The same fact for the off-host bucket, per key rather than per object:
|
||||
-- when the nightly run last wrote an object that needed each key.
|
||||
CREATE TABLE "offhost_backup_key_use" (
|
||||
"key_id" TEXT NOT NULL,
|
||||
"first_written_at" TIMESTAMP(3) NOT NULL,
|
||||
"last_written_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "offhost_backup_key_use_pkey" PRIMARY KEY ("key_id")
|
||||
);
|
||||
|
||||
-- 3. Accounts whose off-host objects have to be deleted: written with the
|
||||
-- account deletion or the data wipe, removed once the bucket holds nothing
|
||||
-- for the account. No foreign key, because a deleted account's row is gone
|
||||
-- by the time the job runs.
|
||||
CREATE TABLE "offhost_purge_requests" (
|
||||
"id" TEXT NOT NULL,
|
||||
"subject_id" TEXT NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"requested_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"last_attempt_at" TIMESTAMP(3),
|
||||
"last_failure" TEXT,
|
||||
CONSTRAINT "offhost_purge_requests_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX "offhost_purge_requests_requested_at_idx"
|
||||
ON "offhost_purge_requests"("requested_at");
|
||||
+54
-7
@@ -4970,19 +4970,29 @@ model PushSubscription {
|
||||
// ─── Data Backups ────────────────────────────────────────────
|
||||
|
||||
model DataBackup {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
type String @default("WEEKLY_AUTO") // WEEKLY_AUTO, MANUAL_UPLOAD_<ms>
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
type String @default("WEEKLY_AUTO") // WEEKLY_AUTO, MANUAL_UPLOAD_<ms>
|
||||
/// The whole encrypted copy as one value: how every backup was stored up
|
||||
/// to v1.39.1. NULL for a copy stored in pieces (`chunks`), which is how
|
||||
/// every backup is written from v1.39.2 on. A row carries one or the other.
|
||||
data String?
|
||||
data String?
|
||||
/// How many pieces a chunked copy has, and the random id sealed into every
|
||||
/// one of them (`src/lib/export/backup-chunks.ts`). The reader checks both
|
||||
/// against the pieces before it releases a byte; NULL on a single-value copy.
|
||||
chunkCount Int? @map("chunk_count")
|
||||
chunkStreamId String? @map("chunk_stream_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
chunkCount Int? @map("chunk_count")
|
||||
chunkStreamId String? @map("chunk_stream_id")
|
||||
/// The application key ids the ciphertext INSIDE this copy was written
|
||||
/// under, recorded as the copy is written (`createBackupKeyIdTextScanner`).
|
||||
/// Key rotation re-seals the copy's envelope but not what is inside it, so
|
||||
/// a key stays needed while any copy lists it. NULL for a copy written
|
||||
/// before v1.39.3, which the admin encryption view counts as unrecorded.
|
||||
/// Consumer: `GET /api/admin/encryption/status` (`backups.stored`).
|
||||
innerKeyIds String[] @default([]) @map("inner_key_ids")
|
||||
/// Whether `innerKeyIds` was recorded; false on every copy written before
|
||||
/// v1.39.3. Consumer: the same status read (`backups.unrecorded`).
|
||||
innerKeyIdsRecorded Boolean @default(false) @map("inner_key_ids_recorded")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
chunks DataBackupChunk[]
|
||||
@@ -5148,6 +5158,43 @@ model OffhostBackupState {
|
||||
@@map("offhost_backup_state")
|
||||
}
|
||||
|
||||
/// Per application key id, when the nightly off-host run last put an object
|
||||
/// in the bucket whose inner ciphertext needed that key. The bucket's
|
||||
/// lifecycle rule keeps each object for its retention, so a retired key is
|
||||
/// needed until that long after this date. Not per account: the question it
|
||||
/// answers is about keys. Written by `runOffhostBackup`; consumer:
|
||||
/// `GET /api/admin/encryption/status` (`backups.offhost`).
|
||||
model OffhostBackupKeyUse {
|
||||
keyId String @id @map("key_id")
|
||||
firstWrittenAt DateTime @map("first_written_at")
|
||||
lastWrittenAt DateTime @map("last_written_at")
|
||||
|
||||
@@map("offhost_backup_key_use")
|
||||
}
|
||||
|
||||
/// An account whose off-host copies have to leave the bucket: it was deleted,
|
||||
/// or its data was wiped. Written in the same transaction as the deletion, so
|
||||
/// the request cannot be lost between the two, and deleted once every object
|
||||
/// for the account is gone. `subjectId` is deliberately not a relation: for a
|
||||
/// deleted account the user row is already gone. Written by
|
||||
/// `requestOffhostPurge`; consumer: `processOffhostPurges` (the
|
||||
/// `offhost-backup-purge` job) and the off-host card's pending count behind
|
||||
/// `GET /api/admin/backups`.
|
||||
model OffhostPurgeRequest {
|
||||
id String @id @default(cuid())
|
||||
subjectId String @map("subject_id")
|
||||
/// `account_deleted`, `data_wiped`, `managed_profile_deleted` or `admin_wipe`.
|
||||
reason String
|
||||
requestedAt DateTime @default(now()) @map("requested_at")
|
||||
attempts Int @default(0)
|
||||
lastAttemptAt DateTime? @map("last_attempt_at")
|
||||
/// The bucket's last refusal, trimmed; shown to the operator.
|
||||
lastFailure String? @map("last_failure")
|
||||
|
||||
@@index([requestedAt])
|
||||
@@map("offhost_purge_requests")
|
||||
}
|
||||
|
||||
// ─── Apple Health export.zip import (v1.4.34) ───────────────
|
||||
//
|
||||
// Mirrors a `pg-boss` `apple-health-import` job's lifecycle so the
|
||||
|
||||
Reference in new issue
Block a user