console: store teardown in purge door, destroy ceremony, settings card
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+817
−6
internal/console/appsettings.go+210 −0internal/console/appsettings_test.go+257 −0internal/console/deletion_test.go+182 −0internal/console/ownership.go+50 −0internal/console/server.go+18 −6internal/console/templates/app_settings.html+56 −0internal/objstore/walk.go+11 −0internal/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()) |