carlos / platform Public

console: sign deploy writes with a request-local scoped grant (#229)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 69952eb48c800d86624e8000610d59923f068f50 parent 1f6c0c4
3 files changed, +294 −3
  • internal/console/apicli.go +42 −3
  • internal/console/deployscope.go +26 −0
  • internal/console/deploysign_test.go +226 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index bc17ba42f..17021d17d 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -27,6 +27,7 @@ import (
"github.com/carlosframework/platform/internal/caps"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
"github.com/carlosframework/platform/internal/version"
)
@@ -881,9 +882,28 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
}
hotfix = false
}
- res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version,
- release.PromoteOptions{Actor: addr, Hotfix: hotfix})
+ signer := release.RootSigner(s.cfg.Key)
+ var authorize func(*release.Pipeline) error
+ if p.Kind == PrincipalService && p.Role == svcrole.Deploy {
+ sg, serr := s.deploySigner(p)
+ if serr != nil {
+ log.Printf("console: deploy signer for %s/%s: %v", acct.Sqid, app, serr)
+ jsonError(w, http.StatusServiceUnavailable, "could not prepare a signing key for this deploy credential. Nothing was promoted.")
+ return
+ }
+ signer = sg
+ authorize = func(pp *release.Pipeline) error {
+ return deployScopeAllows(p, pp != nil, channel, req.Hotfix)
+ }
+ }
+ res, err := signer.PromoteOnLadder(ctx, s.cfg.Releases, acct.Sqid, app, channel, version,
+ release.PromoteOptions{Actor: addr, Hotfix: hotfix, Authorize: authorize})
if err != nil {
+ var ae *release.AuthorizationError
+ if errors.As(err, &ae) {
+ s.writeDeployRefusal(w, ae.Err)
+ return
+ }
log.Printf("console: api promote %s %s -> %s: %v", app, version, channel, err)
// The ladder's own refusal text names the rungs — as useful over the
// API as it is on the app page.
@@ -1080,7 +1100,26 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
})
return
}
- if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil {
+ signer := release.RootSigner(s.cfg.Key)
+ var authorize func(*release.Pipeline) error
+ if p.Kind == PrincipalService && p.Role == svcrole.Deploy {
+ sg, serr := s.deploySigner(p)
+ if serr != nil {
+ log.Printf("console: deploy signer for %s/%s: %v", acct.Sqid, app, serr)
+ jsonError(w, http.StatusServiceUnavailable, "could not prepare a signing key for this deploy credential. Nothing was promoted.")
+ return
+ }
+ signer = sg
+ authorize = func(pp *release.Pipeline) error {
+ return deployScopeAllows(p, pp != nil, channel, false)
+ }
+ }
+ if err := signer.RollbackAuthorized(ctx, s.cfg.Releases, acct.Sqid, app, channel, authorize); err != nil {
+ var ae *release.AuthorizationError
+ if errors.As(err, &ae) {
+ s.writeDeployRefusal(w, ae.Err)
+ return
+ }
log.Printf("console: api rollback %s %s: %v", app, channel, err)
jsonError(w, http.StatusConflict, err.Error())
return
diff --git a/internal/console/deployscope.go b/internal/console/deployscope.go
index 3dda60856..ddde52175 100644
--- a/internal/console/deployscope.go
+++ b/internal/console/deployscope.go
@@ -9,6 +9,7 @@ import (
"fmt"
"net/http"
"strings"
+ "time"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/svcrole"
@@ -142,3 +143,28 @@ func (s *Server) writeDeployRefusal(w http.ResponseWriter, err error) {
}
jsonError(w, http.StatusInternalServerError, "internal error")
}
+
+// deployGrantTTL is how long a request-local grant lives. It only has to
+// outlast one request: a box checks a grant against the pointer's own
+// promoted_at, so a pointer signed inside the window stays valid after the
+// grant expires (spec §7.4).
+const deployGrantTTL = 5 * time.Minute
+
+// deploySigner mints a scoped key and a root-signed grant for exactly the
+// credential's rungs, for one request. Boxes then verify the rung limit as
+// they do for a scoped CI key today. Nothing is stored. Any failure refuses
+// the request: a deploy write never falls back to the root signer.
+func (s *Server) deploySigner(p Principal) (release.Signer, error) {
+ if len(p.Rungs) == 0 {
+ return release.Signer{}, errors.New("deploy credential carries no rungs")
+ }
+ seed, grant, err := release.MintKeyGrant(s.cfg.Key, "deploy."+p.Name, p.Rungs, deployGrantTTL)
+ if err != nil {
+ return release.Signer{}, err
+ }
+ key, err := release.ParseSigningKey(seed, "deploy grant")
+ if err != nil {
+ return release.Signer{}, err
+ }
+ return release.ScopedSigner(key, grant, s.cfg.ReleasePub)
+}
diff --git a/internal/console/deploysign_test.go b/internal/console/deploysign_test.go
new file mode 100644
index 000000000..5ffa6df1d
--- /dev/null
+++ b/internal/console/deploysign_test.go
@@ -0,0 +1,226 @@
+package console
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// deployFixture is a console with app hello in bab, v1 and v2 shipped by
+// paul, and a deploy credential ci over hello with rungs canary and edge.
+func deployFixture(t *testing.T) (fx *consoleFixture, person, deploy string) {
+ t.Helper()
+ 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-sign")
+ liftShipBudget(fx)
+ for _, v := range []string{"v1", "v2"} {
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/hello/releases", person,
+ []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("bin " + v)}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship %s = %d %v", 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)
+ }
+ return fx, person, tok
+}
+
+// pointerGrant returns the decoded payload of the grant carried by a raw
+// channel pointer, or nil when the pointer is root-signed (no grant field).
+func pointerGrant(t *testing.T, fx *consoleFixture, channel string) map[string]any {
+ t.Helper()
+ raw, _, err := release.GetQualified(context.Background(), fx.rel, release.ChannelKey("bab", "hello", channel))
+ if err != nil {
+ t.Fatalf("read %s pointer: %v", channel, err)
+ }
+ var wrap struct {
+ Grant *struct {
+ Payload string `json:"payload"`
+ Sig string `json:"sig"`
+ } `json:"grant"`
+ }
+ if err := json.Unmarshal(raw, &wrap); err != nil {
+ t.Fatalf("pointer is not JSON: %v", err)
+ }
+ if wrap.Grant == nil {
+ return nil
+ }
+ pl, err := base64.StdEncoding.DecodeString(wrap.Grant.Payload)
+ if err != nil {
+ pl, err = base64.RawURLEncoding.DecodeString(wrap.Grant.Payload)
+ if err != nil {
+ t.Fatalf("grant payload is not base64: %v", err)
+ }
+ }
+ var g map[string]any
+ if err := json.Unmarshal(pl, &g); err != nil {
+ t.Fatalf("grant payload is not JSON: %v", err)
+ }
+ return g
+}
+
+func grantRungs(t *testing.T, g map[string]any) []string {
+ t.Helper()
+ var out []string
+ rs, _ := g["rungs"].([]any)
+ for _, r := range rs {
+ s, _ := r.(string)
+ out = append(out, s)
+ }
+ return out
+}
+
+func serialNow(t *testing.T, fx *consoleFixture) int64 {
+ t.Helper()
+ doc, _, err := release.ReadSerial(context.Background(), fx.rel)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return doc.Serial
+}
+
+func TestDeployPromoteIsSignedByAScopedGrant(t *testing.T) {
+ fx, _, deploy := deployFixture(t)
+ before := serialNow(t, fx)
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote",
+ map[string]any{"version": "v1", "channel": "edge"}, deploy)
+ if status != http.StatusOK || str(t, out, "result") != "promoted" {
+ t.Fatalf("deploy promote = %d %v", status, out)
+ }
+ if after := serialNow(t, fx); after != before+1 {
+ t.Fatalf("serial %d -> %d, want +1", before, after)
+ }
+ evs, err := fx.st.Events(OpsAccountID, "hello", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, e := range evs {
+ if e.Action == "promote" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("no promote event in %v", evs)
+ }
+ cp, err := release.Channel(context.Background(), fx.rel, fx.pub, "bab", "hello", "edge")
+ if err != nil {
+ t.Fatalf("edge does not verify: %v", err)
+ }
+ if cp.Version != "v1" {
+ t.Fatalf("edge = %q, want v1", cp.Version)
+ }
+ g := pointerGrant(t, fx, "edge")
+ if g == nil {
+ t.Fatal("edge pointer carries no grant")
+ }
+ if got := strings.Join(grantRungs(t, g), ","); got != "canary,edge" {
+ t.Fatalf("grant rungs = %q, want canary,edge", got)
+ }
+ if g["name"] != "deploy.ci" {
+ t.Fatalf("grant name = %v, want deploy.ci", g["name"])
+ }
+}
+
+func TestDeployRepeatIsANoOp(t *testing.T) {
+ fx, _, deploy := deployFixture(t)
+ body := map[string]any{"version": "v1", "channel": "edge"}
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", body, deploy); status != http.StatusOK || str(t, out, "result") != "promoted" {
+ t.Fatalf("first promote = %d %v", status, out)
+ }
+ before := serialNow(t, fx)
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote", body, deploy)
+ if status != http.StatusOK || str(t, out, "result") != "noop" {
+ t.Fatalf("repeat promote = %d %v, want 200 noop", status, out)
+ }
+ if after := serialNow(t, fx); after != before {
+ t.Fatalf("serial %d -> %d on a no-op", before, after)
+ }
+}
+
+func TestDeploySignerRefusesWithoutTheScopeFunction(t *testing.T) {
+ fx, _, _ := deployFixture(t)
+ sg, err := fx.srv.deploySigner(Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"edge"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ for _, ch := range []string{"beta", "stable"} {
+ if _, err := sg.PromoteOnLadder(ctx, fx.rel, "bab", "hello", ch, "v1", release.PromoteOptions{Hotfix: true}); err == nil {
+ t.Errorf("signer promoted %s with no Authorize hook", ch)
+ }
+ }
+}
+
+func TestDeploySignerFailureFailsClosed(t *testing.T) {
+ fx, _, deploy := deployFixture(t)
+ pub, _, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fx.srv.cfg.ReleasePub = pub
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote",
+ map[string]any{"version": "v1", "channel": "edge"}, deploy)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("promote = %d %v, want 503", status, out)
+ }
+ if got := str(t, out, "error"); got != "could not prepare a signing key for this deploy credential. Nothing was promoted." {
+ t.Fatalf("error = %q", got)
+ }
+ if _, _, err := release.GetQualified(context.Background(), fx.rel, release.ChannelKey("bab", "hello", "edge")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("edge pointer after a failed signer: err = %v, want ErrNotExist", err)
+ }
+}
+
+func TestPersonPromoteUnchanged(t *testing.T) {
+ fx, person, _ := deployFixture(t)
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote",
+ map[string]any{"version": "v1", "channel": "edge"}, person)
+ if status != http.StatusOK {
+ t.Fatalf("person promote = %d %v", status, out)
+ }
+ if g := pointerGrant(t, fx, "edge"); g != nil {
+ t.Fatalf("person-signed pointer carries a grant: %v", g)
+ }
+}
+
+func TestDeployRollback(t *testing.T) {
+ fx, person, deploy := deployFixture(t)
+ for _, v := range []string{"v1", "v2"} {
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/promote",
+ map[string]any{"version": v, "channel": "edge"}, person); status != http.StatusOK {
+ t.Fatalf("person promote %s = %d %v", v, status, out)
+ }
+ }
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/rollback",
+ map[string]any{"channel": "edge"}, deploy)
+ if status != http.StatusOK || str(t, out, "result") != "rolled back" {
+ t.Fatalf("deploy rollback = %d %v", status, out)
+ }
+ cp, err := release.Channel(context.Background(), fx.rel, fx.pub, "bab", "hello", "edge")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cp.Version != "v1" {
+ t.Fatalf("edge = %q, want v1", cp.Version)
+ }
+ g := pointerGrant(t, fx, "edge")
+ if g == nil || g["name"] != "deploy.ci" {
+ t.Fatalf("rolled-back pointer grant = %v, want name deploy.ci", g)
+ }
+}