hub v0.60.1: host deletion demotes escrow custody (never destroys) + customer-delete purge point + S6b obsolete

- DeleteHost(deleteEscrow) demotes current host_escrow into host_escrow_superseded (copy-before-delete, same tx), spares existing; one shared demoteCurrentEscrowTx (reused by SaveHostEscrow). F-14 provenance/gate unchanged.
- DeleteCustomerConfig now purges both escrow tables for all the customer's hosts incl. already-deleted (F-14 provenance UNION) — the one true purge point.
- Wording: checkbox/refusal/Danger-zone → demotion. S6b OBSOLETE. Red-proofs TestDeleteHost_Demotes + TestDeleteCustomer_Purges + wording guard.
This commit is contained in:
2026-07-17 11:25:38 +02:00
parent 106c3379b0
commit 2752e12acc
12 changed files with 310 additions and 59 deletions
+12
View File
@@ -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
+48 -31
View File
@@ -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.
@@ -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 |
+1 -1
View File
@@ -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
+30
View File
@@ -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
@@ -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)
}
}
}
@@ -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)
}
}
+71 -23
View File
@@ -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
@@ -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")
}
}
+1 -1
View File
@@ -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)
@@ -706,7 +706,7 @@
Customer Info header — endpoints and confirm() handlers unchanged. -->
<section class="card">
<h2>Danger zone</h2>
<p class="text-muted">Blocking hides the customer from the Dashboard (reports are still accepted); deleting removes the managed configuration permanently.</p>
<p class="text-muted">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.</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
{{if .IsBlocked}}
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
@@ -284,7 +284,7 @@
<p id="host-delete-impact-{{.HostID}}" style="margin: 0 0 0.5rem; font-size: 0.9em;">&hellip;</p>
<label id="host-delete-escrow-row-{{.HostID}}" style="display: none; margin: 0 0 0.5rem; font-size: 0.85em;">
<input type="checkbox" id="host-delete-escrow-{{.HostID}}">
Also delete the key escrow + DR bundle for this host
Move key escrow to retained custody (required when escrow present) + remove DR bundle for this host
</label>
<p style="margin: 0 0 0.5rem; font-size: 0.85em; color: var(--text-2);">Type the host id to confirm:</p>
<form method="POST" action="/hosts/{{.HostID}}/delete" id="host-delete-form-{{.HostID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">