carlos / platform Public

console: deploy scope check at promote and rollback (#229)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 248148d3595b298464042cf712d865ea0dd42ec8 parent 2f64b09
3 files changed, +218 −0
  • internal/console/apicli.go +8 −0
  • internal/console/deployscope.go +68 −0
  • internal/console/deployscope_test.go +142 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 0787152fb..bc17ba42f 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -775,6 +775,10 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadGateway, "could not read the pipeline for "+app)
return
}
+ if err := deployScopeAllows(p, pipe != nil, channel, req.Hotfix); err != nil {
+ s.writeDeployRefusal(w, err)
+ 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
@@ -1020,6 +1024,10 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadGateway, "could not read the pipeline for "+app)
return
}
+ if err := deployScopeAllows(p, pipe != nil, channel, false); err != nil {
+ s.writeDeployRefusal(w, err)
+ return
+ }
cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel)
if err != nil {
jsonError(w, http.StatusNotFound, "could not read channel "+channel)
diff --git a/internal/console/deployscope.go b/internal/console/deployscope.go
index da4994969..3dda60856 100644
--- a/internal/console/deployscope.go
+++ b/internal/console/deployscope.go
@@ -6,9 +6,12 @@ package console
import (
"errors"
+ "fmt"
+ "net/http"
"strings"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
)
// deployRungs is the order rungs are stored and shown in. stable is not in
@@ -74,3 +77,68 @@ func parseStoredRungs(s string) ([]string, bool) {
}
return parts, true
}
+
+// deployRefusal is a promote or rollback a deploy credential may not make.
+// Its text is the 403's error, so a CI log says what to fix (spec §7.3).
+type deployRefusal struct{ msg string }
+
+func (e *deployRefusal) Error() string { return e.msg }
+
+// deployScopeAllows is the whole scope decision for a deploy credential
+// (spec §7.1). It is pure so it can be table-tested, and it is called three
+// times per write: early in the handler for the message, and by the release
+// primitives on their own pipeline reads (Task 7). For every principal that
+// is not a deploy credential it returns nil and changes nothing.
+func deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error {
+ if p.Kind != PrincipalService || p.Role != svcrole.Deploy {
+ return nil
+ }
+ held := deployHeldWords(p.Rungs)
+ if hotfix {
+ return &deployRefusal{fmt.Sprintf("deploy credential %s cannot hotfix. Promote in ladder order, or ask a member to hotfix.", p.Name)}
+ }
+ rung, ok := release.Rung(channel)
+ if !ok || rung == release.RungStable {
+ return &deployRefusal{fmt.Sprintf("deploy credential %s may move %s only, and %s is not one of them.", p.Name, held, channel)}
+ }
+ if rung == release.RungCanary && !strings.HasPrefix(channel, release.RungCanary+"/") {
+ return &deployRefusal{fmt.Sprintf("deploy credential %s may move canary/<name> channels, not canary itself.", p.Name)}
+ }
+ inScope := false
+ for _, r := range p.Rungs {
+ if r == rung {
+ inScope = true
+ }
+ }
+ if !inScope {
+ return &deployRefusal{fmt.Sprintf("deploy credential %s may move %s only, and %s is not one of them.", p.Name, held, channel)}
+ }
+ if pipelined && rung != release.RungCanary {
+ return &deployRefusal{fmt.Sprintf("%s has a pipeline, so deploy credential %s may move canary/<name> channels only.", p.App, p.Name)}
+ }
+ return nil
+}
+
+// deployHeldWords is the rung list as the refusal names it: canary shown as
+// the channels it covers.
+func deployHeldWords(rungs []string) string {
+ words := make([]string, len(rungs))
+ for i, r := range rungs {
+ words[i] = r
+ if r == release.RungCanary {
+ words[i] = release.RungCanary + "/<name>"
+ }
+ }
+ return strings.Join(words, ", ")
+}
+
+// writeDeployRefusal answers a scope refusal. Anything that is not a
+// deployRefusal is a programming error and answers 500.
+func (s *Server) writeDeployRefusal(w http.ResponseWriter, err error) {
+ var dr *deployRefusal
+ if errors.As(err, &dr) {
+ jsonError(w, http.StatusForbidden, dr.msg)
+ return
+ }
+ jsonError(w, http.StatusInternalServerError, "internal error")
+}
diff --git a/internal/console/deployscope_test.go b/internal/console/deployscope_test.go
new file mode 100644
index 000000000..292710d12
--- /dev/null
+++ b/internal/console/deployscope_test.go
@@ -0,0 +1,142 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+func TestDeployScopeAllows(t *testing.T) {
+ ce := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"canary", "edge"}}
+ beta := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"beta"}}
+ pub := Principal{Kind: PrincipalService, Role: svcrole.Publish, Name: "ci", App: "hello"}
+ person := Principal{Kind: PrincipalUser, Address: "paul@keymail.dev"}
+ for _, tc := range []struct {
+ name string
+ p Principal
+ pipelined bool
+ channel string
+ hotfix bool
+ ok bool
+ }{
+ {"edge in scope", ce, false, "edge", false, true},
+ {"canary branch in scope", ce, false, "canary/feature-x", false, true},
+ {"bare canary refused", ce, false, "canary", false, false},
+ {"bare canary refused on a pipelined app", ce, true, "canary", false, false},
+ {"beta outside rungs", ce, false, "beta", false, false},
+ {"stable refused", ce, false, "stable", false, false},
+ {"stable refused even when granted beta", beta, false, "stable", false, false},
+ {"not a rung", ce, false, "staging", false, false},
+ {"hotfix refused", ce, false, "edge", true, false},
+ {"pipelined: edge refused", ce, true, "edge", false, false},
+ {"pipelined: canary branch allowed", ce, true, "canary/x", false, true},
+ {"beta credential on beta", beta, false, "beta", false, true},
+ {"publish untouched", pub, true, "stable", true, true},
+ {"person untouched", person, true, "stable", true, true},
+ } {
+ err := deployScopeAllows(tc.p, tc.pipelined, tc.channel, tc.hotfix)
+ if (err == nil) != tc.ok {
+ t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok)
+ }
+ }
+}
+
+// TestDeployScopeRefusalsAtTheDoors: promote and rollback by a deploy
+// credential refuse out of scope moves with the approved text (D21 to D24)
+// and leave every channel exactly as it was.
+func TestDeployScopeRefusalsAtTheDoors(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ person := cliToken(t, fx, "paul@keymail.dev", "code-deploy-scope")
+ liftShipBudget(fx)
+ if status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/hello/releases", person,
+ []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: []byte("not an ELF at all")}}); status != http.StatusCreated {
+ t.Fatalf("person ship = %d %v", status, out)
+ }
+ tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, "paul@keymail.dev", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx := context.Background()
+ channelVersion := func(channel string) string {
+ t.Helper()
+ cp, err := release.Channel(ctx, fx.rel, fx.pub, "bab", "hello", channel)
+ if err != nil {
+ return "" // absent
+ }
+ return cp.Version
+ }
+ promote := func(body map[string]any) (int, map[string]any) {
+ t.Helper()
+ return postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", body, tok)
+ }
+ refused := func(name string, status int, out map[string]any, want string) {
+ t.Helper()
+ if status != http.StatusForbidden {
+ t.Errorf("%s: status = %d %v, want 403", name, status, out)
+ return
+ }
+ if got, _ := out["error"].(string); got != want {
+ t.Errorf("%s: error = %q, want %q", name, got, want)
+ }
+ }
+
+ type attempt struct {
+ name string
+ body map[string]any
+ channel string
+ want string
+ }
+ d22 := func(channel string) string {
+ return "deploy credential ci may move canary/<name>, edge only, and " + channel + " is not one of them."
+ }
+ for _, a := range []attempt{
+ {"beta", map[string]any{"version": "v1", "channel": "beta"}, "beta", d22("beta")},
+ {"stable", map[string]any{"version": "v1", "channel": "stable"}, "stable", d22("stable")},
+ {"bare canary", map[string]any{"version": "v1", "channel": "canary"}, "canary",
+ "deploy credential ci may move canary/<name> channels, not canary itself."},
+ {"hotfix", map[string]any{"version": "v1", "channel": "edge", "hotfix": true}, "edge",
+ "deploy credential ci cannot hotfix. Promote in ladder order, or ask a member to hotfix."},
+ } {
+ before := channelVersion(a.channel)
+ status, out := promote(a.body)
+ refused(a.name, status, out, a.want)
+ if after := channelVersion(a.channel); after != before {
+ t.Errorf("%s: channel %s moved %q -> %q", a.name, a.channel, before, after)
+ }
+ }
+
+ // A pipeline names edge and stable. Edge is then refused (D24), and a
+ // pipeline naming stable with no passkey rule still refuses deploy.
+ if _, err := release.SetPipeline(ctx, fx.rel, fx.priv, "bab", "hello",
+ []release.PipelineChannel{{Name: "edge"}, {Name: "stable"}}, "paul@keymail.dev"); err != nil {
+ t.Fatal(err)
+ }
+ for _, a := range []attempt{
+ {"pipelined edge", map[string]any{"version": "v1", "channel": "edge"}, "edge",
+ "hello has a pipeline, so deploy credential ci may move canary/<name> channels only."},
+ {"pipelined stable", map[string]any{"version": "v1", "channel": "stable"}, "stable", d22("stable")},
+ } {
+ before := channelVersion(a.channel)
+ status, out := promote(a.body)
+ refused(a.name, status, out, a.want)
+ if after := channelVersion(a.channel); after != before {
+ t.Errorf("%s: channel %s moved %q -> %q", a.name, a.channel, before, after)
+ }
+ }
+
+ // Rollback: beta is outside the credential's rungs.
+ before := channelVersion("beta")
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/rollback", map[string]any{"channel": "beta"}, tok)
+ refused("rollback beta", status, out, d22("beta"))
+ if after := channelVersion("beta"); after != before {
+ t.Errorf("rollback beta: channel moved %q -> %q", before, after)
+ }
+}