carlos / platform Public

console: mint and list deploy credentials (#229)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5525f1952bdac695af81b3397607de7ff4404f73 parent 7532909
6 files changed, +252 −9
  • internal/console/apiservices.go +42 −3
  • internal/console/deploycred_test.go +13 −1
  • internal/console/deploymint_test.go +138 −0
  • internal/console/server.go +1 −0
  • internal/console/svcpage.go +53 −3
  • internal/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.