diff --git a/CONTEXT.md b/CONTEXT.md index c36a430..55d584b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -3,6 +3,18 @@ > Created with the REUSE.md rollout (2026-07-03). Authoritative history: `hub/CHANGELOG.md` (hub), > `website/CHANGELOG.md`, `scripts/CHANGELOG.md`; end-of-task detail in `REPORT.md`. +- **2026-07-17 — HOST-DELETE DEMOTES ESCROW: hub v0.60.1 (LIVE).** Closes the v0.60.0 review gap: + `DeleteHost(deleteEscrow=true)` now DEMOTES the current escrow blob into `host_escrow_superseded` + (copy-before-delete, same tx) + spares existing superseded rows — never destroys custody. The + customer Danger-zone Delete (`DeleteCustomerConfig`, which before touched ONLY `customer_configs`) + is now the one true purge point: cascades both escrow tables for all the customer's hosts incl. + already-deleted (via F-14 `host_deletions`). One shared copy routine `demoteCurrentEscrowTx` (reused + by SaveHostEscrow). Checkbox/refusal/Danger-zone wording → demotion. **S6b VERDICT: OBSOLETE** — + re-enroll upserts cleanly (`UpsertHost` ON CONFLICT DO UPDATE; `handleAdminCreateHost` no dup + refusal) + v0.57.0 arc auto-fires re-issues (ROADMAP R-3 refined). Red-proofs + `TestDeleteHost_DemotesEscrowNeverDestroys` + `TestDeleteCustomer_PurgesEscrowCustody`. Observation: + customer-delete still doesn't touch host rows / the broader offboarding (rehearsal's question). REPORT.md. + - **2026-07-17 — OFFSITE CONTINUITY Part B SHIPPED: hub v0.60.0 (LIVE).** Superseded-escrow retention (data-first, Viktor's ruling). `SaveHostEscrow`'s destructive `ON CONFLICT` overwrite destroyed the old passphrase's only copy (the reinstall-orphan incident); now a new diff --git a/REPORT.md b/REPORT.md index 8520ba0..73f8e74 100644 --- a/REPORT.md +++ b/REPORT.md @@ -2,39 +2,56 @@ > **Overwrite** this file with a summary of the most recent task only (uniform with the other repos; not cumulative). The cumulative hub history lives in [hub/CHANGELOG.md](hub/CHANGELOG.md); the scripts history lives in [scripts/CHANGELOG.md](scripts/CHANGELOG.md). -## Offsite continuity Part B — superseded-escrow retention (hub v0.60.0) — 2026-07-17 +## Hub v0.60.1 — host deletion demotes escrow custody (never destroys) + S6b verdict — 2026-07-17 -**Baseline:** hub v0.59.0 → **v0.60.0** (LIVE on prod, ArgoCD Synced/Healthy). Closes the data-loss -half of the reinstall-orphaned-repo incident -(`documentation/audits/DIAGNOSE-offbox-repo-orphaned-2026-07-17.md`): the destructive -`SaveHostEscrow` `ON CONFLICT` overwrite meant a new escrow blob DESTROYED the old passphrase's only -copy (18 snapshots unrecoverable). Viktor's ruling — data protection first — is now enforced. Pairs -with controller v0.142.0 (Part A). Green: `go build ./... && go vet ./... && go test ./...`. +**Baseline:** hub v0.60.0 (`106c337`) → **v0.60.1** (LIVE, ArgoCD Synced/Healthy). Hub-only; no +controller/agent change. Closes the deletion-path gap found in the v0.60.0 review. Green: +`go build ./... && go vet ./... && go test ./...` + hub confirm gate. -### Schema decision: history table (not a flag) -`host_escrow_superseded` — an append-only, one-row-per-superseded-blob table. A flag couldn't hold N -retained blobs, and Viktor's ruling is to keep ALL (no pruning); a history table is the only shape -that does. `SaveHostEscrow` now, in one tx: reads the current row; if it seals a DIFFERENT -`restic_pw_sha256`, COPIES it into the history table BEFORE the current row is overwritten; then upserts -the new row. Same-sha re-upload (idempotent re-ceremony) → refresh only, no supersede row. Returns -`superseded bool`. +### Scenario A — host delete demotes, never destroys (`store.DeleteHost`) +The v0.60.0 code deleted `host_escrow` + `host_escrow_superseded` on `deleteEscrow=true`. Now it +DEMOTES the current row into `host_escrow_superseded` (copy-BEFORE-delete, same tx) and SPARES existing +superseded rows. Extracted THE one escrow row-copy routine `demoteCurrentEscrowTx` (INSERT…SELECT) and +refactored `SaveHostEscrow` to use it too — one copy routine, not two (rule 2). The F-14 provenance row ++ gate semantics are unchanged (comments reworded: demotion, not destruction). Edge: no escrow row → +unchanged; the `ErrHostEscrowPresent` refusal without the flag is unchanged. +**Red-proof (run-fail-revert):** `TestDeleteHost_DemotesEscrowNeverDestroys` — pre-fix (delete both) → +retained=0 → FAIL; fixed → both blobs (demoted current + spared superseded) retrievable, host gone. -### What ships (retention only — the guided-recovery flow is R-26, NOT built) -- `SaveHostEscrow` rewrite + `CountSupersededEscrow` / `ListSupersededEscrow` (the latter seeds R-26). - The hub still never decrypts; the ACK + restore-serving read the CURRENT row (`GetHostEscrow`) — - **unchanged**. `DeleteHost(deleteEscrow=true)` also drops the retained rows. -- Upload handler: `escrow_superseded` hub-internal audit event (tied to the owning customer) + a log - line with the retained count. Operator host-detail DR/Backup panel shows "N superseded escrow - blob(s) retained". Registered the controller-pushed `offbox_repo_orphaned` / `offbox_repo_reset` in - `allowedEventTypes` + `customerMessages`. -- **Red-proof** `TestSaveHostEscrow_RetainsSuperseded` (pre-fix destructive overwrite → old blob gone → - FAIL; fixed → retained + retrievable via `ListSupersededEscrow`; same-sha idempotent = no supersede). +### Scenario B — customer delete is the purge point (`store.DeleteCustomerConfig`) +**What it did before:** ONLY `DELETE FROM customer_configs` — it touched neither hosts nor escrow +(so deleting a customer left hosts + escrow blobs orphaned — an offboarding gap, see Observations). +**Now:** one tx that purges `host_escrow` AND `host_escrow_superseded` for all the customer's hosts — +INCLUDING already-deleted hosts (resolved via the F-14 `host_deletions` provenance `UNION`), so a +host-delete-then-customer-delete ordering leaves nothing orphaned. Danger-zone copy states it. +**Red-proof:** `TestDeleteCustomer_PurgesEscrowCustody` (pre-fix config-only → blobs survive → FAIL). -### Deploy -Built `felhom-hub:0.60.0`, bumped `manifests/hub.yaml`, ArgoCD Synced/Healthy, rollout complete -(`:0.60.0`). The `CREATE TABLE IF NOT EXISTS host_escrow_superseded` migration runs at startup -(idempotent). ACK semantics unaffected (current-row reads only). +### Scenario C — wording (`host_detail_body.html`, `hosts.go`, `customer_unified.html`) +Host-delete escrow checkbox → "Move key escrow to retained custody (required when escrow present) + +remove DR bundle for this host"; the 409 refusal message → "…move it to retained custody…"; the +customer Danger-zone copy → "…permanently removes the retained recovery-key custody (escrow blobs)… +This is the one true purge point; host deletion only demotes custody, never destroys it." Guard test +`TestHostDeleteEscrowLabel_DemotionWording`. -### NOT done (registered as R-26) -The guided "recover old history with the old recovery code" flow — Part B ships retention + visibility -only; R-26 registers the recovery flow that consumes the retained blobs. +### Scenario D — S6b necessity verdict: **OBSOLETE** +**Verdict + landmarks:** re-enrolling an existing host_id upserts cleanly — `store.UpsertHost` is +`INSERT … ON CONFLICT(host_id) DO UPDATE` (`internal/store/store.go:2099`), and +`handleAdminCreateHost` (`internal/api/handler.go:913`) calls it with NO duplicate refusal (it even +mints a fresh api_key). Combined with the v0.57.0 re-enroll arc (claim/offsite/escrow-honesty +auto-fire on re-enrollment, `reissueOnReenroll` `handler.go:1020`), no manual stale-host deletion is +required before re-enroll. Since S6b is not a literal runbook step, the verdict is recorded here + in +ROADMAP R-3 ("re-enrollment upserts cleanly; S6b obsolete; escrow cascade now defined; the rehearsal +live-confirms it"). Scenario A also makes the funnel harmless either way (deletion demotes, not +destroys). + +### Deploy verification +`felhom-hub:0.60.1` built + pushed; `manifests/hub.yaml` bumped; ArgoCD Synced/Healthy; rollout +complete (`:0.60.1`). The `demoteCurrentEscrowTx` path is INSERT…SELECT (atomic within the delete tx). + +### Observations +- **Customer-delete offboarding gap (unchanged this task, by scope):** `DeleteCustomerConfig` still + does NOT delete the customer's HOST rows or the broader offboarding (Hetzner sub-account, WG peer, + Storage-Box data) — only the escrow tables + the config row. That non-escrow lifecycle stays the + delete/re-create rehearsal's question (ROADMAP R-3). +- No pruning added (Viktor's data-first ruling); the ACK assembly + the upload supersede path are + untouched. diff --git a/documentation/architecture/00-capability-map.md b/documentation/architecture/00-capability-map.md index c7f6e6d..3aaadd0 100644 --- a/documentation/architecture/00-capability-map.md +++ b/documentation/architecture/00-capability-map.md @@ -32,7 +32,7 @@ | Appliance day-0 install: golden image → first boot → auto-confirm (zero clicks) → claimable box | installer, agent, hub, golden | **PROVEN-LIVE** (nested VM) | `DRILL-day0-vm-2026-07-12`, `DRILL-day0-take2-2026-07-12` | First firing on real customer hardware pending → R-1 | | BYO install: `--mode byo`, mandatory caps, host-mutation disclosure, coexistence guards | installer v1.15+, agent | **PARTIAL** | `DRILL-GL6-2026-07-08` (demo box); GL-8 coexistence fixes | Peti clean-slate reinstall on proxmox2 is the first real BYO run of the current path → R-1 | | Customer claim: one-time emailed code → customer sets own password (bcrypt, operator never sees it) | controller v0.122, hub v0.50 | **PROVEN-LIVE** (drill VM) | `DRILL-day0-vm-2026-07-12` §10/F-4 (gate ON via real edge; claimed, code consumed) | Never executed by a non-Viktor human → R-3. (Dropped mis-cited `CAMPAIGN-4` F-C — that is the escrow-claim 502, not password claim) | -| Escrow ceremony: customer-facing wizard, one-shot R claim, operator zero-knowledge | controller v0.127, agent v0.88/0.89 | **PROVEN-LIVE** (drill VM, endpoint-exact) | agent v0.88.0 REPORT (ceremony ~4s, one-shot claim 200→410, R absent from every payload); `SPIKE-controller-escrow-2026-07-13` | Endpoints driven on the drill VM; customer-facing **browser wizard** leg not yet live-validated. First supervised ceremony with a real customer pending → R-1. **agent v0.89.0:** `/escrow/preflight` `pbs_storage_id` row now live-reloads (reads current agent.json) — a pbsdr convergence that seeds the id flips it green with NO service restart. **hub v0.60.0 (data-first retention):** a re-escrow with a DIFFERENT sealed passphrase no longer destroys the old blob — the hub RETAINS it (`host_escrow_superseded`), so a previous passphrase stays recoverable with its recovery code (turns the reinstall-orphan incident from "history destroyed" into "history recoverable"). Guided-recovery flow = R-26. Red-proof `TestSaveHostEscrow_RetainsSuperseded` | +| Escrow ceremony: customer-facing wizard, one-shot R claim, operator zero-knowledge | controller v0.127, agent v0.88/0.89 | **PROVEN-LIVE** (drill VM, endpoint-exact) | agent v0.88.0 REPORT (ceremony ~4s, one-shot claim 200→410, R absent from every payload); `SPIKE-controller-escrow-2026-07-13` | Endpoints driven on the drill VM; customer-facing **browser wizard** leg not yet live-validated. First supervised ceremony with a real customer pending → R-1. **agent v0.89.0:** `/escrow/preflight` `pbs_storage_id` row now live-reloads (reads current agent.json) — a pbsdr convergence that seeds the id flips it green with NO service restart. **hub v0.60.0 (data-first retention):** a re-escrow with a DIFFERENT sealed passphrase no longer destroys the old blob — the hub RETAINS it (`host_escrow_superseded`), so a previous passphrase stays recoverable with its recovery code (turns the reinstall-orphan incident from "history destroyed" into "history recoverable"). Guided-recovery flow = R-26. Red-proof `TestSaveHostEscrow_RetainsSuperseded`. **hub v0.60.1 — custody survives the host lifecycle:** host deletion (with the escrow ack) DEMOTES the current blob to retained custody (moved into `host_escrow_superseded`, never destroyed; existing superseded rows spared); the customer Danger-zone Delete is the one true purge point (cascades both escrow tables incl. already-deleted hosts). No operator path through host lifecycle can lose a blob. Red-proofs `TestDeleteHost_DemotesEscrowNeverDestroys` + `TestDeleteCustomer_PurgesEscrowCustody` | | DR tier by default: PBS + WireGuard base infra on every install, hub-controlled activation | installer v1.15, agent v0.86, hub v0.51 | **IMPLEMENTED** | `DRILL-day0-take2-2026-07-12` §2 (WG enabled both modes, PBS-DR descriptor auto-provisioned ~1s after WG registration, zero operator steps); ships installer v1.15/agent v0.86/hub v0.51 | Live only on demo/drill fleet. (Cited spike was slice-0 mechanics — shipped nothing; corrected. Candidate upgrade to PROVEN-LIVE — see REPORT.) **agent v0.89.0 closes the F4 non-default-storage-id gap (R-22) — PROVEN-LIVE 2026-07-17:** the reconcile self-grants the ACL through the root wrapper on a pre-check 403 instead of dead-locking. Reproduced F4 on the demo (marker moved aside = reinstall fresh-state + felhom-offsite ACLs revoked) → next reconcile tick `pbsdr: pre-check 403 … self-granting … (R-22)` → `converged state=adopted` in ~3 s, ACLs self-restored, `pvesm status felhom-offsite`=active, zero operator action. No more one-shot `pveum` grant | | Uninstall: KEPT-vs-WIPED statement, secret purge, enrolled-drive handling | installer | **PARTIAL** | `DRILL-GL6-2026-07-08` Phase 1/5 (KEPT-vs-WIPED printed verbatim; drive data intact ×3); GL-4 code | Secret purge (GL6-F1 `.bak` residue) fixed v1.12.0; enrolled-drive `mnt-*.mount` units survive (GL6-F2, open); cluster-aware `felhom_guests` guard + saferemove cost warning missing → R-9 | diff --git a/documentation/backlog/ROADMAP.md b/documentation/backlog/ROADMAP.md index e5fd0a4..dd649d2 100644 --- a/documentation/backlog/ROADMAP.md +++ b/documentation/backlog/ROADMAP.md @@ -22,7 +22,7 @@ |----|------|------|--------|--------------------------| | R-1 | **Peti convergence:** clean-slate proxmox2 reinstall (spec'd 07-15), first live auto-confirm, supervised escrow ceremony, execute parked publish trains (agent 0.81→0.88, controller → 0.137) | L | spec'd | Flips: publish train PARTIAL→PROVEN-LIVE; appliance/BYO/day-0 "real customer" notes; escrow ceremony. The single biggest unproven surface — an alpha where fixes can't ship remotely is dead. **Reinstall arc SHIPPED hub v0.57.0 (2026-07-16):** the clean-slate reinstall-of-existing-customer path is now first-class — claim re-issue (F2), offsite re-issue (F3), escrow-honesty-on-re-issue (2.3) all auto-fire on re-enrollment. Peti's proxmox2 clean-slate now walks a supported path | | R-2 | ~~Resolve ~215 lines of foreign WIP in felhom.eu clone (`hub/internal/notify/`, `store.go`, `hub/internal/claim/`)~~ | S | **killed** (2026-07-16) | Not a real issue: the "foreign WIP" was in-flight code from a concurrent CC session on the customer-claim arc, snapshotted before it committed. All of it landed cleanly — `notify/`+`claim/engine.go` in `6b40eb8` (v0.50.0), `store.go` in `a1d0450` (v0.54.0), plus follow-up `e205a2d`; v0.55.0 shipped. Working tree is clean, no stashes. Lesson already codified: never run two writing sessions on one felhom.eu clone (CLAUDE.md §git add -A) | -| R-3 | Friend-alpha onboarding runbook (generalized from `pilot/RUNBOOK-peti-return-2026-07-13`): hardware prep → golden → install → claim → ceremony → "first restore by the customer" scripted step | M | idea | Flips: "customer performs a restore" MISSING row; produces the tester-agreement sibling of `PETI-tester-agreement.md`. **Next from-scratch rehearsal to include customer DELETE + re-create** — walks the never-validated deletion lifecycle (Hetzner sub-account deprovision? escrow blob cleanup? WG peer release?) | +| R-3 | Friend-alpha onboarding runbook (generalized from `pilot/RUNBOOK-peti-return-2026-07-13`): hardware prep → golden → install → claim → ceremony → "first restore by the customer" scripted step | M | idea | Flips: "customer performs a restore" MISSING row; produces the tester-agreement sibling of `PETI-tester-agreement.md`. **Next from-scratch rehearsal to include customer DELETE + re-create** — the ESCROW cascade is now DEFINED (hub v0.60.1): host delete DEMOTES escrow to retained custody (never destroys), customer Danger-zone delete PURGES it (the one true purge point). **S6b (manual stale-host delete before re-enroll) is OBSOLETE** — re-enrollment upserts the existing host row cleanly (`store.UpsertHost` ON CONFLICT DO UPDATE; `handleAdminCreateHost` no duplicate refusal) + the v0.57.0 arc auto-fires the re-issues; the rehearsal live-confirms it. **Remaining deletion-lifecycle = the NON-escrow offboarding** (Hetzner sub-account deprovision? WG peer release? Storage-Box data?) | | R-4 | Claim-code deliverability: test-send to gmail.com / freemail.hu / citromail.hu / t-online.hu; tighten DMARC `p=none` → `p=quarantine` (pending since email.md 02-04) | S | idea | A claim code in spam bricks onboarding at step 1. Cheap, do before first invite | ## P2 — during alpha diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 7002ed6..9aa5874 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,35 @@ # Felhom Hub — Changelog +## v0.60.1 — host deletion DEMOTES escrow custody (never destroys) + S6b obsolete (2026-07-17) + +Closes the deletion-path gap in v0.60.0's review: `DeleteHost(deleteEscrow=true)` was still DELETING +escrow rows (the same-customer reinstall flow funnels the operator straight into that tick). +Principle (Viktor's standing ruling): host deletion is a lifecycle event — blob custody survives it; +the customer Danger-zone Delete is the one true purge point. Green: +`go build ./... && go vet ./... && go test ./...`. + +- **Scenario A — host delete demotes, never destroys.** `DeleteHost(deleteEscrow=true)` now DEMOTES + the current `host_escrow` row into `host_escrow_superseded` (copy-BEFORE-delete, same tx) and SPARES + existing superseded rows — no operator path through host lifecycle can lose a blob. Reuses THE one + escrow row-copy routine (`demoteCurrentEscrowTx`, also used by `SaveHostEscrow`). The F-14 + provenance row + gate semantics are unchanged (wording updated: demotion, not destruction). Edge: + no escrow row → unchanged; the `ErrHostEscrowPresent` refusal without the flag is unchanged. + Red-proof `TestDeleteHost_DemotesEscrowNeverDestroys`. +- **Scenario B — customer delete is the purge point.** `DeleteCustomerConfig` (which previously deleted + ONLY the `customer_configs` row) now, in one tx, purges `host_escrow` AND `host_escrow_superseded` + for all the customer's hosts — INCLUDING already-deleted hosts (resolved via the F-14 + `host_deletions` provenance) so a host-delete-then-customer-delete ordering leaves nothing orphaned. + Danger-zone copy states it. Red-proof `TestDeleteCustomer_PurgesEscrowCustody`. +- **Scenario C — wording.** The host-delete escrow checkbox now reads "Move key escrow to retained + custody (required when escrow present)…"; the refusal message + customer Danger-zone copy match. + Guard test `TestHostDeleteEscrowLabel_DemotionWording`. +- **Scenario D — S6b verdict (docs): OBSOLETE.** Re-enrolling an existing host_id upserts cleanly + (`UpsertHost` ON CONFLICT DO UPDATE, `store.go`; `handleAdminCreateHost` has no duplicate refusal) + + the v0.57.0 re-enroll arc auto-fires the re-issues → no manual stale-host deletion needed before + re-enroll. Scenario A also makes the funnel harmless either way. ROADMAP R-3 refined. +- **Scope:** hub-only; no controller/agent change; ACK assembly + upload supersede path untouched. + Deploy: bump `manifests/hub.yaml` tag to `0.60.1` and sync. + ## v0.60.0 — offsite continuity Part B: superseded-escrow retention (data-first) (2026-07-17) Closes the data-loss half of the reinstall-orphaned-repo incident: `SaveHostEscrow`'s destructive diff --git a/hub/internal/store/customer_delete_purge_test.go b/hub/internal/store/customer_delete_purge_test.go new file mode 100644 index 0000000..b9d6fc9 --- /dev/null +++ b/hub/internal/store/customer_delete_purge_test.go @@ -0,0 +1,44 @@ +package store + +import "testing" + +// Scenario B (v0.60.1) — the customer Danger-zone Delete is the one true purge point: it removes +// host_escrow AND host_escrow_superseded for ALL the customer's hosts, including hosts already deleted +// (whose demoted blobs survive as superseded rows), so nothing is left orphaned. RED-PROOF: pre-fix +// DeleteCustomerConfig deleted only customer_configs → the escrow blobs survived → the assertions FAIL. +func TestDeleteCustomer_PurgesEscrowCustody(t *testing.T) { + s := newTestStore(t) + const cust = "cust-b" + if err := s.UpsertHost(&Host{HostID: "b1", CustomerID: cust, APIKey: "k1"}); err != nil { + t.Fatal(err) + } + if err := s.UpsertHost(&Host{HostID: "b2", CustomerID: cust, APIKey: "k2"}); err != nil { + t.Fatal(err) + } + // b1: current SHA_A + superseded SHA_OLD; b2: current SHA_B. + s.SaveHostEscrow("b1", []byte("old"), "fp", "zk", "t", "SHA_OLD") + s.SaveHostEscrow("b1", []byte("A"), "fp", "zk", "t", "SHA_A") + s.SaveHostEscrow("b2", []byte("B"), "fp", "zk", "t", "SHA_B") + + // ORDERING: delete b2 as a HOST first — SHA_B is demoted to a superseded row and the b2 host row is + // gone (so it can only be found again via the F-14 host_deletions provenance). + if err := s.DeleteHost("b2", true); err != nil { + t.Fatal(err) + } + if n, _ := s.CountSupersededEscrow("b2"); n != 1 { + t.Fatalf("precondition: b2 demoted blob = %d, want 1", n) + } + + // The customer Danger-zone Delete purges EVERYTHING for the customer's hosts (current + orphaned). + if err := s.DeleteCustomerConfig(cust); err != nil { + t.Fatalf("DeleteCustomerConfig: %v", err) + } + for _, h := range []string{"b1", "b2"} { + if cur, _ := s.GetHostEscrow(h); cur != nil { + t.Fatalf("%s current escrow survived the customer delete", h) + } + if n, _ := s.CountSupersededEscrow(h); n != 0 { + t.Fatalf("%s retained escrow survived the customer delete: %d (orphaned)", h, n) + } + } +} diff --git a/hub/internal/store/host_delete_demote_test.go b/hub/internal/store/host_delete_demote_test.go new file mode 100644 index 0000000..a55a34c --- /dev/null +++ b/hub/internal/store/host_delete_demote_test.go @@ -0,0 +1,70 @@ +package store + +import ( + "errors" + "testing" +) + +// Scenario A (v0.60.1) — host deletion DEMOTES the current escrow blob to retained custody and SPARES +// existing superseded blobs; it never destroys custody. RED-PROOF: the v0.60.0 code deleted both escrow +// tables → after DeleteHost the blobs are gone → the retrieval assertions FAIL. +func TestDeleteHost_DemotesEscrowNeverDestroys(t *testing.T) { + s := newTestStore(t) + const hostID, cust = "dh1", "cust-dh" + if err := s.UpsertHost(&Host{HostID: hostID, CustomerID: cust, APIKey: "k"}); err != nil { + t.Fatal(err) + } + // current escrow = SHA_A, one superseded = SHA_OLD (two uploads with different passphrases). + if _, err := s.SaveHostEscrow(hostID, []byte("blob-old"), "fp", "zk", "2026-07-09T00:00:00Z", "SHA_OLD"); err != nil { + t.Fatal(err) + } + if _, err := s.SaveHostEscrow(hostID, []byte("blob-A"), "fp", "zk", "2026-07-16T00:00:00Z", "SHA_A"); err != nil { + t.Fatal(err) + } + if n, _ := s.CountSupersededEscrow(hostID); n != 1 { + t.Fatalf("precondition superseded=%d, want 1", n) + } + + // Refusal without the flag is unchanged (escrow present + !deleteEscrow → ErrHostEscrowPresent). + if err := s.DeleteHost(hostID, false); !errors.Is(err, ErrHostEscrowPresent) { + t.Fatalf("delete without the flag: got %v, want ErrHostEscrowPresent", err) + } + + if err := s.DeleteHost(hostID, true); err != nil { + t.Fatalf("DeleteHost: %v", err) + } + if h, _ := s.GetHost(hostID); h != nil { + t.Fatal("host row survived the delete") + } + // The current row is DEMOTED (moved), not kept. + if cur, _ := s.GetHostEscrow(hostID); cur != nil { + t.Fatal("current escrow row survived — should be demoted into superseded, not left as current") + } + // RED-PROOF: BOTH blobs are RETAINED (SHA_A demoted + SHA_OLD spared) — host delete never destroys. + sup, err := s.ListSupersededEscrow(hostID) + if err != nil { + t.Fatal(err) + } + if len(sup) != 2 { + t.Fatalf("retained superseded = %d, want 2 (SHA_A demoted + SHA_OLD spared) — host delete DESTROYED custody", len(sup)) + } + shas := map[string]bool{} + for _, e := range sup { + shas[e.ResticPwSHA256] = true + } + if !shas["SHA_A"] || !shas["SHA_OLD"] { + t.Fatalf("retained shas = %v, want both SHA_A + SHA_OLD", shas) + } + + // EDGE: a host with NO escrow → DeleteHost unchanged (no demote row, no error). + const h2 = "dh2" + if err := s.UpsertHost(&Host{HostID: h2, CustomerID: cust, APIKey: "k2"}); err != nil { + t.Fatal(err) + } + if err := s.DeleteHost(h2, true); err != nil { + t.Fatalf("no-escrow delete: %v", err) + } + if n, _ := s.CountSupersededEscrow(h2); n != 0 { + t.Fatalf("a no-escrow host delete created a superseded row: %d", n) + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 73b369d..18d2219 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -1130,10 +1130,38 @@ func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) { return configs, rows.Err() } -// DeleteCustomerConfig deletes a customer configuration. +// DeleteCustomerConfig deletes a customer configuration AND purges the customer's escrow custody +// (v0.60.1). The customer Danger-zone Delete is the ONE true purge point for recovery-key custody: +// host deletion only DEMOTES a blob to retained custody (never destroys), so removing the customer is +// the deliberate, acknowledged point where that retained custody is permanently removed. In one tx it +// deletes host_escrow AND host_escrow_superseded for ALL the customer's hosts — INCLUDING hosts +// already deleted (whose demoted blobs survive in host_escrow_superseded), resolved via the F-14 +// host_deletions provenance so a host-delete-then-customer-delete ordering leaves nothing orphaned. +// The broader offboarding lifecycle (Hetzner sub-account, WG peer, Storage-Box data, the host rows +// themselves) is NOT this method — see the delete/re-create rehearsal (ROADMAP R-3). func (s *Store) DeleteCustomerConfig(customerID string) error { - _, err := s.db.Exec("DELETE FROM customer_configs WHERE customer_id = ?", customerID) - return err + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + // Current hosts' escrow. + if _, err := tx.Exec(`DELETE FROM host_escrow WHERE host_id IN (SELECT host_id FROM hosts WHERE customer_id = ?)`, customerID); err != nil { + return fmt.Errorf("DeleteCustomerConfig %s: purge host_escrow: %w", customerID, err) + } + // Retained (superseded) blobs for BOTH current and already-deleted hosts of this customer. + if _, err := tx.Exec(` + DELETE FROM host_escrow_superseded WHERE host_id IN ( + SELECT host_id FROM hosts WHERE customer_id = ? + UNION + SELECT host_id FROM host_deletions WHERE customer_id = ? + )`, customerID, customerID); err != nil { + return fmt.Errorf("DeleteCustomerConfig %s: purge host_escrow_superseded: %w", customerID, err) + } + if _, err := tx.Exec(`DELETE FROM customer_configs WHERE customer_id = ?`, customerID); err != nil { + return fmt.Errorf("DeleteCustomerConfig %s: delete config: %w", customerID, err) + } + return tx.Commit() } // GetCustomerConfigByAPIKey looks up a customer config by its unique API key. @@ -1958,8 +1986,9 @@ func (s *Store) CountHostArtifacts(hostID string) (HostArtifacts, error) { // // v0.53.0 (F-14 provenance): every delete also writes a host_deletions row IN THE SAME tx. // escrow_acked = deleteEscrow AND an escrow row was actually present — "removed through the -// escrow-ack flow" means an acknowledged destruction happened, not merely that the checkbox -// was ticked over nothing. +// escrow-ack flow" means the operator acknowledged the host removal and the current escrow blob was +// DEMOTED to retained custody (v0.60.1: moved into host_escrow_superseded, not destroyed), not +// merely that the checkbox was ticked over nothing. The flag's F-14 gate semantics are unchanged. func (s *Store) DeleteHost(hostID string, deleteEscrow bool) error { if hostID == "" { return fmt.Errorf("DeleteHost: empty host_id") @@ -1997,6 +2026,19 @@ func (s *Store) DeleteHost(hostID string, deleteEscrow bool) error { return fmt.Errorf("DeleteHost %s: customer lookup: %w", hostID, err) } + // v0.60.1: host deletion is a LIFECYCLE event — the current escrow blob is DEMOTED to retained + // custody (copied into host_escrow_superseded, copy-BEFORE-delete in this same tx), NEVER + // destroyed; existing superseded rows are spared. No operator path through host lifecycle can + // lose a blob. The customer Danger-zone Delete is the one true purge point (deleteCustomer). + if deleteEscrow { + if _, derr := demoteCurrentEscrowTx(tx, hostID); derr != nil { + return fmt.Errorf("DeleteHost %s: demote escrow to retained custody: %w", hostID, derr) + } + if _, derr := tx.Exec(`DELETE FROM host_escrow WHERE host_id = ?`, hostID); derr != nil { + return fmt.Errorf("DeleteHost %s: remove current escrow row: %w", hostID, derr) + } + } + stmts := []string{ `DELETE FROM guests WHERE host_id = ?`, `DELETE FROM host_reports WHERE host_id = ?`, @@ -2006,12 +2048,8 @@ func (s *Store) DeleteHost(hostID string, deleteEscrow bool) error { `DELETE FROM log_bundle_requests WHERE scope_id = ?`, `DELETE FROM log_bundles WHERE scope_id = ?`, `DELETE FROM wg_peers WHERE host_id = ?`, + `DELETE FROM hosts WHERE host_id = ?`, } - if deleteEscrow { - stmts = append(stmts, `DELETE FROM host_escrow WHERE host_id = ?`) - stmts = append(stmts, `DELETE FROM host_escrow_superseded WHERE host_id = ?`) // retained blobs go with the host - } - stmts = append(stmts, `DELETE FROM hosts WHERE host_id = ?`) for _, q := range stmts { if _, err := tx.Exec(q, hostID); err != nil { return fmt.Errorf("DeleteHost %s: %q: %w", hostID, q, err) @@ -2021,8 +2059,8 @@ func (s *Store) DeleteHost(hostID string, deleteEscrow bool) error { } // HostDeletion is one host-removal provenance record (v0.53.0, F-14). EscrowAcked means the -// operator removed the host through the escrow-ack flow — an acknowledged destruction of the -// host's key custody, the ONLY state that permits the PBS-DR auto-re-issue. +// operator removed the host through the escrow-ack flow — the current key custody was DEMOTED to +// retained custody (v0.60.1), not destroyed — the ONLY state that permits the PBS-DR auto-re-issue. type HostDeletion struct { HostID string CustomerID string @@ -2095,6 +2133,23 @@ type HostEscrow struct { // host_escrow_superseded before overwriting the current row (Part B, v0.60.0). A same-sha re-upload // (idempotent re-ceremony of the same password) refreshes the current row and does NOT create a // superseded row. +// demoteCurrentEscrowTx copies the host's CURRENT host_escrow row (if any) into +// host_escrow_superseded as a retained blob, inside the given tx. This is THE ONE escrow row-copy +// routine (v0.60.0): SaveHostEscrow uses it to retain a superseded different-passphrase blob before +// overwriting, and DeleteHost (v0.60.1) uses it to DEMOTE the current blob to retained custody +// instead of destroying it. Returns the number of rows copied (0 when the host has no current row). +// The hub never decrypts; custody is unchanged. +func demoteCurrentEscrowTx(tx *sql.Tx, hostID string) (int64, error) { + res, err := tx.Exec(` + INSERT INTO host_escrow_superseded (host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, superseded_at) + SELECT host_id, blob, key_fingerprint, posture, created_at, COALESCE(restic_pw_sha256, ''), datetime('now') + FROM host_escrow WHERE host_id = ?`, hostID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, posture, createdAt, resticPwSHA256 string) (superseded bool, err error) { tx, err := s.db.Begin() if err != nil { @@ -2108,13 +2163,9 @@ func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, postu // Retain the current row iff it exists AND seals a DIFFERENT restic password (the incident: a // recreated volume mints a new passphrase; the old must stay recoverable with its recovery code). - var ( - curBlob []byte - curFp, curPosture, curCreated, curSHA string - exists bool - ) - row := tx.QueryRow(`SELECT blob, key_fingerprint, posture, created_at, COALESCE(restic_pw_sha256,'') FROM host_escrow WHERE host_id = ?`, hostID) - switch scanErr := row.Scan(&curBlob, &curFp, &curPosture, &curCreated, &curSHA); scanErr { + var curSHA string + var exists bool + switch scanErr := tx.QueryRow(`SELECT COALESCE(restic_pw_sha256,'') FROM host_escrow WHERE host_id = ?`, hostID).Scan(&curSHA); scanErr { case nil: exists = true case sql.ErrNoRows: @@ -2124,10 +2175,7 @@ func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, postu return false, err } if exists && curSHA != resticPwSHA256 { - if _, err = tx.Exec(` - INSERT INTO host_escrow_superseded (host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, superseded_at) - VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`, - hostID, curBlob, curFp, curPosture, curCreated, curSHA); err != nil { + if _, err = demoteCurrentEscrowTx(tx, hostID); err != nil { return false, err } superseded = true diff --git a/hub/internal/web/escrow_demote_wording_test.go b/hub/internal/web/escrow_demote_wording_test.go new file mode 100644 index 0000000..66e0353 --- /dev/null +++ b/hub/internal/web/escrow_demote_wording_test.go @@ -0,0 +1,30 @@ +package web + +import ( + "strings" + "testing" +) + +// Scenario C (v0.60.1) — the host-delete escrow checkbox must tell the truth: host deletion DEMOTES +// the blob to retained custody, it does not destroy it. Guards the wording against a regression. +func TestHostDeleteEscrowLabel_DemotionWording(t *testing.T) { + b, err := templateFS.ReadFile("templates/host_detail_body.html") + if err != nil { + t.Fatal(err) + } + src := string(b) + if !strings.Contains(src, "Move key escrow to retained custody") { + t.Error("host-delete escrow checkbox must use demotion wording (retained custody)") + } + if strings.Contains(src, "Also delete the key escrow") { + t.Error("host-delete escrow checkbox still uses destruction wording (regression)") + } + // The customer Danger-zone copy names the permanent purge of retained custody (the one true point). + cb, err := templateFS.ReadFile("templates/customer_unified.html") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(cb), "retained recovery-key custody") { + t.Error("customer Danger-zone copy must name the permanent removal of retained recovery-key custody") + } +} diff --git a/hub/internal/web/hosts.go b/hub/internal/web/hosts.go index 2394dce..79ed97c 100644 --- a/hub/internal/web/hosts.go +++ b/hub/internal/web/hosts.go @@ -485,7 +485,7 @@ func (s *Server) handleHostDelete(w http.ResponseWriter, r *http.Request, hostID if err := s.store.DeleteHost(hostID, deleteEscrow); err != nil { if errors.Is(err, store.ErrHostEscrowPresent) { s.logger.Printf("[WARN] host delete refused: %s has key escrow (acknowledgement missing)", hostID) - http.Error(w, "This host has a key escrow (+ DR bundle). Tick the escrow acknowledgement to delete it too — nothing deleted.", http.StatusConflict) + http.Error(w, "This host has a key escrow (+ DR bundle). Tick the escrow acknowledgement to move it to retained custody — nothing deleted.", http.StatusConflict) return } s.logger.Printf("[ERROR] host delete %s: %v", hostID, err) diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 87cbad0..e34af3e 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -706,7 +706,7 @@ Customer Info header — endpoints and confirm() handlers unchanged. -->

Danger zone

-

Blocking hides the customer from the Dashboard (reports are still accepted); deleting removes the managed configuration permanently.

+

Blocking hides the customer from the Dashboard (reports are still accepted); deleting removes the managed configuration permanently — and permanently removes the retained recovery-key custody (escrow blobs) for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it.

{{if .IsBlocked}}
diff --git a/hub/internal/web/templates/host_detail_body.html b/hub/internal/web/templates/host_detail_body.html index c10e380..9046ef4 100644 --- a/hub/internal/web/templates/host_detail_body.html +++ b/hub/internal/web/templates/host_detail_body.html @@ -284,7 +284,7 @@

Type the host id to confirm: