console: mint and list deploy credentials (#229)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
6 files changed,
+252
−9
internal/console/apiservices.go+42 −3internal/console/deploycred_test.go+13 −1internal/console/deploymint_test.go+138 −0internal/console/server.go+1 −0internal/console/svcpage.go+53 −3internal/console/templates/account_settings.html+5 −2
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go| index 25579e9da..befb5994b 100644 |
| --- a/internal/console/apiservices.go |
| +++ b/internal/console/apiservices.go |
| @@ -23,6 +23,7 @@ package console |
| import ( |
| "errors" |
| + "fmt" |
| "log" |
| "net/http" |
| "regexp" |
| @@ -62,6 +63,9 @@ type serviceView struct { |
| // rendered rather than omitted so "bound to nothing" and "bound to an |
| // app whose name did not survive the wire" cannot be confused. |
| App string `json:"app"` |
| + // Rungs is a deploy credential's ladder bound, canonical order. An |
| + // empty array, never null, for every other row. |
| + Rungs []string `json:"rungs"` |
| // Created is when the credential was GRANTED; Rotated is when its |
| // current secret began. The two answer different questions — how long |
| // has this existed, and how old is the secret in use right now — and a |
| @@ -170,6 +174,9 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) |
| Name string `json:"name"` |
| Role string `json:"role"` |
| App string `json:"app"` |
| + // Rungs is the deploy credential's ladder bound: the rungs it may |
| + // move. Required for role deploy, refused for every other role. |
| + Rungs []string `json:"rungs"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| @@ -216,7 +223,7 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) |
| } |
| } |
| actor := auditActor(p, acct) |
| - secret, err := s.store.NewServicePrincipal(acct.ID, name, app, role, actor, time.Now()) |
| + secret, err := s.store.NewServicePrincipalScoped(acct.ID, name, app, role, req.Rungs, actor, time.Now()) |
| if err != nil { |
| // The sentinel, not a string match: a name collision is the |
| // caller's to fix and a busy database is not, and the two must not |
| @@ -231,6 +238,24 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) |
| "and an account-wide one would reach every app in the account") |
| return |
| } |
| + var ue *UnknownRungError |
| + switch { |
| + case errors.Is(err, ErrAccountScopedDeploy): |
| + jsonError(w, http.StatusBadRequest, "a deploy credential must name one app (--app)") |
| + return |
| + case errors.Is(err, ErrDeployNeedsRungs): |
| + jsonError(w, http.StatusBadRequest, "a deploy credential must name its rungs (--rungs): canary, edge or beta") |
| + return |
| + case errors.Is(err, ErrStableNotDelegable): |
| + jsonError(w, http.StatusBadRequest, "stable cannot be delegated: moving it needs a passkey in the console") |
| + return |
| + case errors.As(err, &ue): |
| + jsonError(w, http.StatusBadRequest, fmt.Sprintf("unknown rung %q: one of canary, edge, beta", ue.Rung)) |
| + return |
| + case errors.Is(err, ErrRungsNeedDeploy): |
| + jsonError(w, http.StatusBadRequest, "only a deploy credential takes rungs") |
| + return |
| + } |
| log.Printf("console: service create %s/%s: %v", acct.Sqid, name, err) |
| jsonError(w, http.StatusInternalServerError, "could not mint the credential — try again") |
| return |
| @@ -239,12 +264,21 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) |
| if app != "" { |
| scope = "app " + app |
| } |
| - s.audit(acct, serviceAuditKey(name, app), actor, "service-create", |
| - "minted service credential "+name+" with role "+string(role)+" over "+scope+" (cli)") |
| + canon, _ := canonicalRungs(req.Rungs) // already validated by the mint |
| + rungs := []string{} |
| + if canon != "" { |
| + rungs = strings.Split(canon, ",") |
| + } |
| + detail := "minted service credential " + name + " with role " + string(role) + " over " + scope + " (cli)" |
| + if role == svcrole.Deploy { |
| + detail = fmt.Sprintf("minted service credential %s with role deploy over app %s, rungs %s (%s)", name, app, canon, "cli") |
| + } |
| + s.audit(acct, serviceAuditKey(name, app), actor, "service-create", detail) |
| writeJSON(w, http.StatusCreated, map[string]any{ |
| "name": name, |
| "role": string(role), |
| "app": app, |
| + "rungs": rungs, |
| "account": acct.Sqid, |
| "token": secret, |
| "doors": s.doorsFor(role), |
| @@ -270,12 +304,17 @@ func (s *Server) handleAPIServiceList(w http.ResponseWriter, r *http.Request) { |
| } |
| out := make([]serviceView, 0, len(rows)) |
| for _, t := range rows { |
| + rungs := []string{} |
| + if t.Rungs != "" { |
| + rungs = strings.Split(t.Rungs, ",") |
| + } |
| out = append(out, serviceView{ |
| ID: t.ID, |
| Name: t.Name, |
| Label: t.Label, |
| Role: t.Role, |
| App: t.App, |
| + Rungs: rungs, |
| Created: serviceStamp(t.CreatedAt), |
| Rotated: serviceStamp(t.RotatedAt), |
| LastUsed: serviceStamp(t.LastUsed), |
diff --git a/internal/console/deploycred_test.go b/internal/console/deploycred_test.go| index 3a13ddda6..459735a58 100644 |
| --- a/internal/console/deploycred_test.go |
| +++ b/internal/console/deploycred_test.go |
| @@ -117,8 +117,20 @@ func TestDeployRowWithBadRungsDoesNotResolve(t *testing.T) { |
| t.Errorf("rungs %q: resolved to %+v; want refused", rungs, p) |
| } |
| } |
| + // A deploy row whose app was emptied (rungs still valid) is refused too: |
| + // a deploy credential is never account-wide. |
| + raw, err := st.NewServicePrincipalScoped(1, "noapp", "hello", svcrole.Deploy, []string{"edge"}, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.db.Exec(`UPDATE cli_tokens SET app = '' WHERE token_hash = ?`, hashToken(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, ok := st.CLIPrincipal(raw, now); ok { |
| + t.Errorf("a deploy row with app '' resolved to %+v; want refused", p) |
| + } |
| // A non-deploy row that somehow carries rungs is refused too. |
| - raw, err := st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, "paul@example.test", now) |
| + raw, err = st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, "paul@example.test", now) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/deploymint_test.go b/internal/console/deploymint_test.go| new file mode 100644 |
| index 000000000..1a573554a |
| --- /dev/null |
| +++ b/internal/console/deploymint_test.go |
| @@ -0,0 +1,138 @@ |
| +package console |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestAPIMintDeploy(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-deploy-mint") |
| + u := fx.ts.URL + "/api/cli/services/bab" |
| + status, out := postJSON(t, u, map[string]any{"name": "ci", "role": "deploy", "app": "hello", "rungs": []string{"edge", "canary"}}, token) |
| + if status != http.StatusCreated { |
| + t.Fatalf("mint deploy = %d %v", status, out) |
| + } |
| + if got, _ := out["rungs"].([]any); len(got) != 2 || got[0] != "canary" || got[1] != "edge" { |
| + t.Fatalf("rungs in response = %v", out["rungs"]) |
| + } |
| + for _, tc := range []struct { |
| + body map[string]any |
| + want string // D-text fragment from the approved Copy table |
| + }{ |
| + {map[string]any{"name": "a1", "role": "deploy", "rungs": []string{"edge"}}, "must name one app"}, |
| + {map[string]any{"name": "a2", "role": "deploy", "app": "hello"}, "must name its rungs"}, |
| + {map[string]any{"name": "a3", "role": "deploy", "app": "hello", "rungs": []string{"stable"}}, "stable cannot be delegated"}, |
| + {map[string]any{"name": "a4", "role": "deploy", "app": "hello", "rungs": []string{"prod"}}, `"prod"`}, |
| + {map[string]any{"name": "a5", "role": "publish", "app": "hello", "rungs": []string{"edge"}}, "only a deploy credential"}, |
| + } { |
| + status, out := postJSON(t, u, tc.body, token) |
| + if status != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), tc.want) { |
| + t.Errorf("%v = %d %v; want 400 containing %q", tc.body, status, out, tc.want) |
| + } |
| + } |
| + status, out = getJSON(t, u, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("list = %d %v", status, out) |
| + } |
| + rows, _ := out["services"].([]any) |
| + if len(rows) != 1 { |
| + t.Fatalf("listing = %v", rows) |
| + } |
| + if r, _ := rows[0].(map[string]any)["rungs"].([]any); len(r) != 2 { |
| + t.Fatalf("listing rungs = %v", rows[0]) |
| + } |
| +} |
| + |
| +// A non-deploy row lists "rungs": [] (an empty array, never null), so a |
| +// client can range over it without a nil check. |
| +func TestAPIListNonDeployRungsAreAnEmptyArray(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-deploy-list") |
| + u := fx.ts.URL + "/api/cli/services/bab" |
| + if status, out := postJSON(t, u, map[string]any{"name": "pub", "role": "publish", "app": "hello"}, token); status != http.StatusCreated { |
| + t.Fatalf("mint publish = %d %v", status, out) |
| + } else if r, ok := out["rungs"].([]any); !ok || len(r) != 0 { |
| + t.Fatalf("mint response rungs = %#v, want []", out["rungs"]) |
| + } |
| + status, out := getJSON(t, u, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("list = %d %v", status, out) |
| + } |
| + rows, _ := out["services"].([]any) |
| + if len(rows) != 1 { |
| + t.Fatalf("listing = %v", rows) |
| + } |
| + if r, ok := rows[0].(map[string]any)["rungs"].([]any); !ok || len(r) != 0 { |
| + t.Fatalf("listing rungs = %#v, want []", rows[0].(map[string]any)["rungs"]) |
| + } |
| +} |
| + |
| +func TestFormMintDeploy(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + before, err := fx.st.ServicePrincipals(OpsAccountID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Each refusal is a flash on the settings page, before any passkey trip |
| + // and with no row created. |
| + for _, bad := range []struct { |
| + why string |
| + form url.Values |
| + want string |
| + }{ |
| + {"no app", url.Values{"csrf": {csrf}, "name": {"ci"}, "role": {"deploy"}, "rungs": {"edge"}}, "A deploy credential must name one app. Nothing was minted."}, |
| + {"no rungs", url.Values{"csrf": {csrf}, "name": {"ci"}, "role": {"deploy"}, "app": {"kass"}}, "A deploy credential must name at least one rung: canary, edge or beta. Nothing was minted."}, |
| + {"stable", url.Values{"csrf": {csrf}, "name": {"ci"}, "role": {"deploy"}, "app": {"kass"}, "rungs": {"edge", "stable"}}, "Stable cannot be delegated. Moving it needs a passkey in the console. Nothing was minted."}, |
| + {"an unknown rung", url.Values{"csrf": {csrf}, "name": {"ci"}, "role": {"deploy"}, "app": {"kass"}, "rungs": {"prod"}}, "is not a rung. Choose canary, edge or beta. Nothing was minted."}, |
| + {"rungs on publish", url.Values{"csrf": {csrf}, "name": {"ci"}, "role": {"publish"}, "app": {"kass"}, "rungs": {"edge"}}, "Only a deploy credential takes rungs. Nothing was minted."}, |
| + } { |
| + status, loc, body := postForm(t, c, fx.u("/services/new"), bad.form) |
| + if strings.HasPrefix(loc, fx.fk.srv.URL) { |
| + t.Errorf("%s cost a passkey trip (-> %q)", bad.why, loc) |
| + } |
| + if status != http.StatusSeeOther || loc != fx.path("/settings") { |
| + t.Errorf("%s = %d -> %q, want 303 back to the page:\n%s", bad.why, status, loc, body) |
| + continue |
| + } |
| + _, _, page := get(t, c, fx.u("/settings")) |
| + if !strings.Contains(page, bad.want) { |
| + t.Errorf("%s: the settings page does not say %q:\n%s", bad.why, bad.want, page) |
| + } |
| + rows, err := fx.st.ServicePrincipals(OpsAccountID) |
| + if err != nil || len(rows) != len(before) { |
| + t.Errorf("%s: ServicePrincipals = %d rows %v, want %d (nothing minted)", bad.why, len(rows), err, len(before)) |
| + } |
| + } |
| + |
| + status, _, body := postGated(t, fx, c, fx.u("/services/new"), |
| + url.Values{"csrf": {csrf}, "name": {"ci"}, "role": {"deploy"}, "app": {"kass"}, "rungs": {"edge", "canary"}}) |
| + if status != http.StatusOK { |
| + t.Fatalf("deploy mint = %d:\n%s", status, body) |
| + } |
| + if !strings.Contains(body, "rungs canary, edge") { |
| + t.Fatalf("the secret banner does not name the rungs:\n%s", body) |
| + } |
| + secret := paneSecret(t, body) |
| + p, ok := fx.st.CLIPrincipal(secret, time.Now()) |
| + if !ok || strings.Join(p.Rungs, ",") != "canary,edge" { |
| + t.Fatalf("minted credential resolves to %+v, %v; want rungs canary,edge", p, ok) |
| + } |
| + // The listing shows the rungs too. |
| + _, _, page := get(t, c, fx.u("/settings")) |
| + if !strings.Contains(page, "canary, edge") { |
| + t.Fatalf("the credential listing does not show the rungs:\n%s", page) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 01c315e6e..7d1093876 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1721,6 +1721,7 @@ type page struct { |
| Services []CLIToken |
| ServicesUnknown bool |
| ServiceRoles []string // the mint form's role choices, from svcrole.All |
| + ServiceRungs []string // the mint form's rung choices, deploy only |
| ServiceApps []string // the mint form's scope choices: this account's apps |
| // ServiceSecret is the once-only block, set ONLY on the response to the |
| // create or rotate POST that produced it — never redirected to, never |
diff --git a/internal/console/svcpage.go b/internal/console/svcpage.go| index 05e10bfa4..f325d51e9 100644 |
| --- a/internal/console/svcpage.go |
| +++ b/internal/console/svcpage.go |
| @@ -80,6 +80,7 @@ package console |
| import ( |
| "crypto/subtle" |
| "errors" |
| + "fmt" |
| "log" |
| "net/http" |
| "net/url" |
| @@ -104,6 +105,9 @@ type ServiceSecretView struct { |
| Name string |
| Role string |
| Scope string // "the whole account" or "app <name>" |
| + // Rungs is a deploy credential's rungs, "canary, edge"; empty for every |
| + // other role. |
| + Rungs string |
| Token string |
| // Doors is what the credential may actually reach, read from the SAME |
| // role table the gate consults (doorsFor) — the create response's |
| @@ -176,6 +180,7 @@ func (s *Server) servicePane(p *page, acct *Account) { |
| for _, role := range svcrole.All() { |
| p.ServiceRoles = append(p.ServiceRoles, string(role)) |
| } |
| + p.ServiceRungs = deployRungs |
| // The mint form's app choices. A select rather than a text box because |
| // the door refuses an app this account does not hold anyway, and a typo |
| // that costs a round trip through the passkey is a bad trade. A failed |
| @@ -293,6 +298,27 @@ func (s *Server) handleServiceCreatePage(w http.ResponseWriter, r *http.Request) |
| "and an account-wide one would reach every app in this account. Nothing was minted.") |
| return |
| } |
| + // The rung refusals, same reasoning: the store stays the authority and |
| + // these spare the owner a passkey trip for an impossible request. |
| + canon, rerr := canonicalRungs(r.Form["rungs"]) |
| + var ue *UnknownRungError |
| + switch { |
| + case errors.Is(rerr, ErrStableNotDelegable): |
| + back("err", "Stable cannot be delegated. Moving it needs a passkey in the console. Nothing was minted.") |
| + return |
| + case errors.As(rerr, &ue): |
| + back("err", fmt.Sprintf("“%s” is not a rung. Choose canary, edge or beta. Nothing was minted.", ue.Rung)) |
| + return |
| + case role == svcrole.Deploy && app == "": |
| + back("err", "A deploy credential must name one app. Nothing was minted.") |
| + return |
| + case role == svcrole.Deploy && canon == "": |
| + back("err", "A deploy credential must name at least one rung: canary, edge or beta. Nothing was minted.") |
| + return |
| + case role != svcrole.Deploy && canon != "": |
| + back("err", "Only a deploy credential takes rungs. Nothing was minted.") |
| + return |
| + } |
| if app != "" { |
| found, err := s.store.HasAppIn(acct.ID, app) |
| if err != nil { |
| @@ -308,7 +334,7 @@ func (s *Server) handleServiceCreatePage(w http.ResponseWriter, r *http.Request) |
| if !s.requireFreshAuth(w, r, addr, raw) { |
| return |
| } |
| - secret, err := s.store.NewServicePrincipal(acct.ID, name, app, role, addr, time.Now()) |
| + secret, err := s.store.NewServicePrincipalScoped(acct.ID, name, app, role, r.Form["rungs"], addr, time.Now()) |
| if err != nil { |
| // The sentinel, not a string match: a name collision is the owner's |
| // to fix and a busy database is not. |
| @@ -326,6 +352,26 @@ func (s *Server) handleServiceCreatePage(w http.ResponseWriter, r *http.Request) |
| "and an account-wide one would reach every app in this account. Nothing was minted.") |
| return |
| } |
| + // The store is the authority; these arms say the same words as the |
| + // pre-checks above if one of them ever drifts. |
| + var ue *UnknownRungError |
| + switch { |
| + case errors.Is(err, ErrAccountScopedDeploy): |
| + back("err", "A deploy credential must name one app. Nothing was minted.") |
| + return |
| + case errors.Is(err, ErrDeployNeedsRungs): |
| + back("err", "A deploy credential must name at least one rung: canary, edge or beta. Nothing was minted.") |
| + return |
| + case errors.Is(err, ErrStableNotDelegable): |
| + back("err", "Stable cannot be delegated. Moving it needs a passkey in the console. Nothing was minted.") |
| + return |
| + case errors.As(err, &ue): |
| + back("err", fmt.Sprintf("“%s” is not a rung. Choose canary, edge or beta. Nothing was minted.", ue.Rung)) |
| + return |
| + case errors.Is(err, ErrRungsNeedDeploy): |
| + back("err", "Only a deploy credential takes rungs. Nothing was minted.") |
| + return |
| + } |
| log.Printf("console: service create %s/%s: %v", acct.Sqid, name, err) |
| back("err", "Could not mint the credential — try again.") |
| return |
| @@ -336,10 +382,14 @@ func (s *Server) handleServiceCreatePage(w http.ResponseWriter, r *http.Request) |
| scope := serviceScopeWords(app) |
| // serviceAuditKey, so an app-scoped credential's grant lands in THAT |
| // app's activity feed — the one place its owner looks (C38). |
| - s.audit(acct, serviceAuditKey(name, app), addr, "service-create", |
| - "minted service credential "+name+" with role "+string(role)+" over "+scope+" (console)") |
| + detail := "minted service credential " + name + " with role " + string(role) + " over " + scope + " (console)" |
| + if role == svcrole.Deploy { |
| + detail = fmt.Sprintf("minted service credential %s with role deploy over app %s, rungs %s (%s)", name, app, canon, "console") |
| + } |
| + s.audit(acct, serviceAuditKey(name, app), addr, "service-create", detail) |
| s.renderServiceSecret(w, r, addr, raw, acct, &ServiceSecretView{ |
| Name: name, Role: string(role), Scope: scope, App: app, |
| + Rungs: strings.ReplaceAll(canon, ",", ", "), |
| Token: secret, Doors: s.doorsFor(role), ScopeKnown: true, |
| }) |
| } |
diff --git a/internal/console/templates/account_settings.html b/internal/console/templates/account_settings.html| index d29409850..921d193de 100644 |
| --- a/internal/console/templates/account_settings.html |
| +++ b/internal/console/templates/account_settings.html |
| @@ -60,7 +60,7 @@ |
| <p><strong>New secret for {{.Name}}:</strong></p> |
| {{end}} |
| {{else}} |
| - <p><strong>Minted {{.Name}} — role {{.Role}}, over {{.Scope}}:</strong></p> |
| + {{if .Rungs}}<p><strong>Minted {{.Name}}: role {{.Role}}, over {{.Scope}}, rungs {{.Rungs}}:</strong></p>{{else}}<p><strong>Minted {{.Name}} — role {{.Role}}, over {{.Scope}}:</strong></p>{{end}} |
| {{end}} |
| <p><code>CARLOS_SERVICE_TOKEN={{.Token}}</code></p> |
| <p class="small">Shown once. Only its hash is stored — a lost secret means rotate, not another |
| @@ -102,7 +102,7 @@ |
| <div class="tablewrap"> |
| <table> |
| <tr> |
| - <th>Name</th><th>Role</th><th>Scope</th><th>Label</th> |
| + <th>Name</th><th>Role</th><th>Scope</th><th>Rungs</th><th>Label</th> |
| <th>Secret since</th><th>Last used</th><th>Minted by</th><th></th> |
| </tr> |
| {{range .Services}} |
| @@ -110,6 +110,7 @@ |
| <td>{{if .Name}}{{.Name}}{{else}}<span class="small">(unnamed)</span>{{end}}</td> |
| <td>{{.Role}}</td> |
| <td>{{if .App}}app {{.App}}{{else}}account-wide{{end}}</td> |
| + <td>{{emptyOrDash .RungList}}</td> |
| <td>{{emptyOrDash .Label}}</td> |
| <td>{{if .RotatedAt.IsZero}}—{{else}}{{fmtTime .RotatedAt}}{{end}}</td> |
| <td>{{if .LastUsed.IsZero}}—{{else}}{{fmtTime .LastUsed}}{{end}}</td> |
| @@ -151,6 +152,8 @@ |
| <option value="">the whole account</option> |
| {{range .ServiceApps}}<option value="{{.}}">app {{.}}</option>{{end}} |
| </select> |
| + <span class="small">Rungs, for a deploy credential:</span> |
| + {{range .ServiceRungs}}<label class="small"><input type="checkbox" name="rungs" value="{{.}}"> {{.}}</label>{{end}} |
| <button type="submit">Mint credential</button> |
| </form> |
| <p class="small">The secret is shown once, on the next screen, and is never recoverable from here. |