mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
The step confirmed one key, the active application key. A restore needs more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy written before a rotation needs the retired application key it was written under, and every session, token and share link hangs off API_TOKEN_HMAC_KEY. An operator could do everything the step asked and still be unable to read a single backup. The recovery kit lists every key a restore of this server needs, by id and fingerprint, never key material: - each id in ENCRYPTION_KEYS, plus any retired id a stored copy (innerKeyIds) or an off-host object inside the bucket's retention (OffhostBackupKeyUse) still lists, also once the server dropped it; - BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry, named by the id their objects record; - API_TOKEN_HMAC_KEY. Confirming records the set the admin was shown and refuses a stale one with 409 recovery.kit.stale. The kit is due again as soon as a key it lists is not in that set (a rotation, a new backup key, a re-keyed install); a key that merely drops out does not make it due. "Check my copy" now checks any key in the kit. Migration 0487 carries a confirmation given under the old step forward as a kit holding that key, so that part stays confirmed, and drops the four app_settings columns. It also creates the two tables the readiness evaluation uses (restore_drill_outcomes, recovery_readiness_notices). The routes move to /api/admin/recovery-kit; the reader of the keys the backups still need moves out of the encryption status route into src/lib/recovery/backup-key-needs.ts so the kit and the status view share one definition.
102 lines
4.5 KiB
SQL
102 lines
4.5 KiB
SQL
-- Recovery readiness: what the admins were last told, what the restore drill
|
|
-- last found, and the recovery kit confirmation.
|
|
--
|
|
-- `recovery_readiness_notices` holds, per readiness signal, the state the
|
|
-- admins were last notified about and when. It is the whole memory of the
|
|
-- hourly evaluation: a state change notifies once, a failing signal is
|
|
-- reminded at most once a day, and a recovery is announced once. It also
|
|
-- remembers since when a signal has applied, so a dump switched on by an
|
|
-- upgrade is not called failing before its first run had a chance.
|
|
--
|
|
-- `restore_drill_outcomes` keeps the monthly drill's last result for as long
|
|
-- as it is the last one. pg-boss forgets a failed run after seven days and
|
|
-- the admin failing-jobs card looks back 72 hours, so a failed monthly drill
|
|
-- was on screen three days in thirty.
|
|
--
|
|
-- `recovery_kit_confirmations` replaces the four `encryption_key_backup_*`
|
|
-- columns on `app_settings`. Those confirmed one key, the active application
|
|
-- key; the kit confirms the set of every key a restore needs, by id and
|
|
-- fingerprint. A confirmation given under the old step is carried over as a
|
|
-- kit that holds that one key, so an operator who confirmed the active key
|
|
-- keeps that part confirmed and is asked only for what the old step never
|
|
-- covered.
|
|
--
|
|
-- A backup file written by a release that still had the four columns carries
|
|
-- them in its instance settings; the restore writes an explicit list of
|
|
-- settings and ignores them.
|
|
--
|
|
-- The whole file is one statement, so it applies whole or not at all, and
|
|
-- running it again changes nothing. The drop takes an ACCESS EXCLUSIVE lock
|
|
-- on `app_settings` for a catalogue update only; the wait is bounded.
|
|
--
|
|
-- Reversibility: re-adding the four nullable columns restores the old shape;
|
|
-- the old step then reads as due until it is confirmed again.
|
|
--
|
|
-- @retry-on-lock-timeout
|
|
|
|
DO $migration$
|
|
BEGIN
|
|
PERFORM set_config('lock_timeout', '5s', true);
|
|
|
|
CREATE TABLE IF NOT EXISTS "recovery_readiness_notices" (
|
|
"signal" TEXT NOT NULL,
|
|
"notified_state" TEXT,
|
|
"notified_at" TIMESTAMP(3),
|
|
"watched_since" TIMESTAMP(3),
|
|
CONSTRAINT "recovery_readiness_notices_pkey" PRIMARY KEY ("signal"),
|
|
CONSTRAINT "recovery_readiness_notices_state_check"
|
|
CHECK ("notified_state" IS NULL OR "notified_state" IN ('ok', 'attention', 'failing'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS "restore_drill_outcomes" (
|
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
|
"last_run_at" TIMESTAMP(3) NOT NULL,
|
|
"last_success_at" TIMESTAMP(3),
|
|
"last_failure_at" TIMESTAMP(3),
|
|
"last_failure_reason" TEXT,
|
|
"accounts_checked" INTEGER NOT NULL DEFAULT 0,
|
|
"accounts_failed" INTEGER NOT NULL DEFAULT 0,
|
|
CONSTRAINT "restore_drill_outcomes_pkey" PRIMARY KEY ("id")
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS "recovery_kit_confirmations" (
|
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
|
"confirmed_at" TIMESTAMP(3) NOT NULL,
|
|
"confirmed_by_user_id" TEXT,
|
|
"entries" JSONB NOT NULL,
|
|
CONSTRAINT "recovery_kit_confirmations_pkey" PRIMARY KEY ("id")
|
|
);
|
|
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = current_schema()
|
|
AND table_name = 'app_settings'
|
|
AND column_name = 'encryption_key_backup_confirmed_at'
|
|
) THEN
|
|
INSERT INTO "recovery_kit_confirmations"
|
|
("id", "confirmed_at", "confirmed_by_user_id", "entries")
|
|
SELECT
|
|
'singleton',
|
|
"encryption_key_backup_confirmed_at",
|
|
"encryption_key_backup_confirmed_by_user_id",
|
|
jsonb_build_array(jsonb_build_object(
|
|
'kind', 'application',
|
|
'id', "encryption_key_backup_confirmed_key_id",
|
|
'fingerprint', "encryption_key_backup_confirmed_fingerprint"
|
|
))
|
|
FROM "app_settings"
|
|
WHERE "id" = 'singleton'
|
|
AND "encryption_key_backup_confirmed_at" IS NOT NULL
|
|
AND "encryption_key_backup_confirmed_key_id" IS NOT NULL
|
|
AND "encryption_key_backup_confirmed_fingerprint" IS NOT NULL
|
|
ON CONFLICT ("id") DO NOTHING;
|
|
|
|
ALTER TABLE "app_settings"
|
|
DROP COLUMN IF EXISTS "encryption_key_backup_confirmed_at",
|
|
DROP COLUMN IF EXISTS "encryption_key_backup_confirmed_key_id",
|
|
DROP COLUMN IF EXISTS "encryption_key_backup_confirmed_fingerprint",
|
|
DROP COLUMN IF EXISTS "encryption_key_backup_confirmed_by_user_id";
|
|
END IF;
|
|
END
|
|
$migration$;
|