Files
felhom.eu/hub/internal/store/customer_delete.go
T
admin 9b3381be0a feat(hub): v0.70.0 — a deleted customer actually disappears (residue leg + ghost cleanup)
Found validating v0.69.0 against the live hub. demo-vm-felhom was deleted
on 07-18 and was still on the Customers list AND still raising offsite_stale
(10 events, latest 07-21 17:34, operator email at 19:34) — because
GetCustomers() is report-derived and no lifecycle tier ever deleted a report.

New leg 3 (residue), before the record purge: reports, app_telemetry,
app_log_tails, log_tail_requests, customer_notifications, plus the
credential-bearing appliance_registrations and selfbind_tokens. Audit
(events, notification_log) and F-14 provenance still survive.

Ghost customers are now deletable: 404 means "nothing here", not "no config
row". With no config row the offsite descriptor is unknowable, so the Hetzner
and descriptor legs record skipped_no_config rather than a bare "skipped".

Two more red-proofs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J55BQE1gE2V4ffud5jweGS
2026-07-21 20:28:06 +02:00

97 lines
4.4 KiB
Go

package store
import "fmt"
// Customer DELETE residue (v0.70.0, R-25b follow-up).
//
// The v0.69.0 cascade tore down hosts, external state and the customer record — and a fully deleted
// customer STILL came back on the Customers list, because `GetCustomers()` derives the list purely
// from the REPORT stream (`SELECT ... FROM reports ... GROUP BY customer_id`). Nothing in any
// lifecycle tier ever deleted a report. Worse than cosmetic: the staleness and offsite checkers
// iterate the same report-derived list, so a deleted customer kept raising `offsite_stale` and kept
// emailing the operator — observed live on `demo-vm-felhom`, deleted 2026-07-18, still alerting
// 2026-07-21.
//
// Two of these tables are not telemetry at all but CREDENTIAL-BEARING, and outliving their customer
// is a security defect rather than noise:
//
// - `appliance_registrations` — a `token_hash` + `status='delivered'` row binding a box to the
// customer id. Deleting the row returns a still-living box to the unclaimed pool on its next
// registration, which is exactly the right state for a decommissioned appliance.
// - `selfbind_tokens` — an unconsumed 7-day bind token would remain a working path to bind a box
// to a customer that no longer exists.
//
// What deliberately SURVIVES (unchanged from every other tier): `events` (the audit stream),
// `notification_log` (the send-attempt audit), `host_deletions` and `customer_resets` (F-14
// provenance). The audit trail outlives every lifecycle tier — that rule is not relaxed here.
// CustomerResidue counts what a customer has left behind OUTSIDE the config row: the report-derived
// state that keeps a deleted customer visible and alerting, plus the credential-bearing bindings.
// Counts only — never a token, hash or payload.
type CustomerResidue struct {
Reports int
AppTelemetry int
AppLogTails int
LogTailRequests int
NotificationPrefs int
SelfBindTokens int
ApplianceRegistrations int
}
// Total is the single number the "is there anything left at all?" decision hangs on.
func (r *CustomerResidue) Total() int {
if r == nil {
return 0
}
return r.Reports + r.AppTelemetry + r.AppLogTails + r.LogTailRequests +
r.NotificationPrefs + r.SelfBindTokens + r.ApplianceRegistrations
}
// residueQueries is the ONE list both the counter and the purge walk, so a table can never be
// counted-but-not-purged (or purged-but-not-counted) — the two drift silently otherwise.
var residueQueries = []struct {
table string
dst func(*CustomerResidue) *int
}{
{"reports", func(r *CustomerResidue) *int { return &r.Reports }},
{"app_telemetry", func(r *CustomerResidue) *int { return &r.AppTelemetry }},
{"app_log_tails", func(r *CustomerResidue) *int { return &r.AppLogTails }},
{"log_tail_requests", func(r *CustomerResidue) *int { return &r.LogTailRequests }},
{"customer_notifications", func(r *CustomerResidue) *int { return &r.NotificationPrefs }},
{"selfbind_tokens", func(r *CustomerResidue) *int { return &r.SelfBindTokens }},
{"appliance_registrations", func(r *CustomerResidue) *int { return &r.ApplianceRegistrations }},
}
// CustomerResidue counts the customer's report-derived + binding residue. Read-only.
func (s *Store) CustomerResidue(customerID string) (*CustomerResidue, error) {
res := &CustomerResidue{}
for _, q := range residueQueries {
if err := s.db.QueryRow(`SELECT COUNT(*) FROM `+q.table+` WHERE customer_id = ?`, customerID).
Scan(q.dst(res)); err != nil {
return nil, fmt.Errorf("CustomerResidue %s: count %s: %w", customerID, q.table, err)
}
}
return res, nil
}
// PurgeCustomerResidue removes the report-derived state and the credential-bearing bindings in ONE
// transaction — the leg that actually makes a deleted customer disappear from the Customers list and
// stop alerting. Idempotent: a re-run deletes nothing extra. It NEVER touches events,
// notification_log, host_deletions or customer_resets.
func (s *Store) PurgeCustomerResidue(customerID string) error {
if customerID == "" {
return fmt.Errorf("PurgeCustomerResidue: empty customer_id")
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, q := range residueQueries {
if _, err := tx.Exec(`DELETE FROM `+q.table+` WHERE customer_id = ?`, customerID); err != nil {
return fmt.Errorf("PurgeCustomerResidue %s: purge %s: %w", customerID, q.table, err)
}
}
return tx.Commit()
}