carlos / platform Public

console: the doors a deploy credential reaches (#229)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ddefc40d923a58c0cc5b0747acbb6b6af9786ef7 parent a54f7ca
3 files changed, +83 −14
  • internal/console/apicli.go +1 −1
  • internal/console/authcli.go +13 −13
  • internal/console/deploydoors_test.go +69 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 0787152fb..08555b983 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -68,7 +68,7 @@ func (s *Server) apiAccountAppBy(w http.ResponseWriter, r *http.Request, has fun
// already refused outright for the one credential shape that can carry
// one (see serviceBinding).
if p.Kind == PrincipalService && p.App != "" && p.App != app {
- http.NotFound(w, r)
+ jsonError(w, http.StatusNotFound, "app not found")
return "", nil, "", false
}
found, err := has(acct.ID, app)
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 2f35a07dc..a74696222 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -473,7 +473,7 @@ func (s *Server) registerCLIRoutes() {
// The CLI's read-only API (apicli.go) — deliberately a different prefix
// (/api/cli/) from the identity routes above (/auth/cli/), which must not
// move now that CLIs depend on them.
- s.cliRoute("GET /api/cli/apps", svcrole.Allow(svcrole.Publish, svcrole.Instance, svcrole.Operate), s.handleAPIApps)
+ s.cliRoute("GET /api/cli/apps", svcrole.Allow(svcrole.Publish, svcrole.Deploy, svcrole.Instance, svcrole.Operate), s.handleAPIApps)
// The fleet-wide live tenancy read (discussion #11). An EMPTY role set:
// no service credential of any role reaches it, because the answer names
// every account's instances on every box, and that is an operator's view
@@ -485,10 +485,10 @@ func (s *Server) registerCLIRoutes() {
// bare POST .../{app} route exists to collide with, and the mux prefers
// the literal if one ever does.
s.cliRoute("POST /api/cli/apps/{account}/new", svcrole.Allow(svcrole.Publish), s.handleAPICreateApp)
- s.cliRoute("GET /api/cli/apps/{account}/{app}/channels", svcrole.Allow(svcrole.Publish), s.handleAPIChannels)
- s.cliRoute("POST /api/cli/apps/{account}/{app}/promote", svcrole.Allow(svcrole.Publish), s.handleAPIPromote)
- s.cliRoute("POST /api/cli/apps/{account}/{app}/rollback", svcrole.Allow(svcrole.Publish), s.handleAPIRollback)
- s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish), s.handleAPIShip)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/channels", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIChannels)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/promote", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPromote)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/rollback", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIRollback)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIShip)
// `carlos restart` (issue #113): a member's "cycle this app's processes"
// with no version and no config to push — the same write-door class as
// promote and rollback, and the only one whose subject is a running
@@ -522,11 +522,11 @@ func (s *Server) registerCLIRoutes() {
// release versioning spec 2026-08-08). GET .../releases coexists with
// the POST above on the SAME path — a distinct method route, not a
// collision — for `carlos releases` to read what `carlos ship` writes.
- s.cliRoute("GET /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish), s.handleAPIVersionTargetGet)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIVersionTargetGet)
s.cliRoute("PUT /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish), s.handleAPIVersionTargetSet)
- s.cliRoute("GET /api/cli/apps/{account}/{app}/tags", svcrole.Allow(svcrole.Publish), s.handleAPITagsList)
- s.cliRoute("GET /api/cli/apps/{account}/{app}/tags/{version}", svcrole.Allow(svcrole.Publish), s.handleAPITagGet)
- s.cliRoute("GET /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish), s.handleAPIReleasesList)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/tags", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagsList)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/tags/{version}", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagGet)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIReleasesList)
// The pipeline API (apipipeline.go, release pipelines v2). GET is
// release metadata like channels/releases above — Publish reaches it.
// PUT rewrites the policy governing every promotion into every channel
@@ -539,14 +539,14 @@ func (s *Server) registerCLIRoutes() {
// below. Naming Admin explicitly is what actually opens the door to an
// admin-role service and closes it to every other role, exactly like
// the ledgers and store families.
- s.cliRoute("GET /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Publish), s.handleAPIPipelineGet)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPipelineGet)
s.cliRoute("PUT /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Admin), s.handleAPIPipelinePut)
// What the app is waiting on: the proposals a gated promote, fast-track
// or policy edit turned into (pendinghandlers.go). Read-only, and Publish
// like channels and releases — a proposal names no secret, and the CLI
// needs to see one precisely because it can never confirm one itself
// (spec §4: confirming takes a passkey, in the console).
- s.cliRoute("GET /api/cli/apps/{account}/{app}/pending", svcrole.Allow(svcrole.Publish), s.handleAPIPending)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/pending", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPending)
// Trash and restore (apicli.go). Delete resolves through the LIVE-apps
// door and restore through the trashed-apps one, so each verb answers
// only for an app in the state it can act on — and both give a stranger
@@ -585,10 +585,10 @@ func (s *Server) registerCLIRoutes() {
// while the handler refuses is a registration that lies. As at the mint
// door, the ANSWER a credential gets here moves from 401 to 403.
s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances-manifest", svcrole.Set{}, s.handleAPIInstanceEnable)
- s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceList)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIInstanceList)
// The armed-regions answer for consumer pickers (issue #346) — the
// same gate and role as the list beside it; see handleAPIRegions.
- s.cliRoute("GET /api/cli/apps/{account}/{app}/regions", svcrole.Allow(svcrole.Instance), s.handleAPIRegions)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/regions", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIRegions)
// The per-caller region guess (nearest-region spec §9.4): POST, so the
// hints never ride a URL a redirect could echo.
s.cliRoute("POST /api/cli/apps/{account}/{app}/regions/guess", svcrole.Allow(svcrole.Instance), s.handleAPIRegionGuess)
diff --git a/internal/console/deploydoors_test.go b/internal/console/deploydoors_test.go
new file mode 100644
index 000000000..fb7fb4d59
--- /dev/null
+++ b/internal/console/deploydoors_test.go
@@ -0,0 +1,69 @@
+package console
+
+import (
+ "net/http"
+ "reflect"
+ "sort"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// deployDoors is spec §6, verbatim. A door added later is refused to a
+// deploy credential unless someone adds it here on purpose.
+var deployDoors = []string{
+ "GET /api/cli/apps",
+ "GET /api/cli/apps/{account}/{app}/channels",
+ "POST /api/cli/apps/{account}/{app}/releases",
+ "GET /api/cli/apps/{account}/{app}/releases",
+ "POST /api/cli/apps/{account}/{app}/promote",
+ "POST /api/cli/apps/{account}/{app}/rollback",
+ "GET /api/cli/apps/{account}/{app}/tags",
+ "GET /api/cli/apps/{account}/{app}/tags/{version}",
+ "GET /api/cli/apps/{account}/{app}/version-target",
+ "GET /api/cli/apps/{account}/{app}/pipeline",
+ "GET /api/cli/apps/{account}/{app}/pending",
+ "GET /api/cli/apps/{account}/{app}/instances", // R6: carlos deploy
+ "GET /api/cli/apps/{account}/{app}/regions", // R6: carlos deploy --region
+}
+
+func TestDeployReachesExactlyItsDoors(t *testing.T) {
+ s := newConsoleFixture(t).srv
+ var got []string
+ for pattern, set := range s.cliRoles {
+ if set.Permits(svcrole.Deploy) {
+ got = append(got, pattern)
+ }
+ }
+ sort.Strings(got)
+ want := append([]string(nil), deployDoors...)
+ sort.Strings(want)
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("deploy reaches:\n got %q\nwant %q", got, want)
+ }
+}
+
+// Review Focus 2: the app binding holds for deploy like any service.
+func TestDeployIsBoundToItsApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ for _, app := range []string{"hello", "other"} {
+ if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, "paul@keymail.dev", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/other/promote",
+ map[string]string{"version": "v1", "channel": "edge"}, tok)
+ if status != http.StatusNotFound {
+ t.Fatalf("deploy on a sibling app = %d %v, want 404", status, out)
+ }
+ // And a door outside §6 is the role gate's 403, not a 404.
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/delete", map[string]string{}, tok)
+ if status != http.StatusForbidden {
+ t.Fatalf("deploy on trash = %d %v, want 403", status, out)
+ }
+}