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
+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