console: the flag guards store destruction too, and its refusals point somewhere
Review fix round 1. handleStoreDestroy was a sixth destructive door the flag did not guard: owner, CSRF, the typed BUCKET name and a passkey — the exact ceremony spec §6 rules insufficient for the control plane — over an estate its own doc says has no trash and no undo. Refused before the typed word and before the passkey. The refusal is on DESTROYING a store, not on having one: a control-plane app may hold one like any other app, and forbidding that would be an invented constraint. Four smaller ones, all in files this task already touches: - The fail-closed branch is pinned. "A lookup failure must REFUSE" is a named project constraint and reads like harmless tidying to a later simplifier, so a test drops the control_plane COLUMN — surgical enough that auth and the app 404 still work and only AppFlags fails — and asserts 503 on the API door and the flash on the browser one. Inverting the branch makes it fail with a 200. - The two member tests no longer pass vacuously: an unregistered route answers a member exactly as the owner gate does, so both now prove an OWNER reaches the handler on the same URL. Deleting the routes fails them. - The refusals name the card that can clear the flag, absolutely, because a CLI caller has no page in front of them. controlPlaneRefusal takes *Account for it; every call site already had one. - The account refusal's plural branch is covered: two flagged apps, both named.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+174
−19
internal/console/apicli.go+1 −1internal/console/appsettings.go+20 −3internal/console/controlplane.go+19 −6internal/console/controlplane_test.go+133 −8internal/console/ownership.go+1 −1
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 1989a5ddb..7b68e2b38 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -964,7 +964,7 @@ func (s *Server) handleAPITrashApp(w http.ResponseWriter, r *http.Request) { |
| // The control plane is not trashable at all (controlplane.go): after the |
| // byte-identical 404 above, so a stranger never learns the app exists, |
| // and before anything is written. |
| - if s.refuseControlPlane(w, acct.ID, app, "trashed") { |
| + if s.refuseControlPlane(w, acct, app, "trashed") { |
| return |
| } |
| if s.cfg.Releases == nil { |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index bb27f9054..f0fa593ca 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -334,7 +334,7 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) { |
| // keys, its channels and the units that exec them are all written in it. |
| // Refused here, straight after the owner gate and before the confirm card |
| // is ever rendered — a card that cannot be confirmed is worse than no card. |
| - if msg, _ := s.controlPlaneRefusal(acct.ID, app, "renamed"); msg != "" { |
| + if msg, _ := s.controlPlaneRefusal(acct, app, "renamed"); msg != "" { |
| back("err", msg) |
| return |
| } |
| @@ -470,7 +470,7 @@ func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) { |
| } |
| // Outright, before the typed name and long before the passkey: the flag |
| // is the guard, and no gesture answers it (controlplane.go). |
| - if msg, _ := s.controlPlaneRefusal(acct.ID, app, "trashed"); msg != "" { |
| + if msg, _ := s.controlPlaneRefusal(acct, app, "trashed"); msg != "" { |
| back("err", msg) |
| return |
| } |
| @@ -555,6 +555,9 @@ func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) { |
| // |
| // owner — a member probing this gets a stranger's answer. |
| // CSRF — constant-time, before any pending action can be parked. |
| +// control plane — refused outright, before the typed word and before the |
| +// passkey: this ceremony is exactly the one spec §6 rules |
| +// insufficient for the app that IS the platform. |
| // 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 |
| @@ -589,6 +592,20 @@ func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) { |
| s.setFlash(w, "err", msg) |
| http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther) |
| } |
| + // The sixth destructive door, and the one whose ceremony is closest to |
| + // enough without being it: owner, CSRF, the typed BUCKET name and a |
| + // passkey — exactly the ceremony spec §6 rules insufficient for the |
| + // control plane, over an estate whose own doc says there is no trash and |
| + // no undo. A guard the neighbouring door walks around reads as protection |
| + // without being it (controlplane.go). |
| + // |
| + // The refusal is on DESTROYING the store, not on having one: a |
| + // control-plane app may hold an object store like any other app, and |
| + // forbidding that would be an invented constraint the spec does not make. |
| + if msg, _ := s.controlPlaneRefusal(acct, app, "destroyed"); msg != "" { |
| + fail(msg) |
| + return |
| + } |
| if s.cfg.Releases == nil { |
| fail("No deployment bucket on this host, so this console cannot read the store record — nothing was destroyed.") |
| return |
| @@ -697,7 +714,7 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) { |
| } |
| // The control plane does not change accounts: its releases are read from |
| // one account's prefix by every box in the fleet (controlplane.go). |
| - if msg, _ := s.controlPlaneRefusal(acct.ID, app, "moved to another account"); msg != "" { |
| + if msg, _ := s.controlPlaneRefusal(acct, app, "moved to another account"); msg != "" { |
| back("err", msg) |
| return |
| } |
diff --git a/internal/console/controlplane.go b/internal/console/controlplane.go| index a9c545768..fbb0d8bb8 100644 |
| --- a/internal/console/controlplane.go |
| +++ b/internal/console/controlplane.go |
| @@ -39,6 +39,11 @@ import ( |
| // otherwise msg is the sentence to show and status the code a JSON door should |
| // send with it. |
| // |
| +// The sentence NAMES THE PAGE that can clear the flag. An operator meeting one |
| +// of these refusals is by definition someone the platform has just stopped, |
| +// and "clear the control-plane flag first" without saying where is the same |
| +// stranded-operator problem the clear door was built to end. |
| +// |
| // Callers MUST be past their own membership gate first — the byte-identical |
| // 404 protects account membership, and consulting the flag before it would |
| // turn this refusal into an oracle for which accounts hold a control-plane |
| @@ -48,10 +53,10 @@ import ( |
| // — treating an unreadable row as "not the control plane" — makes a database |
| // blip the way past the guard. The error itself is logged, never returned: |
| // the caller gets one fixed sentence, as with every other store failure here. |
| -func (s *Server) controlPlaneRefusal(accountID int64, app, verb string) (msg string, status int) { |
| - cp, _, err := s.store.AppFlags(accountID, app) |
| +func (s *Server) controlPlaneRefusal(acct *Account, app, verb string) (msg string, status int) { |
| + cp, _, err := s.store.AppFlags(acct.ID, app) |
| if err != nil { |
| - log.Printf("console: control-plane check %s in account %d: %v", app, accountID, err) |
| + log.Printf("console: control-plane check %s in account %s: %v", app, acct.Sqid, err) |
| return "Could not check whether " + app + " is the control plane — nothing was changed. Try again.", |
| http.StatusServiceUnavailable |
| } |
| @@ -59,15 +64,23 @@ func (s *Server) controlPlaneRefusal(accountID int64, app, verb string) (msg str |
| return "", 0 |
| } |
| return app + " is marked as the control plane, so it cannot be " + verb + |
| - " — clear the control-plane flag first if you really mean to.", http.StatusForbidden |
| + " — clear the flag first on the app's Settings tab (" + s.appSettingsURL(acct, app) + |
| + ") if you really mean to.", http.StatusForbidden |
| +} |
| + |
| +// appSettingsURL is where both halves of the flag live, said in full: a CLI |
| +// caller has no page to be already looking at, and a browser flash pointing at |
| +// an absolute URL costs nothing. |
| +func (s *Server) appSettingsURL(acct *Account, app string) string { |
| + return s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/settings" |
| } |
| // refuseControlPlane is controlPlaneRefusal for the JSON doors: it writes the |
| // refusal and returns true when the caller must stop. Browser doors render |
| // pages and set flashes, so they call controlPlaneRefusal directly and hand |
| // msg to their own back()/fail() closure. |
| -func (s *Server) refuseControlPlane(w http.ResponseWriter, accountID int64, app, verb string) bool { |
| - msg, status := s.controlPlaneRefusal(accountID, app, verb) |
| +func (s *Server) refuseControlPlane(w http.ResponseWriter, acct *Account, app, verb string) bool { |
| + msg, status := s.controlPlaneRefusal(acct, app, verb) |
| if msg == "" { |
| return false |
| } |
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go| index f6d5ca05b..ff38a7cd9 100644 |
| --- a/internal/console/controlplane_test.go |
| +++ b/internal/console/controlplane_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| @@ -40,9 +41,16 @@ func TestControlPlaneAppRefusesTrash(t *testing.T) { |
| if status != http.StatusForbidden { |
| t.Fatalf("trash a control-plane app = %d %v, want 403", status, out) |
| } |
| - if msg := str(t, out, "error"); !strings.Contains(msg, "control plane") { |
| + msg := str(t, out, "error") |
| + if !strings.Contains(msg, "control plane") { |
| t.Fatalf("refusal %q does not name the flag the operator has to clear", msg) |
| } |
| + // And WHERE to clear it. A CLI caller has no page in front of them, so a |
| + // refusal that names the flag without naming the card strands exactly the |
| + // operator it just stopped. |
| + if !strings.Contains(msg, "/a/bab/apps/console/settings") { |
| + t.Fatalf("refusal %q does not point at the settings card that can clear the flag", msg) |
| + } |
| present, err := fx.st.HasAppIn(OpsAccountID, "console") |
| if err != nil || !present { |
| t.Fatalf("app row after a refused trash: HasAppIn = %v %v, want true (untouched)", present, err) |
| @@ -210,11 +218,17 @@ func TestControlPlaneAppRefusesAccountDeletion(t *testing.T) { |
| if err != nil || acct == nil { |
| t.Fatalf("AccountBySqid(%q) = %+v %v", sqid, acct, err) |
| } |
| - if _, err := fx.st.CreateAppIn(acct.ID, "console", "paul@keymail.dev", time.Now()); err != nil { |
| - t.Fatal(err) |
| - } |
| - if err := fx.st.SetAppControlPlane(acct.ID, "console", true); err != nil { |
| - t.Fatal(err) |
| + // TWO flagged apps, because the refusal LOOPS: a guard that stopped at the |
| + // first row would name one app and leave the operator to discover the |
| + // other by trying again (coordinator ruling 7 — an account may hold more |
| + // than one). |
| + for _, app := range []string{"console", "keymail"} { |
| + if _, err := fx.st.CreateAppIn(acct.ID, app, "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.SetAppControlPlane(acct.ID, app, true); err != nil { |
| + t.Fatal(err) |
| + } |
| } |
| status, loc, body := postForm(t, c, fx.ts.URL+"/a/"+sqid+"/delete", |
| url.Values{"csrf": {csrf}, "confirm": {"Testco"}}) |
| @@ -229,8 +243,13 @@ func TestControlPlaneAppRefusesAccountDeletion(t *testing.T) { |
| } |
| // The flash must name the app and the flag, or the operator cannot act. |
| _, _, page := get(t, c, fx.ts.URL+"/a/"+sqid+"/settings") |
| - if !strings.Contains(page, "control plane") || !strings.Contains(page, "console") { |
| - t.Fatalf("refusal flash names neither the flag nor the app:\n%s", page) |
| + if !strings.Contains(page, "control plane") { |
| + t.Fatalf("refusal flash does not name the flag:\n%s", page) |
| + } |
| + for _, app := range []string{"console", "keymail"} { |
| + if !strings.Contains(page, app) { |
| + t.Fatalf("refusal flash does not name %q — the loop stopped early:\n%s", app, page) |
| + } |
| } |
| } |
| @@ -380,6 +399,17 @@ func TestMemberCannotClearTheControlPlaneFlag(t *testing.T) { |
| if cp, _, err := fx.st.AppFlags(OpsAccountID, "console"); err != nil || !cp { |
| t.Fatalf("AppFlags after a member's attempt = %v %v, want true (still flagged)", cp, err) |
| } |
| + // The 404 above has to be the OWNER GATE's, not a missing route: an |
| + // unregistered path answers a member exactly the same way, so without this |
| + // the test would still pass with the door deleted. |
| + fx.setUser("paul@keymail.dev") |
| + owner := newBrowser(t) |
| + signUp(t, fx, owner, "code-cp-clear-owner-probe") |
| + status, loc, _ = postForm(t, owner, fx.u("/apps/console/control-plane/clear"), |
| + url.Values{"csrf": {sessionToken(t, fx, owner)}}) |
| + if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| + t.Fatalf("the OWNER on the same URL = %d -> %q, want the handler's step-up — the member's 404 may just be a missing route", status, loc) |
| + } |
| } |
| // TestOwnerSetsTheControlPlaneFlagWithoutAPasskey is the other half of the |
| @@ -444,4 +474,99 @@ func TestMemberCannotSetTheControlPlaneFlag(t *testing.T) { |
| if cp, _, err := fx.st.AppFlags(OpsAccountID, "titogo"); err != nil || cp { |
| t.Fatalf("AppFlags after a member's attempt = %v %v, want false", cp, err) |
| } |
| + // Same proof as the clear door's: an owner must REACH the handler on this |
| + // URL, or the member's 404 is indistinguishable from a route that is not |
| + // registered at all. |
| + fx.setUser("paul@keymail.dev") |
| + owner := newBrowser(t) |
| + signUp(t, fx, owner, "code-cp-set-owner-probe") |
| + status, loc, _ = postForm(t, owner, fx.u("/apps/titogo/control-plane/set"), |
| + url.Values{"csrf": {sessionToken(t, fx, owner)}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings") { |
| + t.Fatalf("the OWNER on the same URL = %d -> %q, want the handler's redirect — the member's 404 may just be a missing route", status, loc) |
| + } |
| + if cp, _, err := fx.st.AppFlags(OpsAccountID, "titogo"); err != nil || !cp { |
| + t.Fatalf("AppFlags after the owner's probe = %v %v, want true", cp, err) |
| + } |
| +} |
| + |
| +// TestControlPlaneAppRefusesStoreDestroy: the sixth destructive door. Its |
| +// ceremony — owner, CSRF, the typed BUCKET name, a passkey — is exactly the |
| +// ceremony spec §6 rules insufficient for the control plane, and what it |
| +// destroys (bucket, objects, IAM principal, credentials, record) has, by its |
| +// own doc, no trash and no undo. Refused before the typed word and before the |
| +// passkey, so the answer is the settings tab rather than a Keymail round trip. |
| +func TestControlPlaneAppRefusesStoreDestroy(t *testing.T) { |
| + fx, _ := controlPlaneFixture(t, "code-cp-store") |
| + putRecord(t, fx, "console", &objstore.Record{ |
| + Declared: &objstore.Declared{EnvPrefix: objstore.DefaultEnvPrefix, At: time.Now(), By: "paul@keymail.dev"}, |
| + }) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, loc, body := postForm(t, c, fx.u("/apps/console/store/destroy"), |
| + url.Values{"csrf": {csrf}, "confirm": {"console"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("destroy a control-plane app's store = %d -> %q:\n%s", status, loc, body) |
| + } |
| + if loc != fx.path("/apps/console/settings") { |
| + t.Fatalf("refusal redirected to %q, want the settings tab — a Keymail redirect means the guard ran too late", loc) |
| + } |
| + if rec := storedRecord(t, fx, "console"); rec == nil { |
| + t.Fatal("the store record was destroyed despite the control-plane flag") |
| + } |
| + // Cleared, the same door works — the refusal is the flag's, not the store's. |
| + if err := fx.st.SetAppControlPlane(OpsAccountID, "console", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, loc, body = postGated(t, fx, c, fx.u("/apps/console/store/destroy"), |
| + url.Values{"csrf": {csrf}, "confirm": {"console"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/console/settings") { |
| + t.Fatalf("destroy after clearing the flag = %d -> %q:\n%s", status, loc, body) |
| + } |
| + if rec := storedRecord(t, fx, "console"); rec != nil { |
| + t.Fatalf("the declaration survived a destroy that should have worked: %+v", rec) |
| + } |
| +} |
| + |
| +// TestControlPlaneLookupFailureRefuses pins the named project constraint: "a |
| +// lookup failure must REFUSE, not fall through". Treating an unreadable row as |
| +// "not the control plane" would make a database blip the way past every guard |
| +// in this file — and `cp, _, _ := s.store.AppFlags(...)` reads like harmless |
| +// tidying, so this is the branch a later simplification inverts. |
| +// |
| +// The column is dropped rather than the database closed, deliberately: closing |
| +// it would break the token lookup and the app 404 first, and the request would |
| +// never reach the flag at all. Dropping ONE column leaves every other reader |
| +// working and fails exactly AppFlags. |
| +func TestControlPlaneLookupFailureRefuses(t *testing.T) { |
| + fx, token := controlPlaneFixture(t, "code-cp-blip") |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + if _, err := fx.st.db.Exec(`ALTER TABLE apps DROP COLUMN control_plane`); err != nil { |
| + t.Fatalf("drop the flag column: %v", err) |
| + } |
| + |
| + // The JSON door: 503, not the 200 a fall-through would give. |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, token) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("trash with an unreadable flag = %d %v, want 503", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "Could not check") { |
| + t.Fatalf("refusal %q does not say the check itself failed", msg) |
| + } |
| + if present, err := fx.st.HasAppIn(OpsAccountID, "console"); err != nil || !present { |
| + t.Fatalf("app row after a refused trash: HasAppIn = %v %v, want true (untouched)", present, err) |
| + } |
| + |
| + // The browser door: the flash, the trash page, and no passkey demanded — |
| + // postForm, so a step-up redirect would show rather than be walked. |
| + status, loc, body := postForm(t, c, fx.u("/trash/delete"), |
| + url.Values{"csrf": {csrf}, "app": {"console"}, "confirm": {"console"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/trash") { |
| + t.Fatalf("purge with an unreadable flag = %d -> %q, want the trash page's flash:\n%s", status, loc, body) |
| + } |
| + if _, _, page := get(t, c, fx.ts.URL+fx.path("/trash")); !strings.Contains(page, "Could not check") { |
| + t.Fatalf("purge refusal flash does not say the check failed:\n%s", page) |
| + } |
| } |
diff --git a/internal/console/ownership.go b/internal/console/ownership.go| index f8185c86d..05b3bc612 100644 |
| --- a/internal/console/ownership.go |
| +++ b/internal/console/ownership.go |
| @@ -331,7 +331,7 @@ func (s *Server) handlePurgeApp(w http.ResponseWriter, r *http.Request) { |
| // prefix holds the release history the fleet is RUNNING. Refused before |
| // the typed name and before the passkey — neither gesture can answer the |
| // flag, and asking for one would only spend it (controlplane.go). |
| - if msg, _ := s.controlPlaneRefusal(acct.ID, app, "deleted"); msg != "" { |
| + if msg, _ := s.controlPlaneRefusal(acct, app, "deleted"); msg != "" { |
| fail(msg) |
| return |
| } |