carlos / platform Public

settings-2 task 6: the console's authorize door (person-only, root-signed)

GET and POST /api/cli/apps/{account}/{app}/releases/{version}/authorize.
GET shows a build's manifest and artifact digests; POST signs a root-key
promotion record for exactly the digest the person confirmed. Both are
closed doors (svcrole.Set{}). The no-key and write-failed bodies come from
internal/updatewords (R8). The host-claim census gets a door fixture for
the POST and a read probe for the GET (R4); closedDoors and claim-routes
name both routes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9d7f410f3a1fc28c9cc059b36e6c1b057d3df201 parent 2109664
7 files changed, +215 −41
  • internal/console/apiauthorize.go +74 −0
  • internal/console/apiauthorize_test.go +46 −0
  • internal/console/authcli.go +5 −0
  • internal/console/claim_invariant_fixtures_test.go +77 −38
  • internal/console/claim_invariant_probes_read_test.go +3 −1
  • internal/console/svcroutes_test.go +8 −2
  • internal/console/testdata/claim-routes.txt +2 −0
diff --git a/internal/console/apiauthorize.go b/internal/console/apiauthorize.go
new file mode 100644
index 000000000..8a4c642be
--- /dev/null
+++ b/internal/console/apiauthorize.go
@@ -0,0 +1,74 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/updatewords"
+)
+
+// handleAPIAuthorize is `carlos releases authorize` (settings spec §4.1). GET
+// shows the digests; POST signs a promotion record for exactly the digest the
+// person confirmed, if the manifest still hashes to it. The console signs
+// with its root key for an app member. Person-only: a credential the app
+// holds must not vouch for builds.
+func (s *Server) handleAPIAuthorize(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ version := r.PathValue("version")
+ if !release.ValidVersion(version) {
+ jsonError(w, http.StatusBadRequest, "invalid version")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
+ defer cancel()
+ plan, err := release.PlanAuthorize(ctx, s.cfg.Releases, acct.Sqid, app, version)
+ if err != nil {
+ jsonError(w, http.StatusNotFound, err.Error())
+ return
+ }
+ if r.Method == http.MethodGet {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "version": plan.Version, "manifest_digest": plan.ManifestDigest, "artifacts": plan.Artifacts,
+ })
+ return
+ }
+ var req struct {
+ ManifestDigest string `json:"manifest_digest"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil || req.ManifestDigest != plan.ManifestDigest {
+ jsonError(w, http.StatusConflict, release.ErrAuthorizeChanged.Error())
+ return
+ }
+ if len(s.cfg.Key) == 0 {
+ jsonError(w, http.StatusServiceUnavailable, updatewords.Text("settings.authorize.no-key"))
+ return
+ }
+ already, err := release.RootSigner(s.cfg.Key).Authorize(ctx, s.cfg.Releases, plan, addr)
+ switch {
+ case errors.Is(err, release.ErrAuthorizeChanged):
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ case err != nil:
+ log.Printf("console: api authorize %s/%s %s: %v", acct.Sqid, app, version, err)
+ jsonError(w, http.StatusBadGateway, updatewords.Text("settings.authorize.write-failed"))
+ return
+ }
+ result := "authorized"
+ if already {
+ result = "already"
+ } else {
+ s.auditActivity(acct, app, addr, Activity{
+ Action: "authorize", TargetType: "release", Target: version,
+ Detail: "signed a promotion record for " + version + " (manifest " + plan.ManifestDigest[:12] + ") (cli)",
+ })
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"result": result})
+}
diff --git a/internal/console/apiauthorize_test.go b/internal/console/apiauthorize_test.go
new file mode 100644
index 000000000..fc4aaa1c4
--- /dev/null
+++ b/internal/console/apiauthorize_test.go
@@ -0,0 +1,46 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestAuthorizeDoorShowsThenSigns(t *testing.T) {
+ fx := newDigestsFixture(t)
+ if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil {
+ t.Fatal(err)
+ }
+ st, out := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", fx.member)
+ if st != http.StatusOK {
+ t.Fatalf("plan: %d %s", st, out)
+ }
+ var plan struct {
+ ManifestDigest string `json:"manifest_digest"`
+ }
+ if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.ManifestDigest == "" {
+ t.Fatalf("plan body %s: %v", out, err)
+ }
+ st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", map[string]string{"manifest_digest": "00"}, fx.member)
+ if st != http.StatusConflict {
+ t.Fatalf("a confirmation for other bytes: %d %s", st, out)
+ }
+ st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", map[string]string{"manifest_digest": plan.ManifestDigest}, fx.member)
+ if st != http.StatusOK {
+ t.Fatalf("sign: %d %s", st, out)
+ }
+ if rec, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1"); err != nil || !rec.Authorized {
+ t.Fatalf("record = %+v, %v", rec, err)
+ }
+}
+
+func TestAuthorizeDoorRefusesServiceCredentials(t *testing.T) {
+ fx := newDigestsFixture(t)
+ st, _ := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", fx.serviceToken)
+ if st != http.StatusNotFound && st != http.StatusForbidden {
+ t.Fatalf("a service credential reached authorize: %d", st)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index cd717aa4f..8dc10dc91 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -528,6 +528,11 @@ func (s *Server) registerCLIRoutes() {
// manifest digests the console then signs into the channel's pointer, so
// no credential reaches it.
s.cliRoute("POST /api/cli/apps/{account}/{app}/channels/resign", svcrole.Set{}, s.handleAPIChannelResign)
+ // `carlos releases authorize` (settings spec §4.1): GET shows a build's
+ // digests, POST signs a promotion record for the digest a person
+ // confirmed. A credential the app holds never vouches for a build.
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/releases/{version}/authorize", svcrole.Set{}, s.handleAPIAuthorize)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/releases/{version}/authorize", svcrole.Set{}, s.handleAPIAuthorize)
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
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go
index 1763d515a..5425428da 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -21,6 +21,7 @@ package console
import (
"context"
"encoding/json"
+ "errors"
"net/http"
"net/url"
"strconv"
@@ -238,6 +239,22 @@ func (cx *claimCase) resignPlan(t *testing.T, channel string) {
cx.vars["digest"], cx.vars["history_digests"] = plan.Digest, string(hd)
}
+// authorizePlan deletes the promotion record kass's v1 promotion wrote and
+// keeps the authorize plan's digest in vars, as `carlos releases authorize`
+// shows it before the person confirms.
+func (cx *claimCase) authorizePlan(t *testing.T) {
+ t.Helper()
+ ctx := context.Background()
+ if err := cx.rel.Delete(ctx, release.PromotionRecordKey("bab", "kass", "v1")); err != nil {
+ t.Fatalf("clear kass v1's promotion record: %v", err)
+ }
+ plan, err := release.PlanAuthorize(ctx, cx.rel, "bab", "kass", "v1")
+ if err != nil {
+ t.Fatalf("stage an authorize plan: %v", err)
+ }
+ cx.vars["manifest_digest"] = plan.ManifestDigest
+}
+
func (cx *claimCase) trash(t *testing.T, app string) {
t.Helper()
cx.mustAPI(t, http.MethodPost, "/api/cli/apps/bab/"+app+"/delete", "", http.StatusOK)
@@ -682,44 +699,46 @@ var claimFixtures = map[string]claimFixture{
"route GET /api/cli/apps/{account}/{app}/regions": claimRead("reads armed regions"),
"route GET /api/cli/apps/{account}/{app}/relay": claimRead("reads relay status and heartbeats"),
"route GET /api/cli/apps/{account}/{app}/releases": claimRead("lists releases"),
- "route GET /api/cli/apps/{account}/{app}/retention": claimRead("reads the retention policy"),
- "route GET /api/cli/apps/{account}/{app}/routes": claimRead("lists registry routes"),
- "route GET /api/cli/apps/{account}/{app}/schedules": claimRead("lists schedules and their box status"),
- "route GET /api/cli/apps/{account}/{app}/sidecars": claimRead("lists sidecar records"),
- "route GET /api/cli/apps/{account}/{app}/sidecars/{name}": claimRead("reads one sidecar record"),
- "route GET /api/cli/apps/{account}/{app}/store": claimRead("reads the store record (never a secret)"),
- "route GET /api/cli/apps/{account}/{app}/store/scan": claimRead("reads scanning status"),
- "route GET /api/cli/apps/{account}/{app}/tags": claimRead("lists release tags"),
- "route GET /api/cli/apps/{account}/{app}/tags/{version}": claimRead("reads one tag"),
- "route GET /api/cli/apps/{account}/{app}/version-target": claimRead("reads the version target"),
- "route GET /api/cli/fleets/{account}": claimRead("lists an account's fleets"),
- "route GET /api/cli/services/{account}": claimRead("lists service credentials"),
- "route GET /api/cli/status/instances": claimRead("operator live tenancy view"),
- "route GET /api/cli/status/layout": claimRead("operator layout view"),
- "route GET /api/cli/store/approvals": claimRead("operator entitlement queue"),
- "route GET /api/cli/system/antivirus": claimRead("reads the deployment scanning setting"),
- "route GET /api/cli/system/geoip": claimRead("operator view of the GeoIP pointer, box heartbeats and feed status (control/geoip/, status/, edge files); writes nothing"),
- "route GET /api/cli/system/jobs/{id}": claimRead("reads a system update job"),
- "route GET /api/cli/system/layout": claimRead("reads the deployment layout intent"),
- "route GET /api/cli/system/pointer-digests": claimRead("operator report of each account's pointer digest marker and the channels whose head has no digest; writes nothing"),
- "route GET /api/cli/system/status": claimRead("reads platform release status"),
- "route GET /apps": claimRead("legacy redirect into the account"),
- "route GET /apps/{app}": claimRead("legacy redirect into the account"),
- "route GET /auth/callback": claimNonApp("completes sign-in: session, user and possibly a person's first account"),
- "route GET /auth/cli": claimRead("renders the device-code approval page"),
- "route GET /auth/cli/tokens": claimRead("redirects to /settings"),
- "route GET /auth/cli/whoami": claimRead("names the bearer's principal"),
- "route GET /auth/confirm": claimNonApp("consumes a sign-in confirmation link: confirmations and sessions"),
- "route GET /auth/signin": claimNonApp("starts an OAuth flow: the auth cookie and its PKCE state"),
- "route GET /fleet": claimRead("operator fleet view"),
- "route GET /fleet/economics": claimRead("operator economics view"),
- "route GET /fleet/economics/accounts": claimRead("operator per-account economics table"),
- "route GET /fleet/metrics": claimRead("operator box metrics"),
- "route GET /fleet/store": claimRead("operator entitlement queue page"),
- "route GET /healthz": claimRead("answers ok and the build"),
- "route GET /invite/{token}": claimRead("shows an invitation; joining is the POST"),
- "route GET /settings": claimRead("personal settings: the caller's own tokens"),
- "route GET /{$}": claimRead("the front door: redirects or renders sign-in"),
+ "route GET /api/cli/apps/{account}/{app}/releases/{version}/authorize": claimRead(
+ "reads one build's manifest digests"),
+ "route GET /api/cli/apps/{account}/{app}/retention": claimRead("reads the retention policy"),
+ "route GET /api/cli/apps/{account}/{app}/routes": claimRead("lists registry routes"),
+ "route GET /api/cli/apps/{account}/{app}/schedules": claimRead("lists schedules and their box status"),
+ "route GET /api/cli/apps/{account}/{app}/sidecars": claimRead("lists sidecar records"),
+ "route GET /api/cli/apps/{account}/{app}/sidecars/{name}": claimRead("reads one sidecar record"),
+ "route GET /api/cli/apps/{account}/{app}/store": claimRead("reads the store record (never a secret)"),
+ "route GET /api/cli/apps/{account}/{app}/store/scan": claimRead("reads scanning status"),
+ "route GET /api/cli/apps/{account}/{app}/tags": claimRead("lists release tags"),
+ "route GET /api/cli/apps/{account}/{app}/tags/{version}": claimRead("reads one tag"),
+ "route GET /api/cli/apps/{account}/{app}/version-target": claimRead("reads the version target"),
+ "route GET /api/cli/fleets/{account}": claimRead("lists an account's fleets"),
+ "route GET /api/cli/services/{account}": claimRead("lists service credentials"),
+ "route GET /api/cli/status/instances": claimRead("operator live tenancy view"),
+ "route GET /api/cli/status/layout": claimRead("operator layout view"),
+ "route GET /api/cli/store/approvals": claimRead("operator entitlement queue"),
+ "route GET /api/cli/system/antivirus": claimRead("reads the deployment scanning setting"),
+ "route GET /api/cli/system/geoip": claimRead("operator view of the GeoIP pointer, box heartbeats and feed status (control/geoip/, status/, edge files); writes nothing"),
+ "route GET /api/cli/system/jobs/{id}": claimRead("reads a system update job"),
+ "route GET /api/cli/system/layout": claimRead("reads the deployment layout intent"),
+ "route GET /api/cli/system/pointer-digests": claimRead("operator report of each account's pointer digest marker and the channels whose head has no digest; writes nothing"),
+ "route GET /api/cli/system/status": claimRead("reads platform release status"),
+ "route GET /apps": claimRead("legacy redirect into the account"),
+ "route GET /apps/{app}": claimRead("legacy redirect into the account"),
+ "route GET /auth/callback": claimNonApp("completes sign-in: session, user and possibly a person's first account"),
+ "route GET /auth/cli": claimRead("renders the device-code approval page"),
+ "route GET /auth/cli/tokens": claimRead("redirects to /settings"),
+ "route GET /auth/cli/whoami": claimRead("names the bearer's principal"),
+ "route GET /auth/confirm": claimNonApp("consumes a sign-in confirmation link: confirmations and sessions"),
+ "route GET /auth/signin": claimNonApp("starts an OAuth flow: the auth cookie and its PKCE state"),
+ "route GET /fleet": claimRead("operator fleet view"),
+ "route GET /fleet/economics": claimRead("operator economics view"),
+ "route GET /fleet/economics/accounts": claimRead("operator per-account economics table"),
+ "route GET /fleet/metrics": claimRead("operator box metrics"),
+ "route GET /fleet/store": claimRead("operator entitlement queue page"),
+ "route GET /healthz": claimRead("answers ok and the build"),
+ "route GET /invite/{token}": claimRead("shows an invitation; joining is the POST"),
+ "route GET /settings": claimRead("personal settings: the caller's own tokens"),
+ "route GET /{$}": claimRead("the front door: redirects or renders sign-in"),
// GET that writes: a replica file download streams versioned objects (reads
// only) but audits every successful GET as an activity row of the app
@@ -1294,6 +1313,26 @@ var claimFixtures = map[string]claimFixture{
return ""
},
}},
+ "route POST /api/cli/apps/{account}/{app}/releases/{version}/authorize": {door: &claimDoor{
+ apps: claimRefs("bab/kass"),
+ setup: func(t *testing.T, cx *claimCase) { cx.authorizePlan(t) },
+ do: claimAPIVar(http.MethodPost, "/api/cli/apps/bab/kass/releases/v1/authorize",
+ `{"manifest_digest":"<manifest_digest>"}`),
+ check: func(t *testing.T, cx *claimCase, open bool) string {
+ rec, err := release.ReadPromotionRecord(context.Background(), cx.rel, cx.pub, "bab", "kass", "v1")
+ switch {
+ case !open && errors.Is(err, release.ErrNoPromotionRecord):
+ return ""
+ case !open && err == nil:
+ return "kass v1's promotion record was written while kass was foreign-owned"
+ case err != nil:
+ return err.Error()
+ case !rec.Authorized || rec.ManifestDigest != cx.vars["manifest_digest"]:
+ return "kass v1's promotion record is not an authorization of the confirmed digest"
+ }
+ return ""
+ },
+ }},
"route POST /api/cli/apps/{account}/{app}/rollback": {door: &claimDoor{
apps: claimRefs("bab/kass"),
setup: func(t *testing.T, cx *claimCase) {
diff --git a/internal/console/claim_invariant_probes_read_test.go b/internal/console/claim_invariant_probes_read_test.go
index 176b3bb2f..c94acb2ac 100644
--- a/internal/console/claim_invariant_probes_read_test.go
+++ b/internal/console/claim_invariant_probes_read_test.go
@@ -200,7 +200,9 @@ var claimReadProbes = map[string]claimProbe{
"route GET /api/cli/apps/{account}/{app}/regions": claimAPIRead(nil, "/api/cli/apps/bab/titogo/regions", `"regions"`),
"route GET /api/cli/apps/{account}/{app}/relay": claimAPIRead(func(t *testing.T, cx *claimCase) { cx.relayEnabled(t) },
"/api/cli/apps/bab/titogo/relay", `"app":"titogo"`, `"enabled":true`),
- "route GET /api/cli/apps/{account}/{app}/releases": claimAPIRead(nil, "/api/cli/apps/bab/kass/releases", `"version":"v1"`),
+ "route GET /api/cli/apps/{account}/{app}/releases": claimAPIRead(nil, "/api/cli/apps/bab/kass/releases", `"version":"v1"`),
+ "route GET /api/cli/apps/{account}/{app}/releases/{version}/authorize": claimAPIRead(nil,
+ "/api/cli/apps/bab/kass/releases/v1/authorize", `"version":"v1"`, `"manifest_digest":"`),
"route GET /api/cli/apps/{account}/{app}/retention": claimAPIRead(nil, "/api/cli/apps/bab/titogo/retention", `"keep"`),
"route GET /api/cli/apps/{account}/{app}/routes": claimAPIRead(nil, "/api/cli/apps/bab/kass/routes", `"kass.carlosapp.dev"`),
"route GET /api/cli/apps/{account}/{app}/schedules": claimAPIRead(claimWorldSchedule, "/api/cli/apps/bab/titogo/schedules", `"sync"`),
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 9e7e6ef88..bf736963b 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -224,6 +224,11 @@ var closedDoors = []string{
// `carlos channels resign`: the console signs digests a person has
// confirmed. A credential never confirms on a person's behalf.
"POST /api/cli/apps/{account}/{app}/channels/resign",
+ // `carlos releases authorize`: the console signs a promotion record for
+ // the digest a person confirmed. The GET is closed with it, as the
+ // pointer-digests read is: a credential has no plan to confirm.
+ "GET /api/cli/apps/{account}/{app}/releases/{version}/authorize",
+ "POST /api/cli/apps/{account}/{app}/releases/{version}/authorize",
}
// TestEveryCLIDoorNamesItsRoles is the default-deny guarantee for the
@@ -890,6 +895,7 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
"{id}", "deadbeefdeadbeef",
"{level}", "0",
"{span}", "1-1",
+ "{version}", "v1",
).Replace(path)
if strings.Contains(u, "{") {
t.Fatalf("closedDoors entry %q has a wildcard this test does not fill", pattern)
@@ -907,8 +913,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
}
seen++
}
- if seen != len(closedDoors) || seen != 45 {
- t.Errorf("walked %d closed doors, want all %d (and 45 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, and four store-entitlement doors)",
+ if seen != len(closedDoors) || seen != 47 {
+ t.Errorf("walked %d closed doors, want all %d (and 47 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, and four store-entitlement doors)",
seen, len(closedDoors))
}
}
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt
index 392cdeaf2..558ba6d63 100644
--- a/internal/console/testdata/claim-routes.txt
+++ b/internal/console/testdata/claim-routes.txt
@@ -65,6 +65,7 @@ route GET /api/cli/apps/{account}/{app}/pipeline
route GET /api/cli/apps/{account}/{app}/regions
route GET /api/cli/apps/{account}/{app}/relay
route GET /api/cli/apps/{account}/{app}/releases
+route GET /api/cli/apps/{account}/{app}/releases/{version}/authorize
route GET /api/cli/apps/{account}/{app}/retention
route GET /api/cli/apps/{account}/{app}/routes
route GET /api/cli/apps/{account}/{app}/schedules
@@ -185,6 +186,7 @@ route POST /api/cli/apps/{account}/{app}/relay
route POST /api/cli/apps/{account}/{app}/relay/adopted
route POST /api/cli/apps/{account}/{app}/relay/disable
route POST /api/cli/apps/{account}/{app}/releases
+route POST /api/cli/apps/{account}/{app}/releases/{version}/authorize
route POST /api/cli/apps/{account}/{app}/restart
route POST /api/cli/apps/{account}/{app}/restart/scope
route POST /api/cli/apps/{account}/{app}/restore