console: an owner can mark an app as the control plane, without a passkey
Ruling 6. Clearing the flag was one-way: it could only be SET at claim time, so an owner who cleared it on a live console had no way back except a sqlite write on the box — which is the product gap CLAUDE.md refuses to accept, not a workaround. The settings card now carries both directions, owner-only, with spec §6's asymmetry: clearing is the dangerous act and keeps requireFreshAuth LAST; setting only ADDS refusals, so it deliberately demands no step-up. Making the safe direction expensive would discourage it and teach nobody anything. Both directions are audited, and the card explains what marking an app costs and what it does not — a person with ops membership still deploys it exactly as before. The card's one-way warning is gone, because it is no longer true. Ruling 7, recorded not coded: nothing constrains an account to a single flagged app, and nothing should. controlPlaneAppsIn already returns a list and the account refusal names every app it finds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+137
−8
internal/console/controlplane.go+49 −0internal/console/controlplane_test.go+64 −0internal/console/server.go+5 −2internal/console/templates/app_settings.html+19 −6
diff --git a/internal/console/controlplane.go b/internal/console/controlplane.go| index 216fefb71..a9c545768 100644 |
| --- a/internal/console/controlplane.go |
| +++ b/internal/console/controlplane.go |
| @@ -227,3 +227,52 @@ func (s *Server) handleAppControlPlaneClear(w http.ResponseWriter, r *http.Reque |
| s.audit(acct, app, addr, "control-plane", "cleared the control-plane flag") |
| back("ok", "Cleared the control-plane flag on "+app+" — it can now be renamed, moved and deleted like any other app.") |
| } |
| + |
| +// handleAppControlPlaneSet is the other direction, and the reason the flag is |
| +// not a trap: an owner who clears it must be able to put it back without a |
| +// database write on a box — the platform's own rule is that a job with no |
| +// API/CLI door is a product gap, not a license to open sqlite. |
| +// |
| +// Deliberately NO fresh auth (spec §6). The asymmetry is the whole design: |
| +// setting the flag only ADDS refusals, so the dangerous direction is clearing, |
| +// and that is the one that costs a passkey. Making setting expensive would |
| +// discourage the safe act and teach nobody anything. |
| +// |
| +// Owner-only all the same: declaring an app to BE the platform decides what |
| +// the account's other owners can no longer delete, which is not a member's |
| +// call. |
| +func (s *Server) handleAppControlPlaneSet(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 |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther) |
| + } |
| + cp, _, err := s.store.AppFlags(acct.ID, app) |
| + if err != nil { |
| + log.Printf("console: control-plane set read %s/%s: %v", acct.Sqid, app, err) |
| + back("err", "Could not read this app's control-plane setting — nothing was changed. Try again.") |
| + return |
| + } |
| + if cp { |
| + back("err", app+" is already marked as the control plane.") |
| + return |
| + } |
| + if err := s.store.SetAppControlPlane(acct.ID, app, true); err != nil { |
| + log.Printf("console: control-plane set %s/%s: %v", acct.Sqid, app, err) |
| + back("err", "Could not mark this app as the control plane — nothing was changed. Try again.") |
| + return |
| + } |
| + s.audit(acct, app, addr, "control-plane", "marked "+app+" as the control plane") |
| + back("ok", "Marked "+app+" as the control plane — trashing, deleting, renaming and moving it are refused until the flag is cleared.") |
| +} |
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go| index 780c2393f..f6d5ca05b 100644 |
| --- a/internal/console/controlplane_test.go |
| +++ b/internal/console/controlplane_test.go |
| @@ -381,3 +381,67 @@ func TestMemberCannotClearTheControlPlaneFlag(t *testing.T) { |
| t.Fatalf("AppFlags after a member's attempt = %v %v, want true (still flagged)", cp, err) |
| } |
| } |
| + |
| +// TestOwnerSetsTheControlPlaneFlagWithoutAPasskey is the other half of the |
| +// card, and it is what stops the flag being a one-way trap: an owner who |
| +// clears it on a live console must be able to put it back without a database |
| +// write on the box. |
| +// |
| +// postForm, not postGated: setting the flag is the SAFE direction (spec §6), |
| +// so it deliberately demands no step-up — a redirect into Keymail here would |
| +// be the asymmetry inverted. |
| +func TestOwnerSetsTheControlPlaneFlagWithoutAPasskey(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-set") |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + if _, _, page := get(t, c, fx.u("/apps/titogo/settings")); !strings.Contains(page, "/apps/titogo/control-plane/set") { |
| + t.Fatalf("the settings page offers no way to set the flag:\n%s", page) |
| + } |
| + status, loc, body := postForm(t, c, fx.u("/apps/titogo/control-plane/set"), |
| + url.Values{"csrf": {csrf}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("set the flag = %d -> %q:\n%s", status, loc, body) |
| + } |
| + if loc != fx.path("/apps/titogo/settings") { |
| + t.Fatalf("set redirected to %q, want the settings tab — a Keymail redirect means setting grew a passkey it must not have", loc) |
| + } |
| + if cp, _, err := fx.st.AppFlags(OpsAccountID, "titogo"); err != nil || !cp { |
| + t.Fatalf("AppFlags after a set = %v %v, want true", cp, err) |
| + } |
| + // The refusals came back with it — the set is not decoration. |
| + status2, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", map[string]string{}, token) |
| + if status2 != http.StatusForbidden { |
| + t.Fatalf("trash after setting the flag = %d %v, want 403", status2, out) |
| + } |
| +} |
| + |
| +// TestMemberCannotSetTheControlPlaneFlag: declaring an app to be the platform |
| +// is an owner's act, and a member meets the same byte-identical 404 the clear |
| +// door gives them — with no step-up ever offered ahead of it. |
| +func TestMemberCannotSetTheControlPlaneFlag(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("kim@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-cp-set-kim") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, loc, _ := postForm(t, c, fx.u("/apps/titogo/control-plane/set"), |
| + url.Values{"csrf": {csrf}}) |
| + if status != http.StatusNotFound || loc != "" { |
| + t.Fatalf("a member setting the flag = %d -> %q, want the byte-identical 404", status, loc) |
| + } |
| + 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) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index e9b5af180..ff4d49cd2 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -749,9 +749,12 @@ 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) |
| - // Clearing the control-plane flag is browser-only and passkey-gated by |
| - // design; there is deliberately no API sibling (controlplane.go). |
| + // The control-plane flag's two directions, both browser-only and both |
| + // owner-only. Clearing is passkey-gated, setting is not — the asymmetry |
| + // is spec §6's, and there is deliberately no API sibling for either |
| + // (controlplane.go). |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/control-plane/clear", s.handleAppControlPlaneClear) |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/control-plane/set", s.handleAppControlPlaneSet) |
| // Custom domain attach/detach (appdomains.go, #82): owner-only, no |
| // passkey step-up (spec §6) — detach's guard is the typed-host confirm. |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/domains/attach", s.handleAppDomainAttach) |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index 39e343f78..469278b8d 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -161,23 +161,36 @@ credentials — an operator of this deployment has to tear it down.</p> |
| and delivers its credentials into this app's config.</p> |
| {{end}} |
| {{end}}{{end}} |
| -{{if and .Settings .Settings.ControlPlane}}{{if .IsOwner}} |
| +{{if and .Settings .IsOwner}} |
| <h2 class="danger-head">Control plane</h2> |
| +{{if .Settings.ControlPlane}} |
| <p class="small">{{.Detail.App}} is marked as the control plane: it is what |
| serves this deployment, so its release history is what the fleet is running. |
| Clearing the flag removes that protection — trash, permanent deletion, rename |
| and the account move all become available, and a service credential can promote |
| it again.</p> |
| -<p class="small">Clearing is one-way from here: the flag is stamped when an app |
| -is claimed (<code>carlos apps create -control-plane</code>), and this page |
| -cannot put it back.</p> |
| <form method="post" action="{{.Base}}/apps/{{.Detail.App}}/control-plane/clear" class="settings-row"> |
| <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| <button type="submit" class="btn-sm btn-danger">Clear the control-plane flag</button> |
| </form> |
| -<p class="small">Confirms with your passkey.</p> |
| -{{end}}{{end}} |
| +<p class="small">Confirms with your passkey — clearing is the dangerous |
| +direction, so it is the one that asks. Marking an app again does not.</p> |
| +{{else}} |
| +<p class="small">{{.Detail.App}} is an ordinary app. Marking it as the control |
| +plane says it IS this deployment’s platform: trashing, permanent |
| +deletion, renaming and the account move are then refused, the account holding |
| +it cannot be retired, and a service credential can neither promote it nor |
| +shorten its release history. A person with ops membership still deploys it |
| +exactly as before.</p> |
| +<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/control-plane/set" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <button type="submit" class="btn-sm">Mark as control plane</button> |
| +</form> |
| +<p class="small">No passkey: marking only adds refusals, and it is undone from |
| +this same card.</p> |
| +{{end}} |
| +{{end}} |
| {{if .IsOwner}} |
| <h2 class="danger-head">Delete this app</h2> |