carlos / platform Public

console: deploy role and rung-scoped service credentials (#229)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 994c431b724bf27f44ed822a9affda3bbcee70a6 parent 52aac91
6 files changed, +318 −18
  • internal/console/authstore.go +10 −3
  • internal/console/deploycred_test.go +142 −0
  • internal/console/deployscope.go +76 −0
  • internal/console/svcprincipal.go +62 −13
  • internal/svcrole/svcrole.go +8 −2
  • internal/svcrole/svcrole_test.go +20 −0
diff --git a/internal/console/authstore.go b/internal/console/authstore.go
index 8098415a1..fd6672677 100644
--- a/internal/console/authstore.go
+++ b/internal/console/authstore.go
@@ -122,8 +122,14 @@ type CLIToken struct {
// minted before this column existed, including every legacy kind='app'
// row NewAppToken mints — those never rotate through this path.
RotatedAt time.Time
+ // Rungs is a deploy credential's scope in its stored, canonical form
+ // ("canary,edge"); empty for every other kind and role.
+ Rungs string
}
+// RungList is Rungs for a person to read: "canary, edge".
+func (t CLIToken) RungList() string { return strings.ReplaceAll(t.Rungs, ",", ", ") }
+
// NeverExpires reports whether this credential has no expiry — true only
// for app and service tokens, whose expires_at is 0 by design (spec §8). The
// page asks this rather than testing ExpiresAt against the epoch itself, so
@@ -214,6 +220,7 @@ func (s *Store) migrateCLIAuth() error {
`ALTER TABLE cli_tokens ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE cli_tokens ADD COLUMN revoked_at INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE cli_tokens ADD COLUMN rotated_at INTEGER NOT NULL DEFAULT 0`,
+ `ALTER TABLE cli_tokens ADD COLUMN rungs TEXT NOT NULL DEFAULT ''`,
} {
if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -550,7 +557,7 @@ func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) {
// scanCLITokenRows drains a `cli_tokens` listing query into CLIToken values.
// CLITokens (by address) and ServicePrincipals (by account) select the same
-// fourteen columns in the same order and need the same legacy-row
+// fifteen columns in the same order and need the same legacy-row
// normalization — a kind='app' row's `role` column is empty in the database but
// IS the instance role (spec §5). This is the one place that normalization
// happens, so neither caller, nor any reader downstream of them, has to
@@ -562,7 +569,7 @@ func scanCLITokenRows(rows *sql.Rows) ([]CLIToken, error) {
var t CLIToken
var created, expires, used, revoked, rotated int64
if err := rows.Scan(&t.ID, &t.Address, &t.Label, &t.RemoteAddr, &t.Kind, &t.AccountID, &t.App,
- &t.Role, &t.Name, &created, &expires, &used, &revoked, &rotated); err != nil {
+ &t.Role, &t.Name, &created, &expires, &used, &revoked, &rotated, &t.Rungs); err != nil {
return nil, err
}
t.CreatedAt = time.Unix(created, 0).UTC()
@@ -593,7 +600,7 @@ func scanCLITokenRows(rows *sql.Rows) ([]CLIToken, error) {
// keep that promise.
func (s *Store) CLITokens(address string) ([]CLIToken, error) {
rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, kind, account_id, app,
-role, name, created_at, expires_at, last_used, revoked_at, rotated_at
+role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs
FROM cli_tokens WHERE address = ? AND revoked_at = 0 ORDER BY created_at DESC, token_hash`, address)
if err != nil {
return nil, err
diff --git a/internal/console/deploycred_test.go b/internal/console/deploycred_test.go
new file mode 100644
index 000000000..3a13ddda6
--- /dev/null
+++ b/internal/console/deploycred_test.go
@@ -0,0 +1,142 @@
+package console
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+func TestCanonicalRungs(t *testing.T) {
+ for _, tc := range []struct {
+ in []string
+ want string
+ }{
+ {nil, ""},
+ {[]string{" ", ""}, ""},
+ {[]string{"edge"}, "edge"},
+ {[]string{"edge", " canary ", "edge", ""}, "canary,edge"},
+ {[]string{"beta", "canary", "edge"}, "canary,edge,beta"},
+ } {
+ got, err := canonicalRungs(tc.in)
+ if err != nil || got != tc.want {
+ t.Errorf("canonicalRungs(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
+ }
+ }
+ if _, err := canonicalRungs([]string{"edge", "stable"}); !errors.Is(err, ErrStableNotDelegable) {
+ t.Errorf("stable: err = %v, want ErrStableNotDelegable", err)
+ }
+ for _, bad := range []string{"Edge", "canary/x", "prod", "edge;beta"} {
+ var ue *UnknownRungError
+ if _, err := canonicalRungs([]string{bad}); !errors.As(err, &ue) || ue.Rung != bad {
+ t.Errorf("canonicalRungs(%q) err = %v, want UnknownRungError naming it", bad, err)
+ }
+ }
+}
+
+func TestParseStoredRungs(t *testing.T) {
+ if got, ok := parseStoredRungs("canary,edge"); !ok || !reflect.DeepEqual(got, []string{"canary", "edge"}) {
+ t.Fatalf("parseStoredRungs(canary,edge) = %v, %v", got, ok)
+ }
+ // Not canonical, empty, or naming stable: never a usable scope.
+ for _, bad := range []string{"", "edge,canary", "edge,edge", "edge,stable", "edge,", " edge"} {
+ if got, ok := parseStoredRungs(bad); ok {
+ t.Errorf("parseStoredRungs(%q) = %v, true; want false", bad, got)
+ }
+ }
+}
+
+func TestMintDeployRules(t *testing.T) {
+ st := newDeployStore(t)
+ now := time.Now()
+ mint := func(app string, role svcrole.Role, rungs []string) error {
+ _, err := st.NewServicePrincipalScoped(1, "ci", app, role, rungs, "paul@example.test", now)
+ return err
+ }
+ if err := mint("", svcrole.Deploy, []string{"edge"}); !errors.Is(err, ErrAccountScopedDeploy) {
+ t.Errorf("deploy with no app: %v", err)
+ }
+ if err := mint("hello", svcrole.Deploy, nil); !errors.Is(err, ErrDeployNeedsRungs) {
+ t.Errorf("deploy with no rungs: %v", err)
+ }
+ if err := mint("hello", svcrole.Deploy, []string{"stable"}); !errors.Is(err, ErrStableNotDelegable) {
+ t.Errorf("deploy with stable: %v", err)
+ }
+ if err := mint("hello", svcrole.Publish, []string{"edge"}); !errors.Is(err, ErrRungsNeedDeploy) {
+ t.Errorf("publish with rungs: %v", err)
+ }
+ // The old entry point cannot mint a deploy credential: it has no rungs.
+ if _, err := st.NewServicePrincipal(1, "ci", "hello", svcrole.Deploy, "paul@example.test", now); !errors.Is(err, ErrDeployNeedsRungs) {
+ t.Errorf("NewServicePrincipal(deploy): %v", err)
+ }
+}
+
+func TestDeployPrincipalReadsBackItsRungs(t *testing.T) {
+ st := newDeployStore(t)
+ now := time.Now()
+ raw, err := st.NewServicePrincipalScoped(1, "ci", "hello", svcrole.Deploy, []string{"edge", "canary"}, "paul@example.test", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, ok := st.CLIPrincipal(raw, now)
+ if !ok || p.Role != svcrole.Deploy || p.App != "hello" || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) {
+ t.Fatalf("CLIPrincipal = %+v, %v", p, ok)
+ }
+ // Review Focus 3: rotation keeps the rungs, and the old secret dies.
+ fresh, err := st.RotateServicePrincipal(1, "ci", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := st.CLIPrincipal(raw, now); ok {
+ t.Fatal("the old secret still resolves after rotate")
+ }
+ p, ok = st.CLIPrincipal(fresh, now)
+ if !ok || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) {
+ t.Fatalf("after rotate: %+v, %v", p, ok)
+ }
+ rows, err := st.ServicePrincipals(1)
+ if err != nil || len(rows) != 1 || rows[0].Rungs != "canary,edge" || rows[0].RungList() != "canary, edge" {
+ t.Fatalf("ServicePrincipals = %+v, %v", rows, err)
+ }
+}
+
+func TestDeployRowWithBadRungsDoesNotResolve(t *testing.T) {
+ st := newDeployStore(t)
+ now := time.Now()
+ for i, rungs := range []string{"", "edge,canary", "stable", "edge,beta,"} {
+ raw, err := st.NewServicePrincipalScoped(1, "ci"+string(rune('a'+i)), "hello", svcrole.Deploy, []string{"edge"}, "paul@example.test", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = ? WHERE token_hash = ?`, rungs, hashToken(raw)); err != nil {
+ t.Fatal(err)
+ }
+ if p, ok := st.CLIPrincipal(raw, now); ok {
+ t.Errorf("rungs %q: resolved to %+v; want refused", rungs, 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)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = 'edge' WHERE token_hash = ?`, hashToken(raw)); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := st.CLIPrincipal(raw, now); ok {
+ t.Error("a publish row carrying rungs resolved")
+ }
+}
+
+// newDeployStore is a bare console store, enough for the store-level tests.
+func newDeployStore(t *testing.T) *Store {
+ t.Helper()
+ st, err := Open(t.TempDir() + "/console.db")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { st.Close() })
+ return st
+}
diff --git a/internal/console/deployscope.go b/internal/console/deployscope.go
new file mode 100644
index 000000000..da4994969
--- /dev/null
+++ b/internal/console/deployscope.go
@@ -0,0 +1,76 @@
+// 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"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// 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
+}
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go
index d928bd1e8..fb2cbbb0c 100644
--- a/internal/console/svcprincipal.go
+++ b/internal/console/svcprincipal.go
@@ -52,6 +52,10 @@ type Principal struct {
App string // "" means account-scoped
Role svcrole.Role
Name string // "" for a person and for legacy kind='app' rows
+ // Rungs is a deploy credential's scope, in ladder order. nil for every
+ // other role, and never empty for a deploy principal: CLIPrincipal
+ // refuses a deploy row it cannot read a scope from.
+ Rungs []string
}
// ErrServiceNameTaken is returned by NewServicePrincipal when the account
@@ -75,11 +79,11 @@ func (s *Store) CLIPrincipal(rawToken string, now time.Time) (Principal, bool) {
return Principal{}, false
}
hash := hashToken(rawToken)
- var storedHash, addr, kind, app, role, name string
+ var storedHash, addr, kind, app, role, name, rungs string
var accountID, expires, revoked int64
- err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at
+ err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs
FROM cli_tokens WHERE token_hash = ?`, hash).
- Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked)
+ Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs)
if err != nil {
return Principal{}, false
}
@@ -127,7 +131,20 @@ FROM cli_tokens WHERE token_hash = ?`, hash).
if !ok {
return Principal{}, false
}
- p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: r, Name: name}
+ var scope []string
+ if r == svcrole.Deploy {
+ // Fail closed: a deploy row with no readable scope, or with no
+ // app, is not treated as unscoped (spec §5.5).
+ scope, ok = parseStoredRungs(rungs)
+ if !ok || app == "" {
+ return Principal{}, false
+ }
+ } else if rungs != "" {
+ // Rungs on any other role would read as a limit and enforce
+ // nothing; a row this console did not write does not resolve.
+ return Principal{}, false
+ }
+ p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: r, Name: name, Rungs: scope}
default:
return Principal{}, false
}
@@ -137,6 +154,22 @@ FROM cli_tokens WHERE token_hash = ?`, hash).
return p, true
}
+// ErrAccountScopedService is a Service credential minted without an app. It is
+// its own sentinel because the mint doors match sentinels rather than strings,
+// and a scope refusal is the caller's to fix while a busy database is not.
+var ErrAccountScopedService = errors.New("console: a service-role credential must be bound to one app")
+
+// ErrAccountScopedDeploy is a deploy credential minted without an app.
+var ErrAccountScopedDeploy = errors.New("console: a deploy credential must be bound to one app")
+
+// ErrDeployNeedsRungs is a deploy credential minted with no rungs. There is
+// no default set: the minter says what the credential may move.
+var ErrDeployNeedsRungs = errors.New("console: a deploy credential needs at least one rung")
+
+// ErrRungsNeedDeploy is rungs named for any other role, where they would
+// read as a limit and enforce nothing.
+var ErrRungsNeedDeploy = errors.New("console: only a deploy credential takes rungs")
+
// NewServicePrincipal mints a credential a SERVER holds, scoped to one
// account and — optionally — one app within it, and carrying exactly one
// role from the closed svcrole catalogue. mintedBy is the member who minted
@@ -163,12 +196,14 @@ FROM cli_tokens WHERE token_hash = ?`, hash).
// rotated_at is stamped equal to created_at at mint: the current secret
// began exactly when the credential did. RotateServicePrincipal advances it
// on every later rotation without touching created_at.
-// ErrAccountScopedService is a Service credential minted without an app. It is
-// its own sentinel because the mint doors match sentinels rather than strings,
-// and a scope refusal is the caller's to fix while a busy database is not.
-var ErrAccountScopedService = errors.New("console: a service-role credential must be bound to one app")
-
func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (string, error) {
+ return s.NewServicePrincipalScoped(accountID, name, app, role, nil, mintedBy, now)
+}
+
+// NewServicePrincipalScoped is NewServicePrincipal with a rung scope, which
+// only a deploy credential takes. The rules live here rather than in a
+// handler for the reason the Service rule does: there are two mint doors.
+func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (string, error) {
if name == "" {
return "", errors.New("console: a service credential needs a name")
}
@@ -189,6 +224,20 @@ func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcr
if role == svcrole.Service && app == "" {
return "", ErrAccountScopedService
}
+ canon, err := canonicalRungs(rungs)
+ if err != nil {
+ return "", err
+ }
+ if role == svcrole.Deploy {
+ if app == "" {
+ return "", ErrAccountScopedDeploy
+ }
+ if canon == "" {
+ return "", ErrDeployNeedsRungs
+ }
+ } else if canon != "" {
+ return "", ErrRungsNeedDeploy
+ }
tx, err := s.db.Begin()
if err != nil {
return "", err
@@ -207,9 +256,9 @@ func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcr
return "", err
}
if _, err := tx.Exec(`INSERT INTO cli_tokens
-(token_hash, address, label, remote_addr, created_at, rotated_at, expires_at, kind, account_id, app, role, name)
-VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?)`,
- hashToken(raw), mintedBy, now.Unix(), now.Unix(), cliKindService, accountID, app, string(role), name); err != nil {
+(token_hash, address, label, remote_addr, created_at, rotated_at, expires_at, kind, account_id, app, role, name, rungs)
+VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?, ?)`,
+ hashToken(raw), mintedBy, now.Unix(), now.Unix(), cliKindService, accountID, app, string(role), name, canon); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
@@ -229,7 +278,7 @@ VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?)`,
// stays out of CLITokens.
func (s *Store) ServicePrincipals(accountID int64) ([]CLIToken, error) {
rows, err := s.db.Query(`SELECT token_hash, address, label, remote_addr, kind, account_id, app,
-role, name, created_at, expires_at, last_used, revoked_at, rotated_at
+role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs
FROM cli_tokens WHERE account_id = ? AND kind IN (?, ?) AND revoked_at = 0
ORDER BY created_at DESC, token_hash`, accountID, cliKindService, cliKindApp)
if err != nil {
diff --git a/internal/svcrole/svcrole.go b/internal/svcrole/svcrole.go
index d0dc2a607..9c0820a95 100644
--- a/internal/svcrole/svcrole.go
+++ b/internal/svcrole/svcrole.go
@@ -31,6 +31,12 @@ const (
// Publish is the deploy loop: claim an app, ship, promote, roll back,
// manage its hostnames, retire it.
Publish Role = "publish"
+ // Deploy is a deploy pipeline's credential: ship, and promote or roll
+ // back only the rungs the credential was minted with. It is always bound
+ // to one app, and it never reaches app lifecycle, retention or domains.
+ // The rungs live on the credential, not here: this package knows roles,
+ // not channels (spec 2026-10-05-deploy-credential-design.md §5).
+ Deploy Role = "deploy"
// Instance is the LEGACY app-token authority, defined by observation of
// today's call sites rather than by its name — it includes domains and
// logs. See the console's role table and spec §5.
@@ -49,14 +55,14 @@ const (
// TestInstanceRoleMatchesTodaysAppTokenReach pins Instance to exactly the
// doors it reaches today, and this role exists so that pin can hold.
Service Role = "service"
- // Admin is the union of the four above plus ledgers, store and place.
+ // Admin is the union of the five above plus ledgers, store and place.
// It is NOT "everything harmless": store grant mints a path-scoped IAM
// user, which is a real handover.
Admin Role = "admin"
)
// catalogue is the whole vocabulary, in the order All returns it.
-var catalogue = []Role{Publish, Instance, Operate, Service, Admin}
+var catalogue = []Role{Publish, Deploy, Instance, Operate, Service, Admin}
// All returns the catalogue. The slice is copied so a caller cannot edit the
// vocabulary by writing through it.
diff --git a/internal/svcrole/svcrole_test.go b/internal/svcrole/svcrole_test.go
index 09c5ab0a0..a5775141f 100644
--- a/internal/svcrole/svcrole_test.go
+++ b/internal/svcrole/svcrole_test.go
@@ -113,3 +113,23 @@ func TestEmptyDistinguishesTheTwoForms(t *testing.T) {
t.Error("Allow(Publish).Empty() = true; Empty must not be unconditionally true")
}
}
+
+func TestDeployIsMintableAndNarrow(t *testing.T) {
+ got, ok := Parse("deploy")
+ if !ok || got != Deploy {
+ t.Fatalf("Parse(\"deploy\") = %q, %v", got, ok)
+ }
+ all := All()
+ if len(all) < 2 || all[0] != Publish || all[1] != Deploy {
+ t.Fatalf("All() = %v; want deploy right after publish", all)
+ }
+ // Existing doors do not admit it: adding a role must not widen anything.
+ for _, door := range []Set{Allow(Publish), Allow(Instance), Allow(Operate), Allow(Service)} {
+ if door.Permits(Deploy) {
+ t.Fatalf("%v admits deploy", door.Roles())
+ }
+ }
+ if !Allow(Deploy).Permits(Admin) {
+ t.Fatal("Allow adds Admin to every set; that changed")
+ }
+}