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>
2 files changed,
+107
−10
internal/console/apiauthorize.go+18 −3internal/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") |
| +} |