carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
5package console
6
7import (
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).
20var deployRungs = []string{release.RungCanary, release.RungEdge, release.RungBeta}
21
22// ErrStableNotDelegable is a mint that names stable.
23var 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).
27type UnknownRungError struct{ Rung string }
28
29func (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.
36func 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).
70func 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).
84type deployRefusal struct{ msg string }
85
86func (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.
93func 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.
125func 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.
138func (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).
151const 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.
157func (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