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:
+71
-23
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user