diff --git a/hub/internal/web/pbsdr.go b/hub/internal/web/pbsdr.go index bb32cc1..9179468 100644 --- a/hub/internal/web/pbsdr.go +++ b/hub/internal/web/pbsdr.go @@ -205,11 +205,38 @@ func (s *Server) pbsdrProvisionAtom(ctx context.Context, customerID string, host defer cancel() res, err := s.tenantsync.Provision(ctx, customerID) if errors.Is(err, tenantsync.ErrTokenExists) { - // ep0 has a token but the hub has no descriptor — state mismatch (lost hub state or a - // half-torn earlier attempt). Never silently re-key: the operator decides via Re-issue. - return "", fmt.Errorf("the endpoint already holds a PBS token for %s but the hub has no descriptor — use the explicit \"Re-issue PBS credentials\" action", customerID) - } - if err != nil { + // ep0 has a token but the hub has no descriptor — state mismatch (lost hub state, a + // half-torn earlier attempt, or the F-14 shape: host deleted, tenancy survived). + // + // F-14 gate (operator ruling 2026-07-13): auto-re-issue is permitted ONLY when the + // hub's own deletion record shows the tenancy's owning host — the customer's most + // recent host deletion — was removed through the escrow-ack flow. Acknowledged + // destruction is not silent re-keying; the old secret went down with the acked host. + // No record / un-acked record → the refusal below, byte-unchanged (manual path). + rec, derr := s.store.LatestHostDeletion(customerID) + if derr != nil { + return "", fmt.Errorf("pbsdr: deletion-provenance lookup for %s: %w", customerID, derr) + } + if rec == nil || !rec.EscrowAcked { + return "", fmt.Errorf("the endpoint already holds a PBS token for %s but the hub has no descriptor — use the explicit \"Re-issue PBS credentials\" action", customerID) + } + res, err = s.tenantsync.Reissue(ctx, customerID) // the EXISTING re-issue op — no new endpoint interaction + if err != nil { + return "", fmt.Errorf("pbsdr: F-14 auto re-issue for %s: %w", customerID, err) + } + note := "Previous key destroyed (acknowledged deletion) — credentials re-issued automatically." + details, _ := json.Marshal(map[string]string{ + "deleted_host": rec.HostID, + "deleted_at": rec.DeletedAt.UTC().Format(time.RFC3339), + "new_host": host.HostID, + "token_id": res.TokenID, + }) + if _, eerr := s.store.SaveEvent(customerID, "pbsdr_auto_reissue", "info", note, string(details), "hub"); eerr != nil { + s.logger.Printf("[WARN] pbsdr: F-14 audit event for %s not stored: %v", customerID, eerr) + } + s.logger.Printf("[INFO] pbsdr F-14 auto re-issue for %s: owning host %s removed via escrow-ack flow (%s) — %s", + customerID, rec.HostID, rec.DeletedAt.UTC().Format(time.RFC3339), note) + } else if err != nil { return "", err } diff --git a/hub/internal/web/pbsdr_test.go b/hub/internal/web/pbsdr_test.go index af129b6..072080d 100644 --- a/hub/internal/web/pbsdr_test.go +++ b/hub/internal/web/pbsdr_test.go @@ -26,7 +26,8 @@ import ( type fakeTenancy struct { provisionCalls int reissueCalls int - err error + err error // both ops fail with this + provisionErr error // Provision-only failure (the F-14 token_exists shape: reissue still works) secret string } @@ -45,6 +46,9 @@ func (f *fakeTenancy) Provision(ctx context.Context, customerID string) (*tenant if f.err != nil { return nil, f.err } + if f.provisionErr != nil { + return nil, f.provisionErr + } return f.result(customerID), nil } @@ -372,6 +376,115 @@ func TestPBSDR_StorageIDChangeUpdatesDescriptorOnly(t *testing.T) { } } +// F-14 scenario A (v0.53.0, operator ruling 2026-07-13): a tenancy orphaned by an ESCROW-ACKED +// host delete → the enable path auto-re-issues via the EXISTING tenantsync re-issue op, writes +// the audit event, and provisioning proceeds to a full descriptor + consume-once secret. +// RED-PROOF (Part 1): dropping the provenance write from DeleteHost's tx → the gate finds no +// record → this test fails with the 502 refusal. +func TestPBSDR_F14AutoReissueOnAckedDeletion(t *testing.T) { + fake := &fakeTenancy{provisionErr: tenantsync.ErrTokenExists, secret: "REISSUED-SECRET"} + s, st, logBuf := newPBSDRServer(t, fake) + + // The F-14 history: the tenancy's owning host was deleted through the escrow-ack flow + // (real DeleteHost, real escrow row — no hand-set provenance). + if err := st.UpsertHost(&store.Host{HostID: "peti-00-dead", CustomerID: "peti", APIKey: "oldkey"}); err != nil { + t.Fatal(err) + } + if err := st.SaveHostEscrow("peti-00-dead", []byte("opaque"), "fp", "posture", "2026-07-01T00:00:00Z", ""); err != nil { + t.Fatal(err) + } + if err := st.DeleteHost("peti-00-dead", true); err != nil { + t.Fatalf("escrow-ack delete: %v", err) + } + + rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}}) + if rr.Code != 303 { + t.Fatalf("enable with acked deletion record = %d (%s), want 303 (auto re-issue proceeds)", rr.Code, rr.Body.String()) + } + if fake.provisionCalls != 1 || fake.reissueCalls != 1 { + t.Errorf("calls = provision %d / reissue %d, want 1/1 (the EXISTING re-issue op, once)", fake.provisionCalls, fake.reissueCalls) + } + + // Full effect: descriptor landed, generation bumped, fresh consume-once secret staged. + desc, desiredJSON, gen := hostState(t, st) + if desc == nil || !desc.Enabled || desc.Namespace != "peti" || desc.TokenID != "felhom@pbs!peti" { + t.Fatalf("descriptor after auto re-issue = %+v (json %s)", desc, desiredJSON) + } + if gen != 1 { + t.Errorf("generation = %d, want 1", gen) + } + if got, err := st.ConsumeHostPBSSecret("peti-01"); err != nil || got != "REISSUED-SECRET" { + t.Fatalf("consume-once secret = (%q, %v), want the re-issued secret", got, err) + } + + // The audit line: a stored hub-source event carrying the operator note. + events, err := st.GetRecentEvents("peti", 10) + if err != nil { + t.Fatalf("events: %v", err) + } + var audit *store.Event + for i := range events { + if events[i].EventType == "pbsdr_auto_reissue" { + audit = &events[i] + } + } + if audit == nil { + t.Fatal("no pbsdr_auto_reissue audit event stored") + } + if audit.Source != "hub" || !strings.Contains(audit.Message, "Previous key destroyed (acknowledged deletion)") { + t.Errorf("audit event = %+v, want hub-source with the operator note", audit) + } + if !strings.Contains(audit.DetailsJSON, "peti-00-dead") { + t.Errorf("audit details lack the deleted host: %s", audit.DetailsJSON) + } + if strings.Contains(logBuf.String(), "REISSUED-SECRET") { + t.Error("secret leaked into the hub log") + } +} + +// F-14 scenario B (the never-silently-re-key law): a surviving tenancy WITHOUT an acked +// deletion record — none at all, or the latest one un-acked — keeps the current refusal and +// records ZERO re-issue calls (the exact non-effect). +// RED-PROOF (§10 B): removing the record check in pbsdrProvisionAtom (auto-reissue +// unconditionally) → the zero-reissue assertions here fail. +func TestPBSDR_F14NoRecordNeverRekeys(t *testing.T) { + assertRefused := func(t *testing.T, fake *fakeTenancy, s *Server, st *store.Store) { + t.Helper() + rr := postUpdate(t, s, url.Values{"dr_tier": {"on"}}) + if rr.Code != 502 || !strings.Contains(rr.Body.String(), "Re-issue PBS credentials") { + t.Fatalf("save = %d (%s), want the byte-unchanged 502 refusal", rr.Code, rr.Body.String()) + } + if fake.reissueCalls != 0 { + t.Errorf("reissue calls = %d, want 0 — SILENT RE-KEY", fake.reissueCalls) + } + if desc, _, gen := hostState(t, st); desc != nil || gen != 0 { + t.Errorf("state written on refusal: desc=%+v gen=%d", desc, gen) + } + if _, err := st.ConsumeHostPBSSecret("peti-01"); err != sql.ErrNoRows { + t.Errorf("a secret was staged on refusal (err %v)", err) + } + } + + t.Run("no deletion record at all", func(t *testing.T) { + fake := &fakeTenancy{provisionErr: tenantsync.ErrTokenExists, secret: "S"} + s, st, _ := newPBSDRServer(t, fake) + assertRefused(t, fake, s, st) + }) + + t.Run("latest record un-acked", func(t *testing.T) { + fake := &fakeTenancy{provisionErr: tenantsync.ErrTokenExists, secret: "S"} + s, st, _ := newPBSDRServer(t, fake) + // A deletion happened, but NOT through the escrow-ack flow (no escrow row → acked=false). + if err := st.UpsertHost(&store.Host{HostID: "peti-00-dead", CustomerID: "peti", APIKey: "oldkey"}); err != nil { + t.Fatal(err) + } + if err := st.DeleteHost("peti-00-dead", true); err != nil { + t.Fatal(err) + } + assertRefused(t, fake, s, st) + }) +} + func TestPBSDR_Reissue(t *testing.T) { fake := &fakeTenancy{secret: "OLD-SECRET"} s, st, logBuf := newPBSDRServer(t, fake)