carlos / platform Public

settings-2: the authorize door is the system operators' (R19)

handleAPIAuthorize opens with apiSystemRoller (ops-account owner or
SystemOperators; everyone else that gate's bare 404), then apiAccountApp
as before (R20: the operator must also be a member of the app's account).
No new strings. The direct-bucket root-key path is unchanged.

Tests: a plain member gets 404 on GET and POST and writes nothing; an
address on SystemOperators that is a member but no ops owner signs, and
gets 404 with SystemOperators empty; the signing and write-budget tests
now use the ops owner's bearer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a18b0a290fc59d8707ddc91bf0c4b884fc07ddc8 parent 6298464
2 files changed, +107 −10
  • internal/console/apiauthorize.go +18 −3
  • internal/console/apiauthorize_test.go +89 −7

CI passed — run details

diff --git a/internal/console/apiauthorize.go b/internal/console/apiauthorize.go
index 25cbd1a9c..4f8f9d17d 100644
--- a/internal/console/apiauthorize.go
+++ b/internal/console/apiauthorize.go
@@ -15,10 +15,25 @@ import (
// 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. The POST draws on the write budget, like
-// resign, before any store work; the GET is a read and does not.
+// with its root key.
+//
+// Operator-only (R19). An authorize record makes a shipped build installable
+// on every channel, outside the pipeline's promotions, so it takes the
+// platform operator's authority: the same people who roll the platform, an
+// owner of the ops account or an address on SystemOperators. The door opens
+// with apiSystemRoller, whose refusal is its own bare 404, before any account
+// lookup, so a member who is not an operator learns nothing (R20). The
+// operator must then also be a member of the app's account (apiAccountApp,
+// whose 404 stands); an operator outside that account authorizes with the
+// root key against the bucket (direct-bucket `carlos releases authorize`).
+//
+// Person-only: a credential the app holds must not vouch for builds. The
+// POST draws on the write budget, like resign, before any store work; the GET
+// is a read and does not.
func (s *Server) handleAPIAuthorize(w http.ResponseWriter, r *http.Request) {
+ if _, _, ok := s.apiSystemRoller(w, r); !ok {
+ return
+ }
addr, acct, app, ok := s.apiAccountApp(w, r)
if !ok {
return
diff --git a/internal/console/apiauthorize_test.go b/internal/console/apiauthorize_test.go
index 73fdf3f39..3dd33d591 100644
--- a/internal/console/apiauthorize_test.go
+++ b/internal/console/apiauthorize_test.go
@@ -5,18 +5,21 @@ import (
"encoding/json"
"errors"
"net/http"
+ "strings"
"testing"
"time"
"github.com/carlosframework/platform/internal/release"
)
+// TestAuthorizeDoorShowsThenSigns: an owner of the ops account (paul) reads
+// the plan and signs exactly the digest he confirmed (R19).
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)
+ st, out := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", fx.operator)
if st != http.StatusOK {
t.Fatalf("plan: %d %s", st, out)
}
@@ -26,11 +29,11 @@ func TestAuthorizeDoorShowsThenSigns(t *testing.T) {
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)
+ st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", map[string]string{"manifest_digest": "00"}, fx.operator)
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)
+ st, out = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/km/releases/v1/authorize", map[string]string{"manifest_digest": plan.ManifestDigest}, fx.operator)
if st != http.StatusOK {
t.Fatalf("sign: %d %s", st, out)
}
@@ -57,7 +60,7 @@ func TestAuthorizeDoorSpendsTheWriteBudget(t *testing.T) {
}
fx.srv.cli.write = newRateLimiter(1, time.Minute)
u := fx.ts.URL + "/api/cli/apps/bab/km/releases/v1/authorize"
- st, out := getRaw(t, u, fx.member)
+ st, out := getRaw(t, u, fx.operator)
if st != http.StatusOK {
t.Fatalf("plan: %d %s", st, out)
}
@@ -67,16 +70,95 @@ func TestAuthorizeDoorSpendsTheWriteBudget(t *testing.T) {
if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.ManifestDigest == "" {
t.Fatalf("plan body %s: %v", out, err)
}
- if st, out := postRaw(t, u, map[string]string{"manifest_digest": "00"}, fx.member); st != http.StatusConflict {
+ if st, out := postRaw(t, u, map[string]string{"manifest_digest": "00"}, fx.operator); st != http.StatusConflict {
t.Fatalf("first request: %d %s", st, out)
}
- if st, out := getRaw(t, u, fx.member); st != http.StatusOK {
+ if st, out := getRaw(t, u, fx.operator); st != http.StatusOK {
t.Fatalf("a read after the budget is spent: %d %s", st, out)
}
- if st, out := postRaw(t, u, map[string]string{"manifest_digest": plan.ManifestDigest}, fx.member); st != http.StatusTooManyRequests || jsonErrorText(t, out) != textTooManyRequests {
+ if st, out := postRaw(t, u, map[string]string{"manifest_digest": plan.ManifestDigest}, fx.operator); st != http.StatusTooManyRequests || jsonErrorText(t, out) != textTooManyRequests {
t.Fatalf("second request over the budget: %d %s", st, out)
}
if _, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1"); !errors.Is(err, release.ErrNoPromotionRecord) {
t.Fatalf("a refused request wrote a record: %v", err)
}
}
+
+// authorizeDigest is the digest `carlos releases authorize` would show for
+// km's v1, read straight from the store so a refused caller's POST can carry
+// the right confirmation.
+func authorizeDigest(t *testing.T, fx *digestsFixture) string {
+ t.Helper()
+ plan, err := release.PlanAuthorize(context.Background(), fx.store, "bab", "km", "v1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return plan.ManifestDigest
+}
+
+// requireAuthorizeRefused: the caller gets the system-operator gate's bare
+// 404 on the GET and on a POST carrying the right digest, and no record is
+// written.
+func requireAuthorizeRefused(t *testing.T, fx *digestsFixture, bearer, who string) {
+ t.Helper()
+ u := fx.ts.URL + "/api/cli/apps/bab/km/releases/v1/authorize"
+ if st, out := getRaw(t, u, bearer); st != http.StatusNotFound || strings.TrimSpace(out) != "404 page not found" {
+ t.Fatalf("%s read the authorize plan: %d %s", who, st, out)
+ }
+ if st, out := postRaw(t, u, map[string]string{"manifest_digest": authorizeDigest(t, fx)}, bearer); st != http.StatusNotFound || strings.TrimSpace(out) != "404 page not found" {
+ t.Fatalf("%s signed an authorization: %d %s", who, st, out)
+ }
+ if _, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1"); !errors.Is(err, release.ErrNoPromotionRecord) {
+ t.Fatalf("a refused authorize wrote a record: %v", err)
+ }
+}
+
+// requireAuthorizeSigns: the caller reads the plan and signs it, and the
+// record is an authorization of that digest.
+func requireAuthorizeSigns(t *testing.T, fx *digestsFixture, bearer, who string) {
+ t.Helper()
+ u := fx.ts.URL + "/api/cli/apps/bab/km/releases/v1/authorize"
+ st, out := getRaw(t, u, bearer)
+ if st != http.StatusOK {
+ t.Fatalf("%s: plan: %d %s", who, st, out)
+ }
+ var plan struct {
+ ManifestDigest string `json:"manifest_digest"`
+ }
+ if err := json.Unmarshal([]byte(out), &plan); err != nil || plan.ManifestDigest == "" {
+ t.Fatalf("%s: plan body %s: %v", who, out, err)
+ }
+ if st, out := postRaw(t, u, map[string]string{"manifest_digest": plan.ManifestDigest}, bearer); st != http.StatusOK {
+ t.Fatalf("%s: sign: %d %s", who, st, out)
+ }
+ rec, err := release.ReadPromotionRecord(context.Background(), fx.store, fx.pub, "bab", "km", "v1")
+ if err != nil || !rec.Authorized || rec.ManifestDigest != plan.ManifestDigest {
+ t.Fatalf("%s: record = %+v, %v", who, rec, err)
+ }
+}
+
+// TestAuthorizeDoorIsNotAMembersDoor (R19): alice is a member of bab, the
+// app's account, but owns nothing in the ops account and is not on
+// SystemOperators. An authorize record makes a shipped build installable on
+// every channel, so she gets the system gate's bare 404 and writes nothing.
+func TestAuthorizeDoorIsNotAMembersDoor(t *testing.T) {
+ fx := newDigestsFixture(t)
+ if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil {
+ t.Fatal(err)
+ }
+ requireAuthorizeRefused(t, fx, fx.member, "a plain member")
+}
+
+// TestAuthorizeDoorOpensToSystemOperators (R19): an address on
+// SystemOperators that is a member of bab but no ops owner can read and
+// sign; the same address with SystemOperators empty is a plain member again.
+func TestAuthorizeDoorOpensToSystemOperators(t *testing.T) {
+ fx := newDigestsFixture(t)
+ if err := fx.store.Delete(context.Background(), release.PromotionRecordKey("bab", "km", "v1")); err != nil {
+ t.Fatal(err)
+ }
+ fx.srv.cfg.SystemOperators = nil
+ requireAuthorizeRefused(t, fx, fx.member, "alice off SystemOperators")
+ fx.srv.cfg.SystemOperators = []string{"alice@keymail.dev"}
+ requireAuthorizeSigns(t, fx, fx.member, "alice on SystemOperators")
+}