Files
felhom.eu/hub/internal/store/customer_delete.go
T
admin 655f5f66f8
gates / gates (push) Successful in 6m0s
hub v0.147.0 (code): the operator's answers S4, S6, S10, S29, S30, S32, S33
- S4 / R-242: GoldenVouchChecker - a published golden unvouched for 2 h raises golden_not_vouched (operator only)
- S6 / R-244: the delete cascade de-references app_log_issues; one-time sweep flag -sweep-app-log-residue=count|apply
- S10 / R-266: read_failed storage entry shows "read failed", not 0 %
- S29 / R-250: host-key scan IPv4 first, ~2 min ladder, "safe to press Save again" error
- S30 / R-246: stale_at retired (reader, setter, badge)
- S32 / R-521: storage_reconnected ends the storage_disconnected cooldown on both channels
- S33 / R-402: host page "Last full check (off-site)" line; two wire-gate allowlist entries removed
Each with a test seen failing first. Not built or deployed yet (operator-attended deploy).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 14:54:14 +02:00

109 lines
5.0 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
// AppLogIssueRefs (R-244, v0.147.0): shared app_log_issues rows naming the customer. De-referenced
// (never blindly deleted) by the purge — see app_log_residue.go.
AppLogIssueRefs 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 + r.AppLogIssueRefs
}
// 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)
}
}
refs, err := appIssueRefsFor(s.db, customerID)
if err != nil {
return nil, fmt.Errorf("CustomerResidue %s: count app_log_issues refs: %w", customerID, err)
}
res.AppLogIssueRefs = len(refs)
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)
}
}
// R-244: de-reference (not delete) the customer in the shared app-log issue rows.
if _, _, err := dereferenceCustomerFromAppIssues(tx, customerID); err != nil {
return fmt.Errorf("PurgeCustomerResidue %s: app_log_issues: %w", customerID, err)
}
return tx.Commit()
}