hub v0.125.0: the customer delete lists the Cloudflare items to remove by hand instead of promising it (R-688)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-25 13:27:08 +02:00
parent 3386041e62
commit ccd915ff34
4 changed files with 87 additions and 3 deletions
+30 -1
View File
@@ -38,13 +38,15 @@ import (
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// deleteCascadeAcks is the three-acknowledgement gate. Every one is REQUIRED — there is no force or
// skip flag anywhere in this file (a missing ack is a refusal, never a downgrade to a partial run).
type deleteCascadeAcks struct {
Hosts bool // "N host(s) will be deleted — recovery-key custody is demoted, not destroyed"
Reset bool // "the customer will be RESET — offsite repo DESTROYED, PBS revoked, tunnel/zone removed"
Reset bool // "the customer will be RESET — offsite repo DESTROYED, PBS revoked" (Cloudflare is NOT touched — R-688)
Purge bool // "the customer record and ALL escrow ciphertext are PURGED — unrecoverable"
}
@@ -125,6 +127,10 @@ func (s *Server) handleCustomerDeletePreview(w http.ResponseWriter, r *http.Requ
if cfg != nil {
customerName = cfg.CustomerName
}
// R-688 (v0.125.0): NO leg of the cascade calls Cloudflare. The dialog used to promise "tunnel and
// zone removed"; it now LISTS what the operator removes by hand, by name. Names and booleans only —
// never the token itself.
cfManual := cloudflareManualRemoval(cfg)
// An incomplete journal row = a cascade that stopped mid-way; the dialog renders it + Resume.
var pending map[string]any
if cr, jerr := s.store.LatestCustomerReset(customerID); jerr != nil {
@@ -153,6 +159,7 @@ func (s *Server) handleCustomerDeletePreview(w http.ResponseWriter, r *http.Requ
"offsite_identifier": offsiteName,
"pbs_tenancy_configured": s.tenantsync != nil,
"pending_journal": pending,
"cloudflare_manual": cfManual,
"residue_total": residue.Total(),
"residue": map[string]int{
"reports": residue.Reports,
@@ -304,3 +311,25 @@ func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request, cu
s.bumpIntent(customerID) // Direction-2: wake any still-holding wait so it completes promptly
http.Redirect(w, r, "/configs?flash=deleted", http.StatusSeeOther)
}
// cloudflareManualRemoval names what the customer delete leaves on Cloudflare's side (R-688): the cascade
// has no Cloudflare leg, so the operator removes these by hand. Nil when the customer has no domain.
func cloudflareManualRemoval(cfg *store.CustomerConfig) []string {
if cfg == nil || strings.TrimSpace(cfg.Domain) == "" {
return nil
}
d := strings.TrimSpace(cfg.Domain)
items := []string{
"the Cloudflare tunnel that serves " + d,
"the DNS records of " + d + " (the apex and *." + d + ")",
}
var c struct {
Infrastructure map[string]any `json:"infrastructure"`
}
if json.Unmarshal([]byte(cfg.ConfigJSON), &c) == nil {
if v, _ := c.Infrastructure["cf_tunnel_token"].(string); strings.TrimSpace(v) != "" {
items[0] += " (this customer's config carried its own tunnel token)"
}
}
return items
}
+36
View File
@@ -21,6 +21,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing"
@@ -585,3 +586,38 @@ func TestDeleteCascade_404WhenNothingRemains(t *testing.T) {
t.Errorf("cascade for an empty id = %d, want 404", rr2.Code)
}
}
// TestR688_PreviewNamesCloudflareByHand — no leg of the cascade calls Cloudflare (R-688), so the preview
// names what the operator removes by hand, by the customer's domain, and never carries the token; and the
// dialog no longer promises "tunnel and zone are removed".
// COMPANION RED-PROOF (REPORT.md): drop "cloudflare_manual" from the preview — this fails.
func TestR688_PreviewNamesCloudflareByHand(t *testing.T) {
s, st := newTestServer(t)
seedDeletable(t, st, "acme")
cfg, _ := st.GetCustomerConfig("acme")
cfg.ConfigJSON = `{"infrastructure":{"cf_tunnel_token":"SECRET-TUNNEL-TOKEN"}}`
if err := st.SaveCustomerConfig(cfg); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.handleCustomerDeletePreview(rr, httptest.NewRequest("GET", "/configs/acme/delete", nil), "acme")
body := rr.Body.String()
for _, want := range []string{`"cloudflare_manual":[`, "the Cloudflare tunnel that serves acme.example", "*.acme.example"} {
if !strings.Contains(body, want) {
t.Errorf("preview missing %q\nbody: %s", want, body)
}
}
if strings.Contains(body, "SECRET-TUNNEL-TOKEN") {
t.Fatal("the preview leaked the tunnel token")
}
tpl, err := os.ReadFile("templates/customer_unified.html")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(tpl), "tunnel and zone are removed") || strings.Contains(string(tpl), "tunnel and zone removed") {
t.Fatal("the delete dialog still promises a Cloudflare removal no leg performs")
}
if !strings.Contains(string(tpl), "cloudflare_manual") {
t.Fatal("the dialog does not render the manual-removal list")
}
}
@@ -860,7 +860,7 @@
{{if .Deletable}}
<section class="card">
<h2>Danger zone</h2>
<p class="text-muted">{{if not .HasConfig}}<strong style="color: var(--warn);">Ghost customer</strong> — the configuration record is already gone; Delete is the applicable action. {{end}}Blocking hides the customer from the Dashboard (reports are still accepted). <strong>Delete customer</strong> is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked, tunnel and zone removed), then purges the customer record and all escrow ciphertext — including the <strong>retained recovery-key custody</strong> for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use <em>Ügyfél-visszaállítás (RESET)</em> above instead.</p>
<p class="text-muted">{{if not .HasConfig}}<strong style="color: var(--warn);">Ghost customer</strong> — the configuration record is already gone; Delete is the applicable action. {{end}}Blocking hides the customer from the Dashboard (reports are still accepted). <strong>Delete customer</strong> is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked — <strong>Cloudflare is not touched</strong>: the tunnel and DNS records are removed by hand, and the dialog lists them), then purges the customer record and all escrow ciphertext — including the <strong>retained recovery-key custody</strong> for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use <em>Ügyfél-visszaállítás (RESET)</em> above instead.</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
{{if .HasConfig}}{{/* v0.70.1: blocking gates dashboard visibility of a CONFIGURED customer — meaningless for a ghost */}}
{{if .IsBlocked}}
@@ -890,7 +890,7 @@
</label>
<label style="display: block; margin: 0 0 0.5rem; font-size: 0.85em;">
<input type="checkbox" id="cust-del-ack2-{{.CustomerID}}">
<strong>2.</strong> The customer will be <strong>RESET</strong> — the offsite repository is <strong>DESTROYED</strong>, PBS credentials are revoked, tunnel and zone are removed.
<strong>2.</strong> The customer will be <strong>RESET</strong> — the offsite repository is <strong>DESTROYED</strong> and PBS credentials are revoked. <strong>Cloudflare is not touched</strong> — I remove the items listed above by hand.
</label>
<label style="display: block; margin: 0 0 0.75rem; font-size: 0.85em; color: var(--crit);">
<input type="checkbox" id="cust-del-ack3-{{.CustomerID}}">
@@ -951,6 +951,16 @@
inv.innerHTML = '<strong>Will be destroyed:</strong> ' + dies.join(', ') +
'. <strong>Custody:</strong> ' + custody + ' (purged in the final leg). ' +
'<strong>Survives:</strong> the audit event stream, the notification log and the deletion provenance.';
// R-688: the cascade has NO Cloudflare leg — name what the operator removes by hand.
if (d.cloudflare_manual && d.cloudflare_manual.length) {
var ul = document.createElement('div');
ul.style.marginTop = '0.4em';
var head = document.createElement('strong');
head.textContent = 'Not removed by the hub — remove by hand in Cloudflare: ';
ul.appendChild(head);
ul.appendChild(document.createTextNode(d.cloudflare_manual.join('; ') + '.'));
inv.appendChild(ul);
}
if (d.has_config === false) {
inv.innerHTML = '<strong style="color: var(--warn)">Ghost customer:</strong> the configuration record ' +
'is already gone, but ' + d.residue_total + ' row(s) of report/telemetry state keep it on the ' +