Files
HealthLog/prisma/migrations/0487_recovery_readiness/migration.sql
T
Marc-André Bombeck d3c672cc3f feat(admin): replace the key backup step with a recovery kit
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.
2026-10-25 13:32:29 +01:00

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$;