// The deploy credential's scope: which rungs it may move, and the one // function that decides whether a promote or rollback by it is in scope. // // Spec: docs/superpowers/specs/2026-10-05-deploy-credential-design.md. package console import ( "errors" "fmt" "net/http" "strings" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/svcrole" ) // deployRungs is the order rungs are stored and shown in. stable is not in // it: no delegated credential may move stable (spec §5.2). var deployRungs = []string{release.RungCanary, release.RungEdge, release.RungBeta} // ErrStableNotDelegable is a mint that names stable. var ErrStableNotDelegable = errors.New("console: stable cannot be delegated") // UnknownRungError is a mint that names something that is not a rung. It // carries the name so both mint doors can say which one (D10, D15). type UnknownRungError struct{ Rung string } func (e *UnknownRungError) Error() string { return "console: unknown rung " + e.Rung } // canonicalRungs turns what a person typed into the stored form: spaces and // empty entries dropped, duplicates collapsed, ladder order, joined by ",". // It returns "" when nothing was named. It never lower-cases: "Edge" is not // a rung, and accepting it would make the stored form differ from the input // in a way the minter did not ask for. func canonicalRungs(in []string) (string, error) { seen := map[string]bool{} for _, raw := range in { r := strings.TrimSpace(raw) if r == "" { continue } if r == release.RungStable { return "", ErrStableNotDelegable } known := false for _, d := range deployRungs { if r == d { known = true } } if !known { return "", &UnknownRungError{Rung: r} } seen[r] = true } var out []string for _, d := range deployRungs { if seen[d] { out = append(out, d) } } return strings.Join(out, ","), nil } // parseStoredRungs reads the column back. Only the exact canonical form of a // non-empty set is accepted: anything else is a row this console did not // write, and a deploy credential with an unreadable scope must not resolve // (spec §5.5). func parseStoredRungs(s string) ([]string, bool) { if s == "" { return nil, false } parts := strings.Split(s, ",") canon, err := canonicalRungs(parts) if err != nil || canon != s { return nil, false } return parts, true } // deployRefusal is a promote or rollback a deploy credential may not make. // Its text is the 403's error, so a CI log says what to fix (spec §7.3). type deployRefusal struct{ msg string } func (e *deployRefusal) Error() string { return e.msg } // deployScopeAllows is the whole scope decision for a deploy credential // (spec §7.1). It is pure so it can be table-tested, and it is called three // times per write: early in the handler for the message, and by the release // primitives on their own pipeline reads (Task 7). For every principal that // is not a deploy credential it returns nil and changes nothing. func deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error { if p.Kind != PrincipalService || p.Role != svcrole.Deploy { return nil } held := deployHeldWords(p.Rungs) if hotfix { return &deployRefusal{fmt.Sprintf("deploy credential %s cannot hotfix. Promote in ladder order, or ask a member to hotfix.", p.Name)} } rung, ok := release.Rung(channel) if !ok || rung == release.RungStable { return &deployRefusal{fmt.Sprintf("deploy credential %s may move %s only, and %s is not one of them.", p.Name, held, channel)} } if rung == release.RungCanary && !strings.HasPrefix(channel, release.RungCanary+"/") { return &deployRefusal{fmt.Sprintf("deploy credential %s may move canary/ channels, not canary itself.", p.Name)} } inScope := false for _, r := range p.Rungs { if r == rung { inScope = true } } if !inScope { return &deployRefusal{fmt.Sprintf("deploy credential %s may move %s only, and %s is not one of them.", p.Name, held, channel)} } if pipelined && rung != release.RungCanary { return &deployRefusal{fmt.Sprintf("%s has a pipeline, so deploy credential %s may move canary/ channels only.", p.App, p.Name)} } return nil } // deployHeldWords is the rung list as the refusal names it: canary shown as // the channels it covers. func deployHeldWords(rungs []string) string { words := make([]string, len(rungs)) for i, r := range rungs { words[i] = r if r == release.RungCanary { words[i] = release.RungCanary + "/" } } return strings.Join(words, ", ") } // writeDeployRefusal answers a scope refusal. Anything that is not a // deployRefusal is a programming error and answers 500. func (s *Server) writeDeployRefusal(w http.ResponseWriter, err error) { var dr *deployRefusal if errors.As(err, &dr) { jsonError(w, http.StatusForbidden, dr.msg) return } 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) }