| 1 | // The deploy credential's scope: which rungs it may move, and the one |
| 2 | // function that decides whether a promote or rollback by it is in scope. |
| 3 | // |
| 4 | // Spec: docs/superpowers/specs/2026-10-05-deploy-credential-design.md. |
| 5 | package console |
| 6 | |
| 7 | import ( |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "net/http" |
| 11 | "strings" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | "github.com/carlosframework/platform/internal/svcrole" |
| 16 | ) |
| 17 | |
| 18 | // deployRungs is the order rungs are stored and shown in. stable is not in |
| 19 | // it: no delegated credential may move stable (spec §5.2). |
| 20 | var deployRungs = []string{release.RungCanary, release.RungEdge, release.RungBeta} |
| 21 | |
| 22 | // ErrStableNotDelegable is a mint that names stable. |
| 23 | var ErrStableNotDelegable = errors.New("console: stable cannot be delegated") |
| 24 | |
| 25 | // UnknownRungError is a mint that names something that is not a rung. It |
| 26 | // carries the name so both mint doors can say which one (D10, D15). |
| 27 | type UnknownRungError struct{ Rung string } |
| 28 | |
| 29 | func (e *UnknownRungError) Error() string { return "console: unknown rung " + e.Rung } |
| 30 | |
| 31 | // canonicalRungs turns what a person typed into the stored form: spaces and |
| 32 | // empty entries dropped, duplicates collapsed, ladder order, joined by ",". |
| 33 | // It returns "" when nothing was named. It never lower-cases: "Edge" is not |
| 34 | // a rung, and accepting it would make the stored form differ from the input |
| 35 | // in a way the minter did not ask for. |
| 36 | func canonicalRungs(in []string) (string, error) { |
| 37 | seen := map[string]bool{} |
| 38 | for _, raw := range in { |
| 39 | r := strings.TrimSpace(raw) |
| 40 | if r == "" { |
| 41 | continue |
| 42 | } |
| 43 | if r == release.RungStable { |
| 44 | return "", ErrStableNotDelegable |
| 45 | } |
| 46 | known := false |
| 47 | for _, d := range deployRungs { |
| 48 | if r == d { |
| 49 | known = true |
| 50 | } |
| 51 | } |
| 52 | if !known { |
| 53 | return "", &UnknownRungError{Rung: r} |
| 54 | } |
| 55 | seen[r] = true |
| 56 | } |
| 57 | var out []string |
| 58 | for _, d := range deployRungs { |
| 59 | if seen[d] { |
| 60 | out = append(out, d) |
| 61 | } |
| 62 | } |
| 63 | return strings.Join(out, ","), nil |
| 64 | } |
| 65 | |
| 66 | // parseStoredRungs reads the column back. Only the exact canonical form of a |
| 67 | // non-empty set is accepted: anything else is a row this console did not |
| 68 | // write, and a deploy credential with an unreadable scope must not resolve |
| 69 | // (spec §5.5). |
| 70 | func parseStoredRungs(s string) ([]string, bool) { |
| 71 | if s == "" { |
| 72 | return nil, false |
| 73 | } |
| 74 | parts := strings.Split(s, ",") |
| 75 | canon, err := canonicalRungs(parts) |
| 76 | if err != nil || canon != s { |
| 77 | return nil, false |
| 78 | } |
| 79 | return parts, true |
| 80 | } |
| 81 | |
| 82 | // deployRefusal is a promote or rollback a deploy credential may not make. |
| 83 | // Its text is the 403's error, so a CI log says what to fix (spec §7.3). |
| 84 | type deployRefusal struct{ msg string } |
| 85 | |
| 86 | func (e *deployRefusal) Error() string { return e.msg } |
| 87 | |
| 88 | // deployScopeAllows is the whole scope decision for a deploy credential |
| 89 | // (spec §7.1). It is pure so it can be table-tested, and it is called three |
| 90 | // times per write: early in the handler for the message, and by the release |
| 91 | // primitives on their own pipeline reads (Task 7). For every principal that |
| 92 | // is not a deploy credential it returns nil and changes nothing. |
| 93 | func deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error { |
| 94 | if p.Kind != PrincipalService || p.Role != svcrole.Deploy { |
| 95 | return nil |
| 96 | } |
| 97 | held := deployHeldWords(p.Rungs) |
| 98 | if hotfix { |
| 99 | return &deployRefusal{fmt.Sprintf("deploy credential %s cannot hotfix. Promote in ladder order, or ask a member to hotfix.", p.Name)} |
| 100 | } |
| 101 | rung, ok := release.Rung(channel) |
| 102 | if !ok || rung == release.RungStable { |
| 103 | return &deployRefusal{fmt.Sprintf("deploy credential %s may move %s only, and %s is not one of them.", p.Name, held, channel)} |
| 104 | } |
| 105 | if rung == release.RungCanary && !strings.HasPrefix(channel, release.RungCanary+"/") { |
| 106 | return &deployRefusal{fmt.Sprintf("deploy credential %s may move canary/<name> channels, not canary itself.", p.Name)} |
| 107 | } |
| 108 | inScope := false |
| 109 | for _, r := range p.Rungs { |
| 110 | if r == rung { |
| 111 | inScope = true |
| 112 | } |
| 113 | } |
| 114 | if !inScope { |
| 115 | return &deployRefusal{fmt.Sprintf("deploy credential %s may move %s only, and %s is not one of them.", p.Name, held, channel)} |
| 116 | } |
| 117 | if pipelined && rung != release.RungCanary { |
| 118 | return &deployRefusal{fmt.Sprintf("%s has a pipeline, so deploy credential %s may move canary/<name> channels only.", p.App, p.Name)} |
| 119 | } |
| 120 | return nil |
| 121 | } |
| 122 | |
| 123 | // deployHeldWords is the rung list as the refusal names it: canary shown as |
| 124 | // the channels it covers. |
| 125 | func deployHeldWords(rungs []string) string { |
| 126 | words := make([]string, len(rungs)) |
| 127 | for i, r := range rungs { |
| 128 | words[i] = r |
| 129 | if r == release.RungCanary { |
| 130 | words[i] = release.RungCanary + "/<name>" |
| 131 | } |
| 132 | } |
| 133 | return strings.Join(words, ", ") |
| 134 | } |
| 135 | |
| 136 | // writeDeployRefusal answers a scope refusal. Anything that is not a |
| 137 | // deployRefusal is a programming error and answers 500. |
| 138 | func (s *Server) writeDeployRefusal(w http.ResponseWriter, err error) { |
| 139 | var dr *deployRefusal |
| 140 | if errors.As(err, &dr) { |
| 141 | jsonError(w, http.StatusForbidden, dr.msg) |
| 142 | return |
| 143 | } |
| 144 | jsonError(w, http.StatusInternalServerError, "internal error") |
| 145 | } |
| 146 | |
| 147 | // deployGrantTTL is how long a request-local grant lives. It only has to |
| 148 | // outlast one request: a box checks a grant against the pointer's own |
| 149 | // promoted_at, so a pointer signed inside the window stays valid after the |
| 150 | // grant expires (spec §7.4). |
| 151 | const deployGrantTTL = 5 * time.Minute |
| 152 | |
| 153 | // deploySigner mints a scoped key and a root-signed grant for exactly the |
| 154 | // credential's rungs, for one request. Boxes then verify the rung limit as |
| 155 | // they do for a scoped CI key today. Nothing is stored. Any failure refuses |
| 156 | // the request: a deploy write never falls back to the root signer. |
| 157 | func (s *Server) deploySigner(p Principal) (release.Signer, error) { |
| 158 | if len(p.Rungs) == 0 { |
| 159 | return release.Signer{}, errors.New("deploy credential carries no rungs") |
| 160 | } |
| 161 | seed, grant, err := release.MintKeyGrant(s.cfg.Key, "deploy."+p.Name, p.Rungs, deployGrantTTL) |
| 162 | if err != nil { |
| 163 | return release.Signer{}, err |
| 164 | } |
| 165 | key, err := release.ParseSigningKey(seed, "deploy grant") |
| 166 | if err != nil { |
| 167 | return release.Signer{}, err |
| 168 | } |
| 169 | return release.ScopedSigner(key, grant, s.cfg.ReleasePub) |
| 170 | } |
| 171 | |