console: an owner can clear the control-plane flag, and CI can preview retention
Ruling 4 — the clear door, which the refusals were pointing at: Every refusal says "clear the control-plane flag first", and until now no door could. That is user-facing text instructing an operator to do something impossible, on the exact path where they are already blocked. The door is browser-only and passkey-gated (spec §6): owner, CSRF, a cheap nothing-to-clear refusal, then requireFreshAuth LAST — handleAppRename's order unchanged. The settings page gains a card that says what clearing gives up, and a flagged app is shown that sentence instead of a rename form the handler would refuse. There is deliberately NO API clear door, and the handler says why: the console's fresh-auth 403 is a dead end for an API caller — the defect that makes a promote gate unworkable — so a CLI clear would be ungated or unusable. Setting is easy (CLI, at claim time); clearing is hard. Ruling 5 — retention's dry run is allowed for a service; the branches that write are still refused. A preview mutates nothing, and CI that cannot see what a policy would do cannot bring a person a reviewed proposal. The guard reads the same decoded struct the switch reads: one parse, no field that can flip the branch afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+238
−14
internal/console/apiretention.go+16 −13internal/console/appsettings.go+13 −0internal/console/controlplane.go+72 −0internal/console/controlplane_test.go+112 −0internal/console/server.go+3 −0internal/console/templates/app_settings.html+22 −1
diff --git a/internal/console/apiretention.go b/internal/console/apiretention.go| index 4deb47ba6..8411601da 100644 |
| --- a/internal/console/apiretention.go |
| +++ b/internal/console/apiretention.go |
| @@ -104,19 +104,6 @@ func (s *Server) handleAPIRetentionSet(w http.ResponseWriter, r *http.Request) { |
| "no deployment bucket on this host — this console cannot write retention") |
| return |
| } |
| - // Retention is how the rollback DEPTH behind a control-plane app gets |
| - // shortened, so it wears the promote pair's service refusal: a CI |
| - // credential must not quietly shrink the console's own history. A PERSON |
| - // still may — how much history to keep is an operator's decision, and |
| - // PruneEligible never touches anything promoted, tagged or recent. |
| - // |
| - // The whole door, dry run included: this is a POST whose default branch is |
| - // the write, and a credential that cannot set retention has no use for a |
| - // preview of setting it. Reading the current policy stays open — that is |
| - // the GET (controlplane.go). |
| - if s.refuseServiceControlPlane(w, p, acct.ID, app, "change its release retention") { |
| - return |
| - } |
| var in struct { |
| Keep int `json:"keep"` |
| Off bool `json:"off"` |
| @@ -136,6 +123,22 @@ func (s *Server) handleAPIRetentionSet(w http.ResponseWriter, r *http.Request) { |
| release.KeepFloor, release.KeepFloor)) |
| return |
| } |
| + // Retention is how the rollback DEPTH behind a control-plane app gets |
| + // shortened, so the branches that WRITE wear the promote pair's service |
| + // refusal: a CI credential must not quietly shrink the console's own |
| + // history. A PERSON still may — how much history to keep is an operator's |
| + // decision, and PruneEligible never touches anything promoted, tagged or |
| + // recent (controlplane.go). |
| + // |
| + // The dry run is exempt because it writes nothing, and a CI that cannot |
| + // see what a policy WOULD do has no way to bring a person a reviewed |
| + // proposal. The condition reads `in`, which is decoded once above and |
| + // never written again, and the switch below re-reads that same struct — |
| + // there is no second parse and no request field that could flip the |
| + // branch after this check. |
| + if !in.DryRun && s.refuseServiceControlPlane(w, p, acct.ID, app, "change its release retention") { |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) |
| defer cancel() |
| switch { |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index b2916fa47..bb27f9054 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -49,6 +49,14 @@ type SettingsView struct { |
| CanRename bool |
| RenameBlocked string |
| + // ControlPlane: this app IS the platform, so its destructive verbs are |
| + // refused until the flag is cleared (controlplane.go). Read straight |
| + // from the row rather than derived, and false when the row cannot be |
| + // read — the PAGE showing no card is the right failure, since the |
| + // handler re-decides everything anyway and the refusals do not depend |
| + // on this being rendered. |
| + ControlPlane bool |
| + |
| // The account-move repair: MismatchTarget is the account the app's |
| // routes say it belongs to ("" when console and routes agree), |
| // TargetKnown whether this console has that account at all, and |
| @@ -129,6 +137,11 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) * |
| // with no bucket configured (s.cfg.Releases == nil), which used to |
| // return before reaching them and show "No domains yet" under a live |
| // route. |
| + if cp, _, err := s.store.AppFlags(acct.ID, p.AppName); err != nil { |
| + log.Printf("console: settings control-plane flag %s/%s: %v", acct.Sqid, p.AppName, err) |
| + } else { |
| + sv.ControlPlane = cp |
| + } |
| sv.Domains = s.settingsDomains(ctx, acct, p.AppName, p.Detail.Domains) |
| sv.OnlyPlatformDomain = len(sv.Domains) > 0 |
| for _, d := range sv.Domains { |
diff --git a/internal/console/controlplane.go b/internal/console/controlplane.go| index 8f5696a10..216fefb71 100644 |
| --- a/internal/console/controlplane.go |
| +++ b/internal/console/controlplane.go |
| @@ -18,11 +18,19 @@ |
| // |
| // 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. |
| +// |
| +// The CLEAR door lives here too (handleAppControlPlaneClear), rather than in |
| +// appsettings.go with the settings tab's other writes, because the refusals it |
| +// unlocks are in this file and the two must be read together: every refusal |
| +// above ends "clear the control-plane flag first", and this is the only place |
| +// that sentence can be acted on. |
| package console |
| import ( |
| + "crypto/subtle" |
| "log" |
| "net/http" |
| + "net/url" |
| "strings" |
| ) |
| @@ -155,3 +163,67 @@ func isare(n int) string { |
| } |
| return "are" |
| } |
| + |
| +// handleAppControlPlaneClear takes the flag off, which is the only way past |
| +// every refusal in this file. |
| +// |
| +// BROWSER ONLY, and that is the design rather than a gap (spec §6). Setting |
| +// the flag is easy — the CLI, at claim time — and clearing it is hard: the |
| +// owner, in a browser, with a passkey. There is deliberately no API door: the |
| +// console's fresh-auth 403 is a dead end for an API caller (the same defect |
| +// that makes a promote gate unworkable), so a CLI clear would have to be |
| +// either ungated, which defeats the flag, or unusable, which is worse than |
| +// absent. |
| +// |
| +// The gate order is handleAppRename's, unchanged: |
| +// |
| +// accountApp — session, then the app's byte-identical 404. |
| +// CSRF — constant-time, before any pending action can be parked. |
| +// owner — a member probing this gets a stranger's answer. |
| +// nothing to clear — a cheap refusal, so an already-clear app never costs a |
| +// passkey touch. |
| +// requireFreshAuth — LAST, so no redirect into Keymail ever happens for |
| +// someone who should have got a 404. |
| +// |
| +// No serial bump: the flag is this console's own row and no box parses it. |
| +func (s *Server) handleAppControlPlaneClear(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) |
| + } |
| + // Fail CLOSED on an unreadable row, like every other reader of this flag: |
| + // clearing what may already be clear is harmless, but a lookup error here |
| + // would otherwise be reported to the operator as a successful clear. |
| + cp, _, err := s.store.AppFlags(acct.ID, app) |
| + if err != nil { |
| + log.Printf("console: control-plane clear 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 not marked as the control plane, so there is nothing to clear.") |
| + return |
| + } |
| + if !s.requireFreshAuth(w, r, addr, raw) { |
| + return |
| + } |
| + if err := s.store.SetAppControlPlane(acct.ID, app, false); err != nil { |
| + log.Printf("console: control-plane clear %s/%s: %v", acct.Sqid, app, err) |
| + back("err", "Could not clear the control-plane flag — nothing was changed. Try again.") |
| + return |
| + } |
| + 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.") |
| +} |
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go| index 64a18172f..780c2393f 100644 |
| --- a/internal/console/controlplane_test.go |
| +++ b/internal/console/controlplane_test.go |
| @@ -269,3 +269,115 @@ func TestControlPlaneRetentionRefusesAServiceButNotAPerson(t *testing.T) { |
| t.Fatalf("a PERSON setting retention on the control plane = %d %v, want 200 — only the service is barred", status, out) |
| } |
| } |
| + |
| +// TestServiceMayPreviewRetentionButNotApplyIt: a preview writes nothing, and |
| +// refusing it would leave CI no way to see what a policy WOULD do before a |
| +// person applies it. The refusal is scoped to the branch that mutates. |
| +func TestServiceMayPreviewRetentionButNotApplyIt(t *testing.T) { |
| + fx, _ := controlPlaneFixture(t, "code-cp-dryrun") |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Publish) |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/retention", |
| + map[string]any{"keep": release.KeepFloor, "dry_run": true}, svc) |
| + if status != http.StatusOK { |
| + t.Fatalf("a SERVICE previewing retention on the control plane = %d %v, want 200", status, out) |
| + } |
| + if dry, _ := out["dry_run"].(bool); !dry { |
| + t.Fatalf("preview answer is not a dry run: %v", out) |
| + } |
| + // The same credential, the same door, the branch that writes: refused. |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/retention", |
| + map[string]any{"keep": release.KeepFloor}, svc) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE applying retention = %d %v, want 403", status, out) |
| + } |
| + // And `off`, which is a write of its own shape. |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/retention", |
| + map[string]any{"off": true}, svc) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE clearing retention = %d %v, want 403", status, out) |
| + } |
| +} |
| + |
| +// TestOwnerClearsTheControlPlaneFlag is the door the refusals point at. Every |
| +// refusal in this file says "clear the control-plane flag first"; without a |
| +// way to do that, the sentence instructs an operator to do something |
| +// impossible on the exact path where they are already blocked. |
| +// |
| +// Browser-only and passkey-gated by design (spec §6): setting the flag is |
| +// easy (the CLI, at claim time), clearing it is hard. There is deliberately |
| +// no API clear door — the console's fresh-auth 403 is a dead end for an API |
| +// caller, so a CLI clear would be either ungated or unusable. |
| +func TestOwnerClearsTheControlPlaneFlag(t *testing.T) { |
| + fx, token := controlPlaneFixture(t, "code-cp-clear") |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // The page has to OFFER it, or the door might as well not exist. |
| + if _, _, page := get(t, c, fx.u("/apps/console/settings")); !strings.Contains(page, "/apps/console/control-plane/clear") { |
| + t.Fatalf("the settings page offers no way to clear the flag:\n%s", page) |
| + } |
| + status, loc, body := postGated(t, fx, c, fx.u("/apps/console/control-plane/clear"), |
| + url.Values{"csrf": {csrf}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("clear the flag = %d -> %q:\n%s", status, loc, body) |
| + } |
| + cp, _, err := fx.st.AppFlags(OpsAccountID, "console") |
| + if err != nil || cp { |
| + t.Fatalf("AppFlags after a confirmed clear = %v %v, want false", cp, err) |
| + } |
| + // The card is state-driven: with the flag gone, so is the button. |
| + if _, _, page := get(t, c, fx.u("/apps/console/settings")); strings.Contains(page, "/apps/console/control-plane/clear") { |
| + t.Fatalf("the clear form is still offered on an app that is no longer flagged:\n%s", page) |
| + } |
| + // And the verb that was refused now works — the whole point of the door. |
| + status2, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, token) |
| + if status2 != http.StatusOK { |
| + t.Fatalf("trash after clearing the flag = %d %v, want 200", status2, out) |
| + } |
| +} |
| + |
| +// TestClearingTheControlPlaneFlagDemandsAPasskey: without the step-up the |
| +// clear does not happen. postForm, not postGated, so the passkey round trip |
| +// is visible rather than walked. |
| +func TestClearingTheControlPlaneFlagDemandsAPasskey(t *testing.T) { |
| + fx, _ := controlPlaneFixture(t, "code-cp-clear-nopasskey") |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, loc, body := postForm(t, c, fx.u("/apps/console/control-plane/clear"), |
| + url.Values{"csrf": {csrf}}) |
| + if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| + t.Fatalf("clear without a passkey = %d -> %q, want the step-up redirect:\n%s", status, loc, body) |
| + } |
| + if cp, _, err := fx.st.AppFlags(OpsAccountID, "console"); err != nil || !cp { |
| + t.Fatalf("AppFlags after an unconfirmed clear = %v %v, want true (still flagged)", cp, err) |
| + } |
| +} |
| + |
| +// TestMemberCannotClearTheControlPlaneFlag proves the gate ORDER as much as |
| +// the permission: a member meets the byte-identical 404, and never a redirect |
| +// into Keymail — requireFreshAuth is LAST, so no step-up is ever offered to |
| +// someone who should have got a 404. |
| +func TestMemberCannotClearTheControlPlaneFlag(t *testing.T) { |
| + fx, _ := controlPlaneFixture(t, "code-cp-clear-member") |
| + 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-clear-kim") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, loc, _ := postForm(t, c, fx.u("/apps/console/control-plane/clear"), |
| + url.Values{"csrf": {csrf}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("a member clearing the flag = %d -> %q, want the byte-identical 404", status, loc) |
| + } |
| + if loc != "" { |
| + t.Fatalf("a member was redirected to %q — step-up must never be offered ahead of the 404", loc) |
| + } |
| + 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) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 981579f19..e9b5af180 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -749,6 +749,9 @@ 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). |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/control-plane/clear", s.handleAppControlPlaneClear) |
| // 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 adff2cacf..39e343f78 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -11,7 +11,11 @@ |
| </table> |
| </div> |
| {{if .IsOwner}} |
| -{{if .Settings.CanRename}} |
| +{{if .Settings.ControlPlane}} |
| +<p class="small">This app is marked as the <strong>control plane</strong> — it |
| +is this deployment’s own platform. Renaming, moving and deleting it are |
| +refused while the flag is set, and a service credential cannot promote it.</p> |
| +{{else if .Settings.CanRename}} |
| <form method="post" action="{{.Base}}/apps/{{.Detail.App}}/rename" class="settings-row"> |
| <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| <label class="visually-hidden" for="rename-to">New name</label> |
| @@ -157,6 +161,23 @@ 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}} |
| + |
| +<h2 class="danger-head">Control plane</h2> |
| +<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}} |
| {{if .IsOwner}} |
| <h2 class="danger-head">Delete this app</h2> |