carlos / platform Public

console: the control-plane flag refuses destruction, not promotion

Trash, purge, rename and transfer are refused outright for a flagged app;
a service credential cannot promote or roll one back; a PERSON promotes it
with ordinary ops membership.

The asymmetry is the design. Gating promotion would have broken
carlos deploy -app console — stable's fresh-auth 403 has no API-side
step-up and deploy log.Fatalfs on a failed promote after the ship has
landed. Meanwhile purge could delete the release history the fleet is
running, behind a confirmation that had no idea what the app was.

Every refusal sits after its door's membership gate and before any write
or fresh-auth prompt, so a non-member still meets the byte-identical 404
and nobody spends a passkey on a gesture that cannot help. A failed flag
lookup refuses with a 503 rather than falling through: treating an
unreadable row as "not the control plane" would make a database blip the
way past the guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 1bf43b532686d2257be37ee7039780732f20813d parent 691be49
5 files changed, +343 −0
  • internal/console/apicli.go +23 −0
  • internal/console/appsettings.go +20 −0
  • internal/console/controlplane.go +94 −0
  • internal/console/controlplane_test.go +194 −0
  • internal/console/ownership.go +12 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index c8d1bc05b..32ebb663d 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -680,6 +680,13 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
})
return
}
+ // A person with ops membership may roll the control plane; a service
+ // credential may not. This is the flag's promote-side job — the whole of
+ // it. Gating a PERSON here would break `carlos deploy -app console`, the
+ // command this exists to make ordinary (controlplane.go).
+ if s.refuseServiceControlPlane(w, p, acct.ID, app, "promote") {
+ return
+ }
res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version,
release.PromoteOptions{Actor: addr})
if err != nil {
@@ -799,6 +806,12 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
return
}
target := cur.History[0]
+ // Rollback moves the same pointer promote moves, so it wears the same
+ // service refusal — a credential barred from promoting the control plane
+ // must not reach the identical effect through the other verb.
+ if s.refuseServiceControlPlane(w, p, acct.ID, app, "roll back") {
+ return
+ }
if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil {
log.Printf("console: api rollback %s %s: %v", app, channel, err)
jsonError(w, http.StatusConflict, err.Error())
@@ -913,6 +926,10 @@ func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) {
// probing this door teaches a stranger nothing.
// rate limit — keyed on the ADDRESS, like handleAPIPromote: a write
// budget belongs to the person, not the terminal.
+// control plane — 403, outright. An app flagged as the control plane IS
+// the platform; its release history is what the fleet is
+// running. Clearing the flag is the separate, deliberate
+// act that unlocks this door (controlplane.go).
// Releases nil — 503. A console with no bucket cannot trash at all: the
// marker IS the trash state, and a row-only trash would
// un-hide itself at the next materialize pass (which
@@ -944,6 +961,12 @@ func (s *Server) handleAPITrashApp(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
return
}
+ // 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") {
+ return
+ }
if s.cfg.Releases == nil {
jsonError(w, http.StatusServiceUnavailable,
"no deployment bucket on this host — this console cannot delete an app")
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index 35d55cb6b..b2916fa47 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -317,6 +317,14 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) {
s.setFlash(w, kind, msg)
http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
}
+ // The control plane keeps its name (controlplane.go): the fleet's release
+ // 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 != "" {
+ back("err", msg)
+ return
+ }
// Normalized exactly as the create doors normalize (normalizeAppName),
// and refused exactly where claimApp refuses: "new" is the create form's
// own literal route, and an app renamed onto it would be unreachable —
@@ -447,6 +455,12 @@ func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) {
s.setFlash(w, kind, msg)
http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
}
+ // 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 != "" {
+ back("err", msg)
+ return
+ }
// Typing the name is a "did you mean this app?" control, not a security
// one — checked before step-up so a slip does not cost a passkey touch.
if strings.TrimSpace(r.FormValue("confirm")) != app {
@@ -668,6 +682,12 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) {
s.setFlash(w, kind, msg)
http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
}
+ // 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 != "" {
+ back("err", msg)
+ return
+ }
target, agreed, err := s.routeAccountFor(app)
if err != nil {
back("err", "Could not read the routes — nothing was moved. Try again.")
diff --git a/internal/console/controlplane.go b/internal/console/controlplane.go
new file mode 100644
index 000000000..d85ea4d7b
--- /dev/null
+++ b/internal/console/controlplane.go
@@ -0,0 +1,94 @@
+// Control-plane apps: the flag that says "this app IS the platform".
+//
+// It gates DESTRUCTION, not promotion, and the asymmetry is the whole design.
+// Deploying the console should be as ordinary as deploying any app — that is
+// what this slice exists to make true — so a promote gate would defeat it:
+// `stable`'s fresh-auth 403 has no API-side step-up, and `carlos deploy`
+// log.Fatalfs on a failed promote AFTER the ship has already landed
+// (deploy.go), so any gate there would leave every console deploy half-done.
+// Destroying the console is the act that deserves a wall, and it had none:
+// handlePurgeApp will delete apps/<sqid>/console/** — the release history the
+// fleet is currently running — behind an owner check, a typed name and a
+// passkey, none of which knows what this app is.
+//
+// Refused OUTRIGHT rather than behind fresh auth. A gesture proves intent,
+// and intent is not the thing in doubt: an operator who means to delete the
+// console can clear the flag first, which is a separate, deliberate act with
+// its own record.
+//
+// The one promote-side restriction is about WHO, not whether: a person with
+// ops membership may roll the control plane, a service credential may not.
+package console
+
+import (
+ "log"
+ "net/http"
+)
+
+// controlPlaneRefusal answers "may this door proceed?" for a destructive verb.
+// msg is "" when the app is not the control plane and the caller may carry on;
+// otherwise msg is the sentence to show and status the code a JSON door should
+// send with it.
+//
+// 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
+// app.
+//
+// A lookup failure REFUSES too, with a 503 rather than a 403: the alternative
+// — 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)
+ if err != nil {
+ log.Printf("console: control-plane check %s in account %d: %v", app, accountID, err)
+ return "Could not check whether " + app + " is the control plane — nothing was changed. Try again.",
+ http.StatusServiceUnavailable
+ }
+ if !cp {
+ 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
+}
+
+// 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)
+ if msg == "" {
+ return false
+ }
+ jsonError(w, status, msg)
+ return true
+}
+
+// refuseServiceControlPlane is the promote pair's gate, and the whole of what
+// the flag does on the promote path: a service credential cannot move a
+// control-plane app's channel pointer; a PERSON with ops membership can, and
+// must be able to — gating a person here would break `carlos deploy -app
+// console`, the command this work exists to make ordinary.
+//
+// The flag is only read for a service, so a person's promote does not grow a
+// database round trip it has no use for.
+func (s *Server) refuseServiceControlPlane(w http.ResponseWriter, p Principal, accountID int64, app, verb string) bool {
+ if p.Kind == PrincipalUser {
+ return false
+ }
+ cp, _, err := s.store.AppFlags(accountID, app)
+ if err != nil {
+ log.Printf("console: control-plane check %s in account %d: %v", app, accountID, err)
+ jsonError(w, http.StatusServiceUnavailable,
+ "could not check this app's control-plane status — nothing was changed")
+ return true
+ }
+ if !cp {
+ return false
+ }
+ jsonError(w, http.StatusForbidden,
+ app+" is the control plane — a service credential cannot "+verb+" it; a person must")
+ return true
+}
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go
new file mode 100644
index 000000000..de92defdd
--- /dev/null
+++ b/internal/console/controlplane_test.go
@@ -0,0 +1,194 @@
+package console
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// controlPlaneFixture is a console holding ONE control-plane app: `console`
+// in the ops account (bab), owned by paul, with the flag set. It returns the
+// fixture and a live CLI token for paul, so a test can go straight to the
+// door it is about.
+//
+// Deliberately minimal, because later tasks build on it: nothing is shipped,
+// no channel is pointed anywhere, and the ship rate limiter is left alone —
+// a test that ships more than twice calls liftShipBudget itself (apicli_test.go).
+func controlPlaneFixture(t *testing.T, label string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "console", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.SetAppControlPlane(OpsAccountID, "console", true); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "paul@keymail.dev", label)
+}
+
+// TestControlPlaneAppRefusesTrash: the console's release history is not
+// something to delete behind a confirmation dialog. Refused outright, not
+// "with fresh auth" — a gesture is not the guard here, the flag is.
+func TestControlPlaneAppRefusesTrash(t *testing.T) {
+ fx, token := controlPlaneFixture(t, "code-cp-trash")
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, token)
+ 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") {
+ t.Fatalf("refusal %q does not name the flag the operator has to clear", 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)
+ }
+}
+
+// TestControlPlaneAppAllowsAPersonToPromote is the other half, and the one
+// that matters most: gating promotion is what an earlier draft got wrong.
+// `stable`'s fresh-auth 403 has no API-side step-up and carlos deploy
+// log.Fatalfs on a failed promote AFTER the ship has landed, so a promote gate
+// would break the very command this work exists to create.
+func TestControlPlaneAppAllowsAPersonToPromote(t *testing.T) {
+ fx, token := controlPlaneFixture(t, "code-cp-promote")
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
+ []shipPart{metaPart(t, "cp00001", "binary"), {name: "linux-arm64", body: []byte("x")}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship = %d %v", status, out)
+ }
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/promote",
+ map[string]string{"version": "cp00001", "channel": "canary/console"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("a PERSON promoting a control-plane app = %d %v, want 200 — gating this breaks carlos deploy", status, out)
+ }
+}
+
+// TestControlPlaneAppRefusesAServicePromote: a person with ops membership may
+// roll the console; a CI credential may not. This is what the flag buys on the
+// promote path instead of a fresh-auth gate.
+func TestControlPlaneAppRefusesAServicePromote(t *testing.T) {
+ fx, token := controlPlaneFixture(t, "code-cp-svc")
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
+ []shipPart{metaPart(t, "cp00002", "binary"), {name: "linux-arm64", body: []byte("x")}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship = %d %v", status, out)
+ }
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Publish)
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/promote",
+ map[string]string{"version": "cp00002", "channel": "canary/console"}, svc)
+ if status != http.StatusForbidden {
+ t.Fatalf("a SERVICE promoting a control-plane app = %d %v, want 403", status, out)
+ }
+}
+
+// TestControlPlaneAppRefusesAServiceRollback: rollback moves the same pointer
+// promote does, so it wears the same service refusal — otherwise a CI
+// credential rolls the control plane by taking the other verb.
+func TestControlPlaneAppRefusesAServiceRollback(t *testing.T) {
+ fx, token := controlPlaneFixture(t, "code-cp-svc-rollback")
+ liftShipBudget(fx)
+ for _, v := range []string{"cp00003", "cp00004"} {
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
+ []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("x")}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship %s = %d %v", v, status, out)
+ }
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/promote",
+ map[string]string{"version": v, "channel": "canary/console"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("promote %s = %d %v", v, status, out)
+ }
+ }
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Publish)
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/rollback",
+ map[string]string{"channel": "canary/console"}, svc)
+ if status != http.StatusForbidden {
+ t.Fatalf("a SERVICE rolling back a control-plane app = %d %v, want 403", status, out)
+ }
+ // And the pointer did not move.
+ if got := fx.channelVersion(t, "console", "canary/console").Version; got != "cp00004" {
+ t.Fatalf("channel after a refused rollback = %q, want cp00004", got)
+ }
+}
+
+// TestControlPlaneAppRefusesBrowserRename: the browser doors render pages, so
+// their refusal is a flash and a redirect rather than JSON — but it is the
+// same refusal, ahead of the confirm card and ahead of any passkey.
+func TestControlPlaneAppRefusesBrowserRename(t *testing.T) {
+ fx, _ := controlPlaneFixture(t, "code-cp-rename")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ status, loc, _ := postForm(t, c, fx.u("/apps/console/rename"),
+ url.Values{"csrf": {csrf}, "to": {"console2"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("rename a control-plane app = %d, want a redirect carrying the refusal", status)
+ }
+ if !strings.Contains(loc, "/apps/console/settings") {
+ t.Fatalf("rename refusal redirected to %q, want the settings tab", loc)
+ }
+ if renamed, err := fx.st.HasAppIn(OpsAccountID, "console"); err != nil || !renamed {
+ t.Fatalf("app row after a refused rename: HasAppIn(console) = %v %v, want true", renamed, err)
+ }
+ if moved, err := fx.st.HasAppIn(OpsAccountID, "console2"); err != nil || moved {
+ t.Fatalf("HasAppIn(console2) = %v %v, want false — the rename must not have landed", moved, err)
+ }
+}
+
+// TestControlPlaneAppRefusesPurge is the refusal this whole flag exists for:
+// "Delete now" is the console's one irreversible button, and its target is
+// apps/<sqid>/console/** — the release history the fleet is running. The
+// refusal must land BEFORE requireFreshAuth, so the answer is the trash page
+// with a flash, never a trip into Keymail for a passkey that cannot help.
+func TestControlPlaneAppRefusesPurge(t *testing.T) {
+ fx, _ := controlPlaneFixture(t, "code-cp-purge")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ status, loc, _ := postForm(t, c, fx.u("/trash/delete"),
+ url.Values{"csrf": {csrf}, "app": {"console"}, "confirm": {"console"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("purge a control-plane app = %d, want a redirect carrying the refusal", status)
+ }
+ if loc != fx.path("/trash") {
+ t.Fatalf("purge refusal redirected to %q, want %q — a step-up redirect here means the flag was checked too late",
+ loc, fx.path("/trash"))
+ }
+ if present, err := fx.st.HasAppIn(OpsAccountID, "console"); err != nil || !present {
+ t.Fatalf("app row after a refused purge: HasAppIn = %v %v, want true (untouched)", present, err)
+ }
+}
+
+// TestOrdinaryAppUnaffected: every refusal above is scoped to the flag.
+func TestOrdinaryAppUnaffected(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cp-ordinary")
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", map[string]string{}, token)
+ if status == http.StatusForbidden {
+ t.Fatalf("trash an ORDINARY app = %d %v, want it to work as before", status, out)
+ }
+}
+
+// TestControlPlaneRefusalIsNotAMembershipOracle: a non-member must meet the
+// byte-identical 404 in apiAccountApp before the flag is ever consulted, or
+// the refusal becomes a way to learn which accounts hold a control-plane app.
+func TestControlPlaneRefusalIsNotAMembershipOracle(t *testing.T) {
+ fx, _ := controlPlaneFixture(t, "code-cp-oracle")
+ stranger := cliToken(t, fx, "eve@keymail.dev", "code-cp-stranger")
+ // postRaw, not postJSON: apiAccountApp's 404 is http.NotFound's plain
+ // text, and the point of this test is that those bytes do not vary.
+ status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, stranger)
+ if status != http.StatusNotFound {
+ t.Fatalf("a non-member trashing the control plane = %d %q, want the byte-identical 404", status, body)
+ }
+ otherStatus, otherBody := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/delete", map[string]string{}, stranger)
+ if otherStatus != status || otherBody != body {
+ t.Fatalf("control-plane 404 (%d %q) differs from unknown-app 404 (%d %q) — the refusal is an oracle",
+ status, body, otherStatus, otherBody)
+ }
+}
diff --git a/internal/console/ownership.go b/internal/console/ownership.go
index 353ad31e8..c98236c8f 100644
--- a/internal/console/ownership.go
+++ b/internal/console/ownership.go
@@ -274,6 +274,10 @@ func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) {
// answer.
// CSRF — constant-time, before any pending action can be
// parked by a cross-site post.
+// control plane — refused outright, before the typed name and before
+// step-up: an app flagged as the control plane holds the
+// release history the fleet is running, and clearing the
+// flag is the separate act that unlocks this door.
// type the APP name — not a security control, a "did you mean this app?"
// control, and checked BEFORE step-up so a slip does not
// cost a passkey touch.
@@ -308,6 +312,14 @@ func (s *Server) handlePurgeApp(w http.ResponseWriter, r *http.Request) {
fail("Choose an app to delete permanently.")
return
}
+ // The control plane is the one app this door must never destroy: its
+ // 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 != "" {
+ fail(msg)
+ return
+ }
if strings.TrimSpace(r.FormValue("confirm")) != app {
fail("Type “" + app + "” exactly to delete it permanently. Nothing was deleted.")
return