carlos / platform Public

console: store teardown in purge door, destroy ceremony, settings card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0e7d91b8442ef32de257655c01c33504f815b7ce parent 146d7af
8 files changed, +817 −6
  • internal/console/appsettings.go +210 −0
  • internal/console/appsettings_test.go +257 −0
  • internal/console/deletion_test.go +182 −0
  • internal/console/ownership.go +50 −0
  • internal/console/server.go +18 −6
  • internal/console/templates/app_settings.html +56 −0
  • internal/objstore/walk.go +11 −0
  • internal/objstore/walk_test.go +33 −0
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index 6291114b3..b0b941bdb 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -25,12 +25,14 @@ package console
import (
"context"
"crypto/subtle"
+ "errors"
"log"
"net/http"
"net/url"
"strings"
"time"
+ "github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/release"
)
@@ -50,6 +52,40 @@ type SettingsView struct {
MismatchTarget string
TargetKnown bool
ViewerInTarget bool
+
+ // Store is the app's object store as this page sees it — nil when the
+ // record could not be read (the page says nothing rather than guessing),
+ // and a zero card when there is genuinely no store, which is what the
+ // "ask for one" line renders from.
+ Store *StoreCard
+}
+
+// StoreCard is the Settings tab's view of apps/<acct>/<app>/store.json (issue
+// #55): the three blocks of the record folded into what a person with no box
+// access can read and act on.
+//
+// KeyAge is the age of Provisioned.At and is labelled as the KEY's age
+// everywhere it appears, never as when the store was provisioned: that
+// timestamp moves on every rotation AND on every grant repair (objstore walk.go
+// step 6), so "provisioned 2 minutes ago" would be a sentence the page tells
+// wrongly forever after the first repair.
+//
+// CanDestroy is the affordance, not the authority — the handler re-decides all
+// of it (a page can be hours old, and StoreOperators/ownership are checked
+// there). It is true when there is an estate AND a backend to tear it down
+// with, or when the record is a declaration nobody has acted on, which needs no
+// backend at all because nothing outside the bucket exists yet.
+type StoreCard struct {
+ Declared bool
+ EnvPrefix string
+ Granted bool
+ Backend string
+ Bucket string
+ Provisioned bool
+ KeyID string
+ KeyAge string
+ PendingFinish bool
+ CanDestroy bool
}
// assembleSettings fills the Settings tab's view. Best-effort by design: a
@@ -71,6 +107,7 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
default:
sv.CanRename = true
}
+ sv.Store = s.storeCard(ctx, acct, p.AppName)
if ra := p.Detail.RouteAccount; ra != "" {
sv.MismatchTarget = ra
if target, err := s.store.AccountBySqid(ra); err == nil && target != nil {
@@ -83,6 +120,53 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
return sv
}
+// storeCard folds the store record into the Settings card. Best-effort like
+// everything else this page assembles: a read failure returns nil and the
+// section is simply absent, because "could not read the record" and "there is
+// no store" must not look the same on a page whose destroy button acts on the
+// difference. An absent record is a zero card — the state that renders the one
+// line telling a member how to ask for a store.
+func (s *Server) storeCard(ctx context.Context, acct *Account, app string) *StoreCard {
+ rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: settings store read %s/%s: %v", acct.Sqid, app, err)
+ return nil
+ }
+ if rec == nil {
+ return &StoreCard{}
+ }
+ card := &StoreCard{}
+ if rec.Declared != nil {
+ card.Declared = true
+ card.EnvPrefix = rec.Declared.EnvPrefix
+ }
+ if g := rec.Grant; g != nil {
+ card.Granted = true
+ card.Backend = g.Backend
+ card.Bucket = g.Bucket
+ }
+ if pr := rec.Provisioned; pr != nil {
+ card.Provisioned = true
+ if pr.Bucket != "" {
+ // The provisioned block wins: the bucket an app's data is in is
+ // whatever was recorded at provision time, which is also the word
+ // the destroy door will make an owner type.
+ card.Bucket = pr.Bucket
+ }
+ card.KeyID = pr.KeyID
+ if !pr.At.IsZero() {
+ card.KeyAge = humanAge(time.Since(pr.At)) + " ago"
+ }
+ card.PendingFinish = pr.PrevKeyID != ""
+ }
+ if card.Granted || card.Provisioned {
+ card.CanDestroy = s.cfg.StoreProvisioner != nil
+ } else {
+ card.CanDestroy = true // a declaration: nothing outside the bucket to tear down
+ }
+ return card
+}
+
// appDeployEmpty reports whether the bucket holds ANYTHING under the app's
// prefix — account-qualified or legacy-unqualified. One key is enough to
// refuse a rename; the caller never needs the listing itself.
@@ -336,6 +420,132 @@ func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther)
}
+// handleStoreDestroy dismantles an app's object store (issue #55, objstore spec
+// §9): the bucket's contents, the bucket, the IAM principal holding its key,
+// the delivered variables, and the record naming them — in that order, last
+// first, so a failure anywhere leaves the record still naming a standing
+// estate.
+//
+// It is the sibling of handlePurgeApp (ownership.go) and wears the same
+// ceremony, in the same order handleAppDelete above wears it — session, CSRF,
+// owner's byte-identical 404, the typed confirmation, and requireFreshAuth
+// LAST, so no redirect into Keymail ever happens for someone who should have
+// got a 404:
+//
+// owner — a member probing this gets a stranger's answer.
+// CSRF — constant-time, before any pending action can be parked.
+// type the BUCKET — not the app's name: the bucket is the thing that stops
+// existing, and its objects are what cannot be brought
+// back. A declared-only record has no bucket, so there the
+// word is the app's name — the only noun it has.
+// no backend — refused here, before the passkey, on an estate this
+// console has no authority over: no gesture could change
+// the answer, so asking for one would waste it.
+// requireFreshAuth — LAST of the auth gates.
+//
+// There is no trash for a store and no undo: the objects are gone, and the
+// credentials with them. Teardown itself re-reads the record immediately before
+// the final delete and refuses (wrapping release.ErrCASConflict) if it changed
+// underneath — a grant landing while this form sat open wins, exactly as a
+// restore wins over a purge.
+func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, app, ok := s.accountApp(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ if role != RoleOwner {
+ http.NotFound(w, r)
+ return
+ }
+ // The local refusal: a flash and the settings tab, mirroring trashFailure's
+ // shape for the Trash page. Not a 404 — everyone reaching it has passed the
+ // ownership gate and is entitled to know what went wrong.
+ fail := func(msg string) {
+ s.setFlash(w, "err", msg)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ }
+ if s.cfg.Releases == nil {
+ fail("No deployment bucket on this host, so this console cannot read the store record — nothing was destroyed.")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
+ defer cancel()
+ rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: store destroy read %s/%s: %v", acct.Sqid, app, err)
+ fail("Could not read “" + app + "”'s object-store record — nothing was destroyed. Try again.")
+ return
+ }
+ if rec == nil {
+ fail("“" + app + "” has no object store, so there is nothing to destroy.")
+ return
+ }
+ want := storeConfirmWord(rec, app)
+ if strings.TrimSpace(r.FormValue("confirm")) != want {
+ fail("Type “" + want + "” exactly to destroy this store. Nothing was destroyed.")
+ return
+ }
+ // Grant OR Provisioned is an estate: a grant block on its own is a grant
+ // that crashed after creating the bucket and the key but before recording
+ // them (objstore.Teardown's own rule), and tearing that down is backend
+ // work like any other.
+ estate := rec.Grant != nil || rec.Provisioned != nil
+ if estate && s.cfg.StoreProvisioner == nil {
+ fail("This console has no storage backend configured (CARLOS_STORE_BACKEND), so it cannot " +
+ "delete “" + app + "”'s bucket or its credentials — nothing was destroyed. An operator of " +
+ "this deployment has to tear it down.")
+ return
+ }
+ if !s.requireFreshAuth(w, r, addr, raw) {
+ return
+ }
+ if err := objstore.Teardown(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, acct.Sqid, app); err != nil {
+ log.Printf("console: store destroy %s/%s: %v", acct.Sqid, app, err)
+ if errors.Is(err, release.ErrCASConflict) {
+ fail("The store changed while you confirmed — nothing was destroyed; reload and try again.")
+ return
+ }
+ fail("Could not destroy “" + app + "”'s object store — it is still there; try again once the " +
+ "backend is reachable.")
+ return
+ }
+ what, flash := "the store declaration", "Withdrew “"+app+"”'s store declaration."
+ if estate {
+ what = "bucket " + want
+ flash = "Destroyed “" + app + "”'s object store: bucket " + want + ", its credentials and the " +
+ "variables they were delivered in are gone."
+ }
+ s.audit(acct, app, addr, "store-destroy", "destroyed the object store ("+what+")")
+ // Box-consumed: the delivered variables were UNSET in the app's config
+ // (objstore.Teardown), so every host still serving this app has an env file
+ // naming a bucket and a key that authenticate against nothing. Without the
+ // bump that stands until the hourly floor comes round.
+ s.bumpSerial(ctx)
+ s.setFlash(w, "ok", flash)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+}
+
+// storeConfirmWord is what a person must type to destroy a store: the BUCKET
+// when the record names one, because the bucket and its objects are what stop
+// existing, and the app's own name when it does not — a declaration, where the
+// app IS the whole subject. The fallback also closes the empty-string trap: a
+// grant block with no bucket cannot come from the walk, but it can come from a
+// hand-edited object, and an empty expected word would let an empty form field
+// confirm a destruction.
+func storeConfirmWord(rec *objstore.Record, app string) string {
+ switch {
+ case rec.Provisioned != nil && rec.Provisioned.Bucket != "":
+ return rec.Provisioned.Bucket
+ case rec.Grant != nil && rec.Grant.Bucket != "":
+ return rec.Grant.Bucket
+ }
+ return app
+}
+
// handleAppMoveAccount is the repair half of an account move: when the
// app's routes already say another account owns it (AccountMismatch), this
// makes the console's record agree — `console migrate-app`, from the
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go
index 2cb6a3f94..94fb98ec3 100644
--- a/internal/console/appsettings_test.go
+++ b/internal/console/appsettings_test.go
@@ -11,12 +11,14 @@ package console
import (
"context"
+ "errors"
"net/http"
"net/url"
"strings"
"testing"
"time"
+ "github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -655,3 +657,258 @@ func TestSettingsPageHidesOwnerFormsFromMembers(t *testing.T) {
t.Errorf("a member is offered the delete form:\n%s", body)
}
}
+
+// ---- the store card and its destroy door ------------------------------------
+//
+// Destroying a store is the only console action that destroys a customer's
+// OBJECTS outside the app trash, and there is no trash for a bucket. So the
+// ceremony is the purge door's, with one difference that matters: what a person
+// types is the BUCKET name, because the bucket is the thing that stops
+// existing — an app name typed here would confirm the wrong noun.
+
+// pendingKey puts the record into the half-finished rotation state: two keys
+// open the bucket until the member finishes it, and the page must nag.
+func pendingKey(t *testing.T, fx *consoleFixture, app, prev string) {
+ t.Helper()
+ ctx := context.Background()
+ rec, etag, err := objstore.Get(ctx, fx.rel, fx.sqid, app)
+ if err != nil || rec == nil || rec.Provisioned == nil {
+ t.Fatalf("read record: %+v %v", rec, err)
+ }
+ rec.Provisioned.PrevKeyID = prev
+ if _, err := objstore.Put(ctx, fx.rel, fx.sqid, app, rec, etag); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// stashedActions counts parked step-up actions — the evidence that a passkey
+// was demanded. A refusal that gets this far has already cost a gesture.
+func stashedActions(t *testing.T, fx *consoleFixture) int {
+ t.Helper()
+ var n int
+ if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+// The typed word is the bucket, checked BEFORE the passkey: a slip must cost a
+// retry, never a gesture, and must destroy nothing. The app's own name is the
+// most likely wrong answer, so it is the one under test.
+func TestStoreDestroyRefusesAnythingButTheBucketName(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fake := armStoreBackend(t, fx)
+ bucket := seedStore(t, fx, fake, "kass", false)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ for _, typed := range []string{"kass", "", "carlos-bab-kas"} {
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/store/destroy"),
+ url.Values{"csrf": {csrf}, "confirm": {typed}})
+ if strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("confirm %q demanded a passkey before it was even checked (-> %q)", typed, loc)
+ }
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("confirm %q = %d -> %q, want the settings flash:\n%s", typed, status, loc, body)
+ }
+ if n := stashedActions(t, fx); n != 0 {
+ t.Fatalf("confirm %q parked %d pending actions — the passkey was asked for anyway", typed, n)
+ }
+ estateStanding(t, fx, fake, "kass", bucket, "a mistyped destroy")
+ if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil {
+ t.Fatalf("a mistyped destroy touched the record: %+v", rec)
+ }
+ }
+ if _, _, page := get(t, c, fx.u("/apps/kass/settings")); !strings.Contains(page, bucket) {
+ t.Fatalf("the refusal does not tell the owner what to type:\n%s", page)
+ }
+}
+
+// The whole ceremony: the bucket typed, the passkey, and then everything —
+// objects, bucket, principal, key and the record — is gone. The serial moves
+// because the delivered variables were unset: every box serving this app is
+// running with credentials that no longer work, and must converge.
+func TestStoreDestroyDestroysTheEstateAndAnnounces(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fake := armStoreBackend(t, fx)
+ bucket := seedStore(t, fx, fake, "kass", false)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ before := serial(t, fx)
+
+ // Without the passkey, nothing moves.
+ status, loc, _ := postForm(t, c, fx.u("/apps/kass/store/destroy"),
+ url.Values{"csrf": {csrf}, "confirm": {bucket}})
+ if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("un-stepped-up destroy = %d -> %q, want a redirect into Keymail", status, loc)
+ }
+ estateStanding(t, fx, fake, "kass", bucket, "a destroy awaiting its passkey")
+
+ status, loc, body := completeStepUp(t, fx, c, loc)
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("resumed destroy = %d -> %q:\n%s", status, loc, body)
+ }
+ estateGone(t, fake, "a completed destroy")
+ if rec := storedRecord(t, fx, "kass"); rec != nil {
+ t.Fatalf("the store record survived its own destruction: %+v", rec)
+ }
+ if got := serial(t, fx); got <= before {
+ t.Fatalf("serial after a destroy = %d, want a bump from %d — the boxes must converge on the unset variables", got, before)
+ }
+ auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", bucket)
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, bucket) {
+ t.Fatalf("the destroy flash does not name what was destroyed:\n%s", page)
+ }
+}
+
+// A declaration is a request nobody has acted on: no bucket, no key, nothing to
+// spend backend authority on. So a console with no backend at all may still
+// withdraw it — and the word to type is the APP's name, because that is the
+// only noun this record has.
+func TestStoreDestroyOfADeclarationNeedsNoBackend(t *testing.T) {
+ fx := newConsoleFixture(t)
+ putRecord(t, fx, "kass", &objstore.Record{
+ Declared: &objstore.Declared{EnvPrefix: objstore.DefaultEnvPrefix, At: time.Now(), By: "paul@keymail.dev"},
+ })
+ fx.srv.cfg.StoreProvisioner = nil
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"),
+ url.Values{"csrf": {csrf}, "confirm": {"kass"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("destroy of a declaration = %d -> %q:\n%s", status, loc, body)
+ }
+ if rec := storedRecord(t, fx, "kass"); rec != nil {
+ t.Fatalf("the declaration survived: %+v", rec)
+ }
+ auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", "declaration")
+}
+
+// A provisioned store on a backend-less console is the other half of that rule:
+// the bucket and the key are real and this console has no authority over them,
+// so it refuses and names the variable that would give it some — before the
+// passkey, since no gesture could change the answer.
+func TestStoreDestroyRefusesAnEstateWithNoBackend(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fake := armStoreBackend(t, fx)
+ bucket := seedStore(t, fx, fake, "kass", false)
+ fx.srv.cfg.StoreProvisioner = nil
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/store/destroy"),
+ url.Values{"csrf": {csrf}, "confirm": {bucket}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("backend-less destroy = %d -> %q:\n%s", status, loc, body)
+ }
+ if n := stashedActions(t, fx); n != 0 {
+ t.Fatalf("a refusal nothing could change still cost a passkey (%d parked actions)", n)
+ }
+ estateStanding(t, fx, fake, "kass", bucket, "a backend-less destroy")
+ if rec := storedRecord(t, fx, "kass"); rec == nil {
+ t.Fatal("the record was deleted with the estate still standing")
+ }
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "CARLOS_STORE_BACKEND") {
+ t.Fatalf("the refusal does not name the backend variable:\n%s", page)
+ }
+}
+
+// A backend that refuses mid-walk must leave the record naming the estate: the
+// record is the ONLY thing that names a bucket and a principal, so a door that
+// deleted it on a failed teardown would leave a paid, credentialed estate
+// nobody can find. The person is told it is still there, which is also the
+// instruction — re-entering the door resumes.
+func TestStoreDestroyFailureLeavesTheRecordNamingTheEstate(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fake := armStoreBackend(t, fx)
+ bucket := seedStore(t, fx, fake, "kass", false)
+ fake.FailNext["EmptyBucket"] = errors.New("backend unreachable")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"),
+ url.Values{"csrf": {csrf}, "confirm": {bucket}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("failed destroy = %d -> %q:\n%s", status, loc, body)
+ }
+ estateStanding(t, fx, fake, "kass", bucket, "a failed destroy")
+ if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil {
+ t.Fatalf("a failed destroy deleted the record naming the estate: %+v", rec)
+ }
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "still there") {
+ t.Fatalf("the failure does not say the store survived:\n%s", page)
+ }
+}
+
+// Destroying is the account owner's. A member meets the same 404 a stranger
+// does — before any passkey redirect could concede the app, or the store,
+// exists.
+func TestStoreDestroyIsOwnerOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fake := armStoreBackend(t, fx)
+ bucket := seedStore(t, fx, fake, "kass", false)
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-sam-store-destroy")
+ csrf := sessionToken(t, fx, c)
+
+ status, _, memberBody := postForm(t, c, fx.u("/apps/kass/store/destroy"),
+ url.Values{"csrf": {csrf}, "confirm": {bucket}})
+ if status != http.StatusNotFound {
+ t.Fatalf("member destroying a store = %d, want 404", status)
+ }
+ strangerBody := strangerRefusal(t, fx, "code-zoe-store-destroy",
+ fx.u("/apps/kass/store/destroy"), url.Values{"confirm": {bucket}})
+ if memberBody != strangerBody {
+ t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
+ }
+ estateStanding(t, fx, fake, "kass", bucket, "a member's refused destroy")
+}
+
+// The card is the member's only sight of a store they cannot reach with box
+// commands: what prefix it delivers under, which bucket, which key and how old
+// that key is — labelled as the KEY's age, because a grant repair moves that
+// timestamp and calling it "provisioned" would be a lie the page tells forever.
+func TestSettingsPageShowsTheStoreCard(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fake := armStoreBackend(t, fx)
+ bucket := seedStore(t, fx, fake, "kass", false)
+ pendingKey(t, fx, "kass", "MEMKEY-OLD")
+ keyID := storedRecord(t, fx, "kass").Provisioned.KeyID
+ c := signInPaul(t, fx)
+
+ status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ if status != http.StatusOK {
+ t.Fatalf("settings page = %d:\n%s", status, body)
+ }
+ for _, want := range []string{
+ objstore.DefaultEnvPrefix, // the env set the app reads
+ bucket, // the bucket its data is in
+ keyID, // the key that opens it
+ "last credential change", // what the age actually means
+ "carlos store rotate -finish -app kass", // the half-done rotation nag
+ `id="store-destroy-confirm"`, // the destroy form
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("the store card is missing %q:\n%s", want, body)
+ }
+ }
+ if strings.Contains(body, "Provisioned</th>") {
+ t.Fatalf("the card labels the key timestamp as a provisioning date:\n%s", body)
+ }
+
+ // An app nobody has declared a store for gets the one line that tells them
+ // how to ask for one — and no destroy form for a store that isn't there.
+ freshApp(t, fx, "sprout")
+ _, _, body = get(t, c, fx.u("/apps/sprout/settings"))
+ if !strings.Contains(body, "carlos store create") {
+ t.Fatalf("an undeclared app is not told how to ask for a store:\n%s", body)
+ }
+ if strings.Contains(body, `id="store-destroy-confirm"`) {
+ t.Fatalf("an app with no store is offered a destroy form:\n%s", body)
+ }
+}
diff --git a/internal/console/deletion_test.go b/internal/console/deletion_test.go
index 5ffffeaab..189619416 100644
--- a/internal/console/deletion_test.go
+++ b/internal/console/deletion_test.go
@@ -3,6 +3,7 @@ package console
import (
"context"
"encoding/json"
+ "fmt"
"net/http"
"net/url"
"strings"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/release"
)
@@ -443,3 +445,183 @@ func TestDeleteNowRefusesAProductionApp(t *testing.T) {
t.Fatalf("the refusal does not name the off-switch on the app page:\n%s", page)
}
}
+
+// ---- purge and the object store ---------------------------------------------
+//
+// An app's object store is the one part of it that lives OUTSIDE the deployment
+// bucket — a real bucket, in a real cloud account, with a real IAM principal
+// holding a live key. Sweeping apps/<acct>/<app>/ without tearing that down
+// first would leave a paid, credentialed estate that no record names, which is
+// why the box-side auto-purge refuses outright (release.ErrStoreProvisioned)
+// and why this door — the only one with backend authority — goes first.
+
+// armStoreBackend gives the fixture's console an in-memory storage backend and
+// a deployment that mints carlos-<acct>-<app> buckets, which is what makes it a
+// console that may destroy one.
+func armStoreBackend(t *testing.T, fx *consoleFixture) *objstore.MemProvisioner {
+ t.Helper()
+ fake := objstore.NewMemProvisioner("eu-west-1")
+ fx.srv.cfg.StoreProvisioner = fake
+ fx.srv.cfg.StoreDeployment = objstore.Deployment{Backend: "mem", Region: "eu-west-1", BucketPrefix: "carlos-"}
+ return fake
+}
+
+// seedStore builds the estate a completed grant leaves behind — a bucket with a
+// customer object in it, the principal holding its key, and the record naming
+// both — hand-written because the door that builds it is an operator API away
+// (apistore.go). crashed=true is the shape a grant that died before its step-6
+// Put leaves: bucket, user and key all real, `provisioned` never recorded. It
+// returns the bucket name, which is also the word this app's destroy door makes
+// a person type.
+func seedStore(t *testing.T, fx *consoleFixture, fake *objstore.MemProvisioner, app string, crashed bool) string {
+ t.Helper()
+ ctx := context.Background()
+ bucket := objstore.MintedBucket(fx.srv.cfg.StoreDeployment, fx.sqid, app)
+ if _, err := fake.EnsureBucket(ctx, bucket); err != nil {
+ t.Fatal(err)
+ }
+ fake.Buckets[bucket]["customer/data.bin"] = []byte("the customer's objects")
+ user := objstore.UserName(fx.sqid, app)
+ arn, err := fake.EnsureUser(ctx, user, objstore.UserPath, "arn:aws:s3:::"+bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ keyID, _, err := fake.MintKey(ctx, user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ rec := &objstore.Record{
+ Declared: &objstore.Declared{EnvPrefix: objstore.DefaultEnvPrefix, At: now, By: "paul@keymail.dev"},
+ Grant: &objstore.GrantBlock{Backend: "mem", Bucket: bucket, At: now, By: "paul@keymail.dev"},
+ }
+ if !crashed {
+ rec.Provisioned = &objstore.Provisioned{Bucket: bucket, Region: "eu-west-1", IAMUser: user,
+ UserARN: arn, KeyID: keyID, At: now}
+ }
+ putRecord(t, fx, app, rec)
+ return bucket
+}
+
+// estateStanding asserts the backend still holds everything seedStore built —
+// the yardstick for "a refusal destroyed nothing".
+func estateStanding(t *testing.T, fx *consoleFixture, fake *objstore.MemProvisioner, app, bucket, when string) {
+ t.Helper()
+ if len(fake.Buckets[bucket]) == 0 {
+ t.Fatalf("%s: the store bucket %s is empty or gone — objects were destroyed", when, bucket)
+ }
+ if _, ok := fake.Users[objstore.UserName(fx.sqid, app)]; !ok {
+ t.Fatalf("%s: the store's IAM principal was deleted", when)
+ }
+}
+
+// estateGone asserts the whole backend estate is down: no bucket, no principal.
+func estateGone(t *testing.T, fake *objstore.MemProvisioner, when string) {
+ t.Helper()
+ if len(fake.Buckets) != 0 || len(fake.Users) != 0 {
+ t.Fatalf("%s left an estate standing: buckets %v, users %v", when, fake.Buckets, fake.Users)
+ }
+}
+
+// A console with no storage backend configured cannot tear a store down, so it
+// must not purge the app either: the prefix sweep would delete store.json —
+// the ONLY record naming a live bucket and a live key — and nothing would ever
+// find them again. The refusal names the variable that fixes it.
+func TestPurgeRefusesAStoreThisConsoleCannotTearDown(t *testing.T) {
+ fx, c, csrf := trashPageFixture(t)
+ fake := armStoreBackend(t, fx)
+ bucket := seedStore(t, fx, fake, "kass", false)
+ fx.srv.cfg.StoreProvisioner = nil // the deployment shape with no backend
+
+ status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"),
+ url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("purge with no backend = %d:\n%s", status, body)
+ }
+ estateStanding(t, fx, fake, "kass", bucket, "a backend-less purge")
+ if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil {
+ t.Fatalf("the store record did not survive the refusal: %+v", rec)
+ }
+ if len(appKeys(t, fx, "kass")) == 0 {
+ t.Fatal("the app's deployment objects were swept by a refused purge")
+ }
+ if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasTrashedAppIn = %v %v, want the row still trashed", has, err)
+ }
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "CARLOS_STORE_BACKEND") {
+ t.Fatalf("the refusal does not name the backend variable:\n%s", page)
+ }
+}
+
+// The whole point of the door: the backend estate comes down FIRST, and only
+// then is the deployment prefix swept — store.json included, now that it names
+// nothing.
+func TestPurgeTearsTheStoreDownBeforeSweepingThePrefix(t *testing.T) {
+ fx, c, csrf := trashPageFixture(t)
+ fake := armStoreBackend(t, fx)
+ seedStore(t, fx, fake, "kass", false)
+ before := serial(t, fx)
+
+ status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"),
+ url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("purge = %d:\n%s", status, body)
+ }
+ estateGone(t, fake, "a completed purge")
+ if keys := appKeys(t, fx, "kass"); len(keys) != 0 {
+ t.Fatalf("the deployment bucket still holds %v", keys)
+ }
+ if rec := storedRecord(t, fx, "kass"); rec != nil {
+ t.Fatalf("the store record survived the purge: %+v", rec)
+ }
+ // Both halves are audited: the store teardown is a separate, irreversible
+ // act on a separate estate, and an operator reading the trail must see it.
+ auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", "carlos-bab-kass")
+ auditedBy(t, fx, "kass", "purge", "paul@keymail.dev", "kass")
+ if got := serial(t, fx); got != before {
+ t.Fatalf("serial after a purge = %d, want unchanged (%d)", got, before)
+ }
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "free to claim again") {
+ t.Fatalf("no purge flash:\n%s", page)
+ }
+}
+
+// A grant that crashed before recording itself leaves a record with `grant` and
+// no `provisioned` — over a bucket, a user and a key that are all real. The
+// guard is on EITHER block for exactly this: reading it as "nothing to tear
+// down" would sweep the only witness to a live, billing estate.
+func TestPurgeTearsDownACrashedGrant(t *testing.T) {
+ fx, c, csrf := trashPageFixture(t)
+ fake := armStoreBackend(t, fx)
+ seedStore(t, fx, fake, "kass", true)
+ if rec := storedRecord(t, fx, "kass"); rec.Provisioned != nil {
+ t.Fatal("the crashed-grant fixture recorded a provisioned block")
+ }
+
+ status, _, body := postGated(t, fx, c, fx.u("/trash/delete"),
+ url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("purge of a crashed grant = %d:\n%s", status, body)
+ }
+ estateGone(t, fake, "a purge over a crashed grant")
+ if keys := appKeys(t, fx, "kass"); len(keys) != 0 {
+ t.Fatalf("the deployment bucket still holds %v", keys)
+ }
+ auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", "carlos-bab-kass")
+}
+
+// purgeAdvice must be honest in BOTH directions: the guard wraps
+// ErrStoreProvisioned for an unreadable store.json too, so this sentence
+// cannot claim a store certainly exists — and it must still name the way out.
+func TestPurgeAdviceMapsAStoreRefusalWithoutOverclaiming(t *testing.T) {
+ msg := purgeAdvice(fmt.Errorf("bab/kass: %w", release.ErrStoreProvisioned), "kass")
+ for _, want := range []string{"kass", "object store", "Settings"} {
+ if !strings.Contains(msg, want) {
+ t.Fatalf("purgeAdvice = %q, want it to mention %q", msg, want)
+ }
+ }
+ if !strings.Contains(msg, "may have") {
+ t.Fatalf("purgeAdvice = %q, want it to admit the store may only MAYBE exist "+
+ "(the guard wraps this sentinel for an unreadable record too)", msg)
+ }
+}
diff --git a/internal/console/ownership.go b/internal/console/ownership.go
index 7c045c132..19d0c6d83 100644
--- a/internal/console/ownership.go
+++ b/internal/console/ownership.go
@@ -23,6 +23,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/release"
)
@@ -279,6 +280,12 @@ func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) {
// requireFreshAuth — LAST of the auth gates, so no redirect into Keymail
// ever happens for someone who should have got a 404.
//
+// What gets destroyed, and in what order, is the other half of the design: the
+// app's OBJECT STORE goes first (see the block below), because it is the one
+// piece of the app that lives outside the deployment bucket — a real bucket
+// with a real credential — and store.json, which is the only thing naming it,
+// is inside the prefix this purge is about to sweep.
+//
// Only then does anything get destroyed, and PurgeApp re-reads the marker and
// re-checks the production flag itself, immediately before deleting: a
// restore that landed while this form sat open wins, and an app somebody
@@ -312,6 +319,38 @@ func (s *Server) handlePurgeApp(w http.ResponseWriter, r *http.Request) {
fail("There is no deployment bucket on this host, so nothing can be deleted here.")
return
}
+ // A provisioned store is torn down BEFORE the prefix sweep — this door
+ // is the only purge path with backend authority (objstore spec §9;
+ // box-side auto-purge refuses via release.ErrStoreProvisioned). Failure
+ // aborts the purge with the record intact: re-entering resumes.
+ // Guard on Provisioned OR Grant (controller ruling, Task 5 review): a
+ // grant block without a provisioned block is a crashed grant whose
+ // backend estate is real.
+ if rec, _, rerr := objstore.Get(r.Context(), s.cfg.Releases, acct.Sqid, app); rerr == nil && rec != nil && (rec.Provisioned != nil || rec.Grant != nil) {
+ if s.cfg.StoreProvisioner == nil {
+ fail("“" + app + "” has a provisioned object store, and this console has no storage backend configured (CARLOS_STORE_BACKEND) — it cannot be deleted here until the store is torn down.")
+ return
+ }
+ if terr := objstore.Teardown(r.Context(), s.cfg.Releases, s.cfg.StoreProvisioner, acct.Sqid, app); terr != nil {
+ log.Printf("console: purge store teardown %s/%s: %v", acct.Sqid, app, terr)
+ fail("Could not tear down “" + app + "”'s object store — nothing was deleted; try again.")
+ return
+ }
+ // rec.Provisioned may be nil here (crashed-grant shape) — name the
+ // bucket from Grant, which the guard condition proves non-nil when
+ // Provisioned is.
+ bucket := ""
+ if rec.Provisioned != nil {
+ bucket = rec.Provisioned.Bucket
+ } else if rec.Grant != nil {
+ bucket = rec.Grant.Bucket
+ }
+ s.audit(acct, app, addr, "store-destroy", "tore down the object store (bucket "+bucket+") during permanent deletion")
+ } else if rerr != nil {
+ log.Printf("console: purge store read %s/%s: %v", acct.Sqid, app, rerr)
+ fail("Could not read “" + app + "”'s object-store record — nothing was deleted; try again.")
+ return
+ }
if err := release.PurgeApp(r.Context(), s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, time.Now()); err != nil {
log.Printf("console: purge %s/%s: %v", acct.Sqid, app, err)
fail(purgeAdvice(err, app))
@@ -358,6 +397,17 @@ func purgeAdvice(err error, app string) string {
case errors.Is(err, release.ErrNotTrashed):
return "“" + app + "” is not in the trash, so nothing was deleted. It may have been " +
"restored, or already deleted, since this page was loaded."
+ case errors.Is(err, release.ErrStoreProvisioned):
+ // Belt and braces: handlePurgeApp tears a store down before it gets
+ // here, so reaching this means the record changed under the door — or
+ // that PurgeApp could not READ it. The guard wraps this sentinel for
+ // an unreadable or unparseable store.json as well as for a store that
+ // really is there (release/trash.go), so the sentence must not claim
+ // certainty it does not have: "has (or may have)" is the whole truth,
+ // and both ways out are named because only one of them will apply.
+ return "“" + app + "” has (or may have) a provisioned object store that this deletion could " +
+ "not verify as gone — tear it down on the app's Settings page, or try again once the " +
+ "store record is readable. Nothing was deleted."
case strings.Contains(err.Error(), "cannot read the production flag"):
return "Could not read the production setting for “" + app + "” — an unreadable flag is treated " +
"as production, so deleting is refused. Set it again with the production switch on the " +
diff --git a/internal/console/server.go b/internal/console/server.go
index 03a6a4fd8..a7a09623b 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -206,12 +206,19 @@ type Config struct {
// lives — a walk that guessed would create the next bucket somewhere
// else, and the record would say so afterwards.
StoreDeployment objstore.Deployment
- // StoreOperators are the addresses allowed to drive the grant, rotate and
- // destroy doors (CARLOS_STORE_OPERATORS). Its own list rather than
- // console ownership on purpose: provisioning spends real money in the
- // DEPLOYMENT's cloud account, so the authority belongs to whoever runs
- // the deployment, not to a tenant who owns an account inside it. Empty is
- // fail-closed — nobody may grant.
+ // StoreOperators are the addresses allowed to drive the GRANT door, and
+ // only that one (CARLOS_STORE_OPERATORS). Its own list rather than
+ // console ownership on purpose: granting CREATES backend estate and
+ // spends real money in the DEPLOYMENT's cloud account, so the authority
+ // belongs to whoever runs the deployment, not to a tenant who owns an
+ // account inside it. Empty is fail-closed — nobody may grant.
+ //
+ // The other two store verbs are deliberately NOT gated on this list.
+ // Rotate and finish are the member's: they re-key an estate that already
+ // exists, cost nothing, and only the member knows when every host has
+ // picked the new pair up. Destroy is the account OWNER's, in the browser,
+ // behind the typed bucket name and a passkey — it spends no money and
+ // destroys only that account's own data.
StoreOperators []string
}
@@ -358,6 +365,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount)
+ // The object store's destroy door (appsettings.go, issue #55): the account
+ // owner, the bucket name typed out, and a passkey. It is the only console
+ // route that destroys a customer's objects outside the app trash, and
+ // unlike the trash there is no cooloff behind it.
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/store/destroy", s.handleStoreDestroy)
s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite)
s.mux.HandleFunc("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite)
// Owner-only account administration, every one of them behind passkey
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index cc672ce26..a6db42984 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -81,6 +81,62 @@ different account moves its bucket objects and re-stamps its routes —
<code>carlos accounts migrate</code>, an operator act; once run, this page
offers the final console-record step.</p>
{{end}}{{end}}
+{{if .Settings}}{{with .Settings.Store}}
+<h2>Object storage</h2>
+{{if or .Declared .Granted .Provisioned}}
+<div class="tablewrap">
+<table>
+ <tr><th>Env prefix</th><th>Backend</th><th>Bucket</th><th>Access key</th><th>Key age</th></tr>
+ <tr>
+ <td>{{if .EnvPrefix}}<code>{{.EnvPrefix}}_*</code>{{else}}<span title="no declaration on the record">—</span>{{end}}</td>
+ <td>{{if .Backend}}{{.Backend}}{{else}}<span title="not granted yet">—</span>{{end}}</td>
+ <td>{{if .Bucket}}<code>{{.Bucket}}</code>{{else}}<span title="not granted yet">—</span>{{end}}</td>
+ <td>{{if .KeyID}}<code>{{.KeyID}}</code>{{else}}<span title="no credentials delivered yet">—</span>{{end}}</td>
+ <td>{{if .KeyAge}}{{.KeyAge}}{{else}}<span title="unknown">—</span>{{end}}</td>
+ </tr>
+</table>
+</div>
+{{if .Provisioned}}<p class="small">Key age is the last credential change — a
+rotation and a repaired grant both move it, so it is not when the store was
+created.</p>{{end}}
+{{if and (not .Granted) (not .Provisioned)}}<p class="small">Declared and waiting:
+an operator of this deployment creates the bucket and delivers the credentials.
+Until then the {{if .EnvPrefix}}<code>{{.EnvPrefix}}_*</code>{{else}}store{{end}}
+variables are not in this app's config.</p>{{end}}
+{{if and .Granted (not .Provisioned)}}<p class="small">A grant is half-done: the
+bucket was claimed but no credentials are recorded yet. Running the grant again
+repairs it — an operator of this deployment does that.</p>{{end}}
+{{if .PendingFinish}}<p class="small">A rotation is half-finished — two keys open
+this bucket until you retire the old one:</p>
+<pre><code>carlos store rotate -finish -app {{$.Detail.App}}</code></pre>{{end}}
+{{if $.IsOwner}}
+<h3 class="danger-head">Destroy this store</h3>
+{{if .CanDestroy}}
+<p class="small">Destroying empties and deletes
+{{if .Bucket}}<code>{{.Bucket}}</code>{{else}}the store{{end}}, deletes the
+credentials that opened it, and removes the delivered variables from this app's
+config. There is no trash for a store: the objects in it are gone, and nothing
+here brings them back.</p>
+<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/store/destroy" class="settings-row">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <label class="visually-hidden" for="store-destroy-confirm">Type {{if .Bucket}}the bucket name{{else}}the app's name{{end}} to confirm</label>
+ <input type="text" id="store-destroy-confirm" name="confirm"
+ placeholder="type {{if .Bucket}}{{.Bucket}}{{else}}{{$.Detail.App}}{{end}} to confirm" autocomplete="off" required>
+ <button type="submit" class="btn-sm btn-danger">Destroy store</button>
+</form>
+<p class="small">Confirms with your passkey.</p>
+{{else}}
+<p class="small">This console has no storage backend configured
+(<code>CARLOS_STORE_BACKEND</code>), so it cannot delete this bucket or its
+credentials — an operator of this deployment has to tear it down.</p>
+{{end}}
+{{end}}
+{{else}}
+<p class="small">No object store. Ask for one with <code>carlos store create -app
+{{$.Detail.App}}</code>; an operator of this deployment then creates the bucket
+and delivers its credentials into this app's config.</p>
+{{end}}
+{{end}}{{end}}
{{if .IsOwner}}
<h2 class="danger-head">Delete this app</h2>
diff --git a/internal/objstore/walk.go b/internal/objstore/walk.go
index e2ec5e4b6..94475421e 100644
--- a/internal/objstore/walk.go
+++ b/internal/objstore/walk.go
@@ -406,6 +406,17 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, acct, app str
if rec.Grant == nil && rec.Provisioned == nil {
return deleteRecordIfUnchanged(ctx, s, acct, app, startKeyID, startBucket)
}
+ // Everything below spends backend authority, and a nil Provisioner is a
+ // deployment that has none — an ordinary shape (console Config.
+ // StoreProvisioner documents it), so this is a refusal, not an assertion.
+ // The console's destroy door already refuses an estate it cannot tear down,
+ // but it decides from a record it read a round trip earlier: a grant landing
+ // in between turns a declaration-only teardown into an estate teardown, and
+ // calling through a nil backend would panic the request rather than refuse
+ // it. The record is left exactly as found, which is what a refusal owes.
+ if p == nil {
+ return fmt.Errorf("objstore: %s/%s has backend estate and this deployment has no storage backend configured", acct, app)
+ }
// Coordinates: the provisioned block when there is one, since the bucket
// an app's data is in is whatever was recorded at provision time. Falling
// back to the grant covers the crashed-grant shape above — and the user
diff --git a/internal/objstore/walk_test.go b/internal/objstore/walk_test.go
index c1e73c6ad..518a968c6 100644
--- a/internal/objstore/walk_test.go
+++ b/internal/objstore/walk_test.go
@@ -1099,6 +1099,39 @@ func TestTeardownDeclaredOnly(t *testing.T) {
}
}
+// A deployment with no backend may still tear down a DECLARATION — there is
+// nothing outside the bucket to act on — but an estate refuses instead of
+// dereferencing the nil backend. The console's destroy door decides from a
+// record it read a round trip earlier, so a grant landing in between arrives
+// here as exactly this shape, and a panic would be the difference between a
+// refusal and a 500 over a live bucket.
+func TestTeardownWithNoProvisioner(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ declare(t, s, "bab", "titogo", "TITO_S3")
+
+ if err := Teardown(ctx, s, nil, "bab", "titogo"); err != nil {
+ t.Fatalf("declared-only teardown with no provisioner: %v", err)
+ }
+
+ // Now the same walk over a grant that DID create backend estate.
+ declare(t, s, "bab", "titogo", "TITO_S3")
+ rec, etag, err := Get(ctx, s, "bab", "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.Grant = &GrantBlock{Backend: "mem", Bucket: "carlos-bab-titogo", At: time.Now(), By: "op@keymail.dev"}
+ if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil {
+ t.Fatal(err)
+ }
+ if err := Teardown(ctx, s, nil, "bab", "titogo"); err == nil {
+ t.Fatal("teardown of a granted store with no provisioner returned nil — it cannot have destroyed anything")
+ }
+ if stored, _, err := Get(ctx, s, "bab", "titogo"); err != nil || stored == nil || stored.Grant == nil {
+ t.Fatalf("the refused teardown did not leave the record intact: %+v %v", stored, err)
+ }
+}
+
func TestTeardownIdempotentOnAbsent(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())