console: a run credential carries its own id and the commands it may run (PRO-6214)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16 files changed,
+1072
−81
apimode.go+18 −13internal/console/apiservices.go+71 −9internal/console/authstore.go+86 −6internal/console/deploycred_test.go+5 −5internal/console/deploydoors_test.go+1 −1internal/console/deploymixed_test.go+1 −1internal/console/deployscope_test.go+1 −1internal/console/deploysign_test.go+2 −2internal/console/svcpage.go+41 −5internal/console/svcprincipal.go+126 −18internal/console/svcprincipal_run_test.go+416 −0internal/console/templates/account_settings.html+5 −2internal/runs/commands.go+101 −0internal/runs/commands_test.go+99 −0services.go+36 −10services_cli_test.go+63 −8
diff --git a/apimode.go b/apimode.go| index 42621bc70..621a71dd2 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -4850,6 +4850,9 @@ type apiServiceView struct { |
| // Rungs is a deploy credential's rungs, canonical order; empty for every |
| // other role (the console never sends null). |
| Rungs []string `json:"rungs"` |
| + // Commands is a run credential's granted command prefixes, each a list |
| + // of words; empty for every other role. |
| + Commands [][]string `json:"commands"` |
| } |
| // createService mints one account-owned service credential, optionally |
| @@ -4861,24 +4864,26 @@ type apiServiceView struct { |
| // (handleAPIServiceCreate's doorsFor) — read at print time, not |
| // hand-maintained here, so this client cannot drift from what the |
| // credential will actually be admitted to. |
| -func (s *apiSession) createService(ctx context.Context, a apiAcct, name, role, app string, rungs []string) (token string, doors []string, gotRungs []string, err error) { |
| +func (s *apiSession) createService(ctx context.Context, a apiAcct, name, role, app string, rungs []string, commands [][]string) (token string, doors []string, gotRungs []string, gotCommands [][]string, err error) { |
| in := struct { |
| - Name string `json:"name"` |
| - Role string `json:"role"` |
| - App string `json:"app"` |
| - Rungs []string `json:"rungs,omitempty"` |
| - }{name, role, app, rungs} |
| + Name string `json:"name"` |
| + Role string `json:"role"` |
| + App string `json:"app"` |
| + Rungs []string `json:"rungs,omitempty"` |
| + Commands [][]string `json:"commands,omitempty"` |
| + }{name, role, app, rungs, commands} |
| status, body, err := s.postJSON(ctx, servicePath(a, ""), in) |
| if err != nil { |
| - return "", nil, nil, err |
| + return "", nil, nil, nil, err |
| } |
| if status != http.StatusCreated { |
| - return "", nil, nil, apiErrorFromBody(status, body) |
| + return "", nil, nil, nil, apiErrorFromBody(status, body) |
| } |
| var res struct { |
| - Token string `json:"token"` |
| - Doors []string `json:"doors"` |
| - Rungs []string `json:"rungs"` |
| + Token string `json:"token"` |
| + Doors []string `json:"doors"` |
| + Rungs []string `json:"rungs"` |
| + Commands [][]string `json:"commands"` |
| } |
| if err := json.Unmarshal(body, &res); err != nil { |
| // The 201 already happened: the credential exists server-side and |
| @@ -4886,11 +4891,11 @@ func (s *apiSession) createService(ctx context.Context, a apiAcct, name, role, a |
| // — was in this unreadable body. Silence here would leave the |
| // operator not knowing the mint even landed. Point at the one way |
| // to recover a working secret for it. |
| - return "", nil, nil, fmt.Errorf("services create %s: minted, but the response was unreadable (%v) — "+ |
| + return "", nil, nil, nil, fmt.Errorf("services create %s: minted, but the response was unreadable (%v) — "+ |
| "the credential exists and its only plaintext copy is lost; check `carlos services ls` "+ |
| "and rotate it", name, err) |
| } |
| - return res.Token, res.Doors, res.Rungs, nil |
| + return res.Token, res.Doors, res.Rungs, res.Commands, nil |
| } |
| // listServices asks the console for every LIVE credential the caller's |
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go| index 7f23b4a6a..9c9b42c80 100644 |
| --- a/internal/console/apiservices.go |
| +++ b/internal/console/apiservices.go |
| @@ -32,6 +32,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/runs" |
| "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| @@ -66,6 +67,10 @@ type serviceView struct { |
| // Rungs is a deploy credential's ladder bound, canonical order. An |
| // empty array, never null, for every other row. |
| Rungs []string `json:"rungs"` |
| + // Commands is a run credential's granted command prefixes, each a list |
| + // of words, in the order minted. An empty array, never null, for every |
| + // other row. |
| + Commands [][]string `json:"commands"` |
| // 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 |
| @@ -177,6 +182,10 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) |
| // 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"` |
| + // Commands is the run credential's granted command prefixes, each |
| + // a list of argv words. Required for role run, refused for every |
| + // other role. |
| + Commands [][]string `json:"commands"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| @@ -223,7 +232,7 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) |
| } |
| } |
| actor := auditActor(p, acct) |
| - secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, req.Rungs, actor, time.Now()) |
| + secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, req.Rungs, req.Commands, 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 |
| @@ -256,6 +265,10 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusBadRequest, "only a deploy credential takes rungs") |
| return |
| } |
| + if msg, ok := runMintRefusal(err); ok { |
| + jsonError(w, http.StatusBadRequest, msg+runMintFlagHint(err)) |
| + 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 |
| @@ -269,22 +282,66 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request) |
| if canon != "" { |
| rungs = strings.Split(canon, ",") |
| } |
| + commands := [][]string{} |
| + if role == svcrole.Run { |
| + commands = req.Commands // already validated by the mint |
| + } |
| detail := "minted service credential " + name + " with role " + string(role) + " over " + scope + " (cli)" |
| - if role == svcrole.Deploy { |
| + switch role { |
| + case svcrole.Deploy: |
| detail = fmt.Sprintf("minted service credential %s with role deploy over app %s, rungs %s (%s)", name, app, canon, "cli") |
| + case svcrole.Run: |
| + detail = fmt.Sprintf("minted service credential %s with role run over app %s, commands %s (%s)", name, app, runs.FormatCommandList(commands), "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), |
| + "name": name, |
| + "role": string(role), |
| + "app": app, |
| + "rungs": rungs, |
| + "commands": commands, |
| + "account": acct.Sqid, |
| + "token": secret, |
| + "doors": s.doorsFor(role), |
| }) |
| } |
| +// runMintRefusal is the words for a run credential's mint refusals, shared |
| +// by both mint doors so they cannot describe one rule two ways. ok is false |
| +// for an error that is not one of them. The API door adds the flag to fix |
| +// (runMintFlagHint); the console form says it as a sentence. |
| +func runMintRefusal(err error) (msg string, ok bool) { |
| + switch { |
| + case errors.Is(err, ErrAccountScopedRun): |
| + return "a run credential must name one app", true |
| + case errors.Is(err, ErrRunNeedsCommands), errors.Is(err, runs.ErrNoCommands): |
| + return `a run credential must name the commands it may run, for example "import classic"`, true |
| + case errors.Is(err, ErrCommandsNeedRun): |
| + return "only a run credential takes commands", true |
| + case errors.Is(err, runs.ErrTooManyCommands): |
| + return fmt.Sprintf("a run credential takes at most %d command prefixes", runs.MaxCommandPrefixes), true |
| + case errors.Is(err, runs.ErrEmptyCommand): |
| + return "a command prefix has no words", true |
| + case errors.Is(err, runs.ErrEmptyCommandWord): |
| + return "a command prefix holds an empty word", true |
| + case errors.Is(err, runs.ErrCommandWordTooLong): |
| + return fmt.Sprintf("a command word is over %d bytes", runs.MaxCommandWordBytes), true |
| + } |
| + return "", false |
| +} |
| + |
| +// runMintFlagHint names the CLI flag that fixes a run mint refusal, the way |
| +// the deploy refusals name --app and --rungs. |
| +func runMintFlagHint(err error) string { |
| + switch { |
| + case errors.Is(err, ErrAccountScopedRun): |
| + return " (--app)" |
| + case errors.Is(err, ErrCommandsNeedRun): |
| + return "" |
| + } |
| + return " (--commands)" |
| +} |
| + |
| // handleAPIServiceList answers `carlos services list`: every live credential |
| // bound to this account, service-named and legacy-nameless alike, so that |
| // absorbing the old kind='app' rows into this model does not make them |
| @@ -308,6 +365,10 @@ func (s *Server) handleAPIServiceList(w http.ResponseWriter, r *http.Request) { |
| if t.Rungs != "" { |
| rungs = strings.Split(t.Rungs, ",") |
| } |
| + commands := t.CommandPrefixes() |
| + if commands == nil { |
| + commands = [][]string{} |
| + } |
| out = append(out, serviceView{ |
| ID: t.ID, |
| Name: t.Name, |
| @@ -315,6 +376,7 @@ func (s *Server) handleAPIServiceList(w http.ResponseWriter, r *http.Request) { |
| Role: t.Role, |
| App: t.App, |
| Rungs: rungs, |
| + Commands: commands, |
| Created: serviceStamp(t.CreatedAt), |
| Rotated: serviceStamp(t.RotatedAt), |
| LastUsed: serviceStamp(t.LastUsed), |
diff --git a/internal/console/authstore.go b/internal/console/authstore.go| index 252792a48..36d595fdb 100644 |
| --- a/internal/console/authstore.go |
| +++ b/internal/console/authstore.go |
| @@ -25,12 +25,14 @@ import ( |
| "crypto/rand" |
| "crypto/subtle" |
| "database/sql" |
| + "encoding/hex" |
| "errors" |
| "fmt" |
| "strings" |
| "sync/atomic" |
| "time" |
| + "github.com/carlosframework/platform/internal/runs" |
| "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| @@ -126,11 +128,28 @@ type CLIToken struct { |
| // Rungs is a deploy credential's scope in its stored, canonical form |
| // ("canary,edge"); empty for every other kind and role. |
| Rungs string |
| + // Commands is a run credential's granted command prefixes in their |
| + // stored form, canonical JSON; empty for every other kind and role. |
| + Commands string |
| } |
| // RungList is Rungs for a person to read: "canary, edge". |
| func (t CLIToken) RungList() string { return strings.ReplaceAll(t.Rungs, ",", ", ") } |
| +// CommandPrefixes is Commands parsed: nil when there are none, or when the |
| +// stored form cannot be read (a listing shows nothing rather than guessing; |
| +// CLIPrincipal refuses such a row outright). |
| +func (t CLIToken) CommandPrefixes() [][]string { |
| + p, ok := parseStoredCommands(t.Commands) |
| + if !ok { |
| + return nil |
| + } |
| + return p |
| +} |
| + |
| +// CommandList is Commands for a person to read: "import classic, import sync". |
| +func (t CLIToken) CommandList() string { return runs.FormatCommandList(t.CommandPrefixes()) } |
| + |
| // 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 |
| @@ -222,6 +241,14 @@ func (s *Store) migrateCLIAuth() error { |
| `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 ''`, |
| + // credential_id is a service credential's stable identity: random, |
| + // minted with the row, kept by rotation, never derived from the name |
| + // (a revoked name can be minted again and must not inherit what the |
| + // old credential started). commands is a run credential's granted |
| + // command prefixes, canonical JSON. Both default to '', a person's |
| + // token's value; backfillCredentialIDs gives older service rows an id. |
| + `ALTER TABLE cli_tokens ADD COLUMN credential_id TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE cli_tokens ADD COLUMN commands TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -250,9 +277,58 @@ ON cli_tokens (account_id, name) |
| WHERE kind = 'service' AND revoked_at = 0;`); err != nil { |
| return err |
| } |
| + return s.backfillCredentialIDs() |
| +} |
| + |
| +// backfillCredentialIDs gives every service row (kind 'service', and the |
| +// legacy kind 'app', which reads back as a service principal) that has no |
| +// credential_id a fresh random one. It runs at every Open and writes only |
| +// rows whose id is still empty, so a second run changes nothing: an id, |
| +// once given, is never replaced. A person's row keeps ”. |
| +// |
| +// The ids come from crypto/rand here rather than SQLite's randomblob, so |
| +// the backfill's ids are drawn exactly as a mint's are. |
| +func (s *Store) backfillCredentialIDs() error { |
| + rows, err := s.db.Query(`SELECT token_hash FROM cli_tokens WHERE kind IN (?, ?) AND credential_id = ''`, |
| + cliKindService, cliKindApp) |
| + if err != nil { |
| + return err |
| + } |
| + var hashes []string |
| + for rows.Next() { |
| + var h string |
| + if err := rows.Scan(&h); err != nil { |
| + rows.Close() |
| + return err |
| + } |
| + hashes = append(hashes, h) |
| + } |
| + rows.Close() |
| + if err := rows.Err(); err != nil { |
| + return err |
| + } |
| + for _, h := range hashes { |
| + id, err := newCredentialID() |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.db.Exec(`UPDATE cli_tokens SET credential_id = ? WHERE token_hash = ? AND credential_id = ''`, id, h); err != nil { |
| + return err |
| + } |
| + } |
| return nil |
| } |
| +// newCredentialID mints a credential's stable id: 128 random bits, hex. It |
| +// is an identity, not a secret, and it is never derived from the name. |
| +func newCredentialID() (string, error) { |
| + b := make([]byte, 16) |
| + if _, err := rand.Read(b); err != nil { |
| + return "", err |
| + } |
| + return hex.EncodeToString(b), nil |
| +} |
| + |
| // The three token kinds cli_tokens holds. The user kind is the empty string |
| // on purpose: it is what every row written before the app kind existed |
| // already says, so the default IS the safe answer. cliKindService is the |
| @@ -508,10 +584,14 @@ func (s *Store) claimRawNewAppToken(accountID int64, app, mintedBy, label string |
| if err != nil { |
| return "", err |
| } |
| + credID, err := newCredentialID() |
| + if err != nil { |
| + return "", err |
| + } |
| if _, err := s.db.Exec(`INSERT INTO cli_tokens |
| -(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app) |
| -VALUES (?, ?, ?, '', ?, 0, ?, ?, ?)`, |
| - hashToken(raw), mintedBy, label, now.Unix(), cliKindApp, accountID, app); err != nil { |
| +(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app, credential_id) |
| +VALUES (?, ?, ?, '', ?, 0, ?, ?, ?, ?)`, |
| + hashToken(raw), mintedBy, label, now.Unix(), cliKindApp, accountID, app, credID); err != nil { |
| return "", err |
| } |
| return raw, nil |
| @@ -558,7 +638,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 |
| -// fifteen columns in the same order and need the same legacy-row |
| +// sixteen 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 |
| @@ -570,7 +650,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, &t.Rungs); err != nil { |
| + &t.Role, &t.Name, &created, &expires, &used, &revoked, &rotated, &t.Rungs, &t.Commands); err != nil { |
| return nil, err |
| } |
| t.CreatedAt = time.Unix(created, 0).UTC() |
| @@ -601,7 +681,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, rungs |
| +role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs, commands |
| 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| index 459735a58..1e0079c84 100644 |
| --- a/internal/console/deploycred_test.go |
| +++ b/internal/console/deploycred_test.go |
| @@ -52,7 +52,7 @@ 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) |
| + _, err := st.NewServicePrincipalScoped(1, "ci", app, role, rungs, nil, "paul@example.test", now) |
| return err |
| } |
| if err := mint("", svcrole.Deploy, []string{"edge"}); !errors.Is(err, ErrAccountScopedDeploy) { |
| @@ -76,7 +76,7 @@ func TestMintDeployRules(t *testing.T) { |
| 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) |
| + raw, err := st.NewServicePrincipalScoped(1, "ci", "hello", svcrole.Deploy, []string{"edge", "canary"}, nil, "paul@example.test", now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -106,7 +106,7 @@ 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) |
| + raw, err := st.NewServicePrincipalScoped(1, "ci"+string(rune('a'+i)), "hello", svcrole.Deploy, []string{"edge"}, nil, "paul@example.test", now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -119,7 +119,7 @@ func TestDeployRowWithBadRungsDoesNotResolve(t *testing.T) { |
| } |
| // 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) |
| + raw, err := st.NewServicePrincipalScoped(1, "noapp", "hello", svcrole.Deploy, []string{"edge"}, nil, "paul@example.test", now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -130,7 +130,7 @@ func TestDeployRowWithBadRungsDoesNotResolve(t *testing.T) { |
| 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, nil, "paul@example.test", now) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/deploydoors_test.go b/internal/console/deploydoors_test.go| index 56da84e68..8d003c3d5 100644 |
| --- a/internal/console/deploydoors_test.go |
| +++ b/internal/console/deploydoors_test.go |
| @@ -52,7 +52,7 @@ func TestDeployIsBoundToItsApp(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| - tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, "paul@keymail.dev", time.Now()) |
| + tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, nil, "paul@keymail.dev", time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/deploymixed_test.go b/internal/console/deploymixed_test.go| index b6f9662d3..21610abc1 100644 |
| --- a/internal/console/deploymixed_test.go |
| +++ b/internal/console/deploymixed_test.go |
| @@ -54,7 +54,7 @@ func TestUnknownStoredRoleIsNotSignedInAtEveryDoor(t *testing.T) { |
| if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { |
| t.Fatal(err) |
| } |
| - raw, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, "paul@keymail.dev", time.Now()) |
| + raw, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, nil, "paul@keymail.dev", time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/deployscope_test.go b/internal/console/deployscope_test.go| index 298a5d443..c05fef515 100644 |
| --- a/internal/console/deployscope_test.go |
| +++ b/internal/console/deployscope_test.go |
| @@ -59,7 +59,7 @@ func TestDeployScopeRefusalsAtTheDoors(t *testing.T) { |
| []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: []byte("not an ELF at all")}}); status != http.StatusCreated { |
| t.Fatalf("person ship = %d %v", status, out) |
| } |
| - tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, "paul@keymail.dev", time.Now()) |
| + tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, nil, "paul@keymail.dev", time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/deploysign_test.go b/internal/console/deploysign_test.go| index 47bead0cf..ed555b856 100644 |
| --- a/internal/console/deploysign_test.go |
| +++ b/internal/console/deploysign_test.go |
| @@ -33,7 +33,7 @@ func deployFixture(t *testing.T) (fx *consoleFixture, person, deploy string) { |
| t.Fatalf("ship %s = %d %v", v, status, out) |
| } |
| } |
| - tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, "paul@keymail.dev", time.Now()) |
| + tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, nil, "paul@keymail.dev", time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -265,7 +265,7 @@ func TestDeployRollbackSignerFailureSaysRolledBack(t *testing.T) { |
| } |
| } |
| bad, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "bad name!", "hello", svcrole.Deploy, |
| - []string{"canary", "edge"}, "paul@keymail.dev", time.Now()) |
| + []string{"canary", "edge"}, nil, "paul@keymail.dev", time.Now()) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/svcpage.go b/internal/console/svcpage.go| index 39fc121df..c6a3bdaa6 100644 |
| --- a/internal/console/svcpage.go |
| +++ b/internal/console/svcpage.go |
| @@ -88,6 +88,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/runs" |
| "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| @@ -108,7 +109,10 @@ type ServiceSecretView struct { |
| // Rungs is a deploy credential's rungs, "canary, edge"; empty for every |
| // other role. |
| Rungs string |
| - Token string |
| + // Commands is a run credential's commands, "import classic, import |
| + // sync"; empty for every other role. |
| + Commands 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 |
| // `doors`, in a browser. Empty on rotate, which grants nothing new. |
| @@ -319,6 +323,24 @@ func (s *Server) handleServiceCreatePage(w http.ResponseWriter, r *http.Request) |
| back("err", "Only a deploy credential takes rungs. Nothing was minted.") |
| return |
| } |
| + // The run refusals, same reasoning again. The field is the CLI's |
| + // --commands text: prefixes separated by commas, words by spaces. |
| + commands := runs.ParseCommandList(r.FormValue("commands")) |
| + var cerr error |
| + switch { |
| + case role == svcrole.Run && app == "": |
| + cerr = ErrAccountScopedRun |
| + case role == svcrole.Run && len(commands) == 0: |
| + cerr = ErrRunNeedsCommands |
| + case role == svcrole.Run: |
| + cerr = runs.CheckCommandPrefixes(commands) |
| + case len(commands) != 0: |
| + cerr = ErrCommandsNeedRun |
| + } |
| + if msg, ok := runMintRefusal(cerr); ok { |
| + back("err", runMintSentence(msg)) |
| + return |
| + } |
| if app != "" { |
| found, err := s.store.HasAppIn(acct.ID, app) |
| if err != nil { |
| @@ -334,7 +356,7 @@ func (s *Server) handleServiceCreatePage(w http.ResponseWriter, r *http.Request) |
| if !s.requireFreshAuth(w, r, addr, raw) { |
| return |
| } |
| - secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, r.Form["rungs"], addr, time.Now()) |
| + secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, r.Form["rungs"], commands, 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. |
| @@ -372,6 +394,10 @@ func (s *Server) handleServiceCreatePage(w http.ResponseWriter, r *http.Request) |
| back("err", "Only a deploy credential takes rungs. Nothing was minted.") |
| return |
| } |
| + if msg, ok := runMintRefusal(err); ok { |
| + back("err", runMintSentence(msg)) |
| + return |
| + } |
| log.Printf("console: service create %s/%s: %v", acct.Sqid, name, err) |
| back("err", "Could not mint the credential — try again.") |
| return |
| @@ -383,17 +409,27 @@ func (s *Server) handleServiceCreatePage(w http.ResponseWriter, r *http.Request) |
| // serviceAuditKey, so an app-scoped credential's grant lands in THAT |
| // app's activity feed — the one place its owner looks (C38). |
| detail := "minted service credential " + name + " with role " + string(role) + " over " + scope + " (console)" |
| - if role == svcrole.Deploy { |
| + switch role { |
| + case svcrole.Deploy: |
| detail = fmt.Sprintf("minted service credential %s with role deploy over app %s, rungs %s (%s)", name, app, canon, "console") |
| + case svcrole.Run: |
| + detail = fmt.Sprintf("minted service credential %s with role run over app %s, commands %s (%s)", name, app, runs.FormatCommandList(commands), "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, |
| + Rungs: strings.ReplaceAll(canon, ",", ", "), |
| + Commands: runs.FormatCommandList(commands), |
| + Token: secret, Doors: s.doorsFor(role), ScopeKnown: true, |
| }) |
| } |
| +// runMintSentence turns a runMintRefusal into the form's sentence: capital |
| +// first, and the same "Nothing was minted." every refusal here ends with. |
| +func runMintSentence(msg string) string { |
| + return strings.ToUpper(msg[:1]) + msg[1:] + ". Nothing was minted." |
| +} |
| + |
| // handleServiceRotatePage is the pane's rotate button: a fresh secret for a |
| // credential that already exists. |
| // |
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go| index cdd8940c9..99509c2a2 100644 |
| --- a/internal/console/svcprincipal.go |
| +++ b/internal/console/svcprincipal.go |
| @@ -21,12 +21,14 @@ package console |
| import ( |
| "context" |
| "crypto/subtle" |
| + "encoding/json" |
| "errors" |
| "fmt" |
| "net/http" |
| "strconv" |
| "time" |
| + "github.com/carlosframework/platform/internal/runs" |
| "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| @@ -57,6 +59,15 @@ type Principal struct { |
| // other role, and never empty for a deploy principal: CLIPrincipal |
| // refuses a deploy row it cannot read a scope from. |
| Rungs []string |
| + // CredentialID is the credential's stable identity: its cli_tokens |
| + // row's random id, kept by rotation and fresh on every mint, so a |
| + // revoked name minted again is a different credential. Set for every |
| + // service principal; "" for a person. |
| + CredentialID string |
| + // Commands is a run credential's granted command prefixes, in the |
| + // order minted. nil for every other role, and never empty for a run |
| + // principal: CLIPrincipal refuses a run row it cannot read them from. |
| + Commands [][]string |
| } |
| // ErrServiceNameTaken is returned by NewServicePrincipal when the account |
| @@ -80,11 +91,12 @@ func (s *Store) CLIPrincipal(rawToken string, now time.Time) (Principal, bool) { |
| return Principal{}, false |
| } |
| hash := hashToken(rawToken) |
| - var storedHash, addr, kind, app, role, name, rungs string |
| + var storedHash, addr, kind, app, role, name, rungs, credID, commands string |
| var accountID, expires, revoked int64 |
| - err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs |
| + err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs, |
| +credential_id, commands |
| FROM cli_tokens WHERE token_hash = ?`, hash). |
| - Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs) |
| + Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs, &credID, &commands) |
| if err != nil { |
| return Principal{}, false |
| } |
| @@ -126,7 +138,8 @@ FROM cli_tokens WHERE token_hash = ?`, hash). |
| // The migration guarantee: identical authority to what |
| // apiInstancePrincipal has always granted this row, expressed in the |
| // new vocabulary. |
| - p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: svcrole.Instance} |
| + p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: svcrole.Instance, |
| + CredentialID: credID} |
| case cliKindService: |
| r, ok := svcrole.Parse(role) |
| if !ok { |
| @@ -145,7 +158,24 @@ FROM cli_tokens WHERE token_hash = ?`, hash). |
| // 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} |
| + var granted [][]string |
| + if r == svcrole.Run { |
| + // Fail closed, the deploy rule's twin: a run row whose |
| + // commands are missing, unreadable, empty or hold a zero-word |
| + // prefix is refused, never read as a credential that may run |
| + // anything. So is one with no app, or with no credential id, |
| + // which is what its runs are recorded against. |
| + granted, ok = parseStoredCommands(commands) |
| + if !ok || app == "" || credID == "" { |
| + return Principal{}, false |
| + } |
| + } else if commands != "" { |
| + // Commands on any other role would read as a limit and |
| + // enforce nothing. |
| + return Principal{}, false |
| + } |
| + p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: r, Name: name, Rungs: scope, |
| + CredentialID: credID, Commands: granted} |
| default: |
| return Principal{}, false |
| } |
| @@ -171,6 +201,53 @@ var ErrDeployNeedsRungs = errors.New("console: a deploy credential needs at leas |
| // read as a limit and enforce nothing. |
| var ErrRungsNeedDeploy = errors.New("console: only a deploy credential takes rungs") |
| +// ErrAccountScopedRun is a run credential minted without an app: the |
| +// Service rule, for the same reason, with its own sentinel so each door can |
| +// say which role it is about. |
| +var ErrAccountScopedRun = errors.New("console: a run credential must be bound to one app") |
| + |
| +// ErrRunNeedsCommands is a run credential minted with no command prefixes. |
| +// There is no default list: the minter says what the credential may run. |
| +var ErrRunNeedsCommands = errors.New("console: a run credential needs at least one command") |
| + |
| +// ErrCommandsNeedRun is commands named for any other role, where they would |
| +// read as a limit and enforce nothing. |
| +var ErrCommandsNeedRun = errors.New("console: only a run credential takes commands") |
| + |
| +// canonicalCommands is the stored form of a command list: JSON, an array of |
| +// arrays of strings, in the order given. "" for none. |
| +func canonicalCommands(prefixes [][]string) (string, error) { |
| + if len(prefixes) == 0 { |
| + return "", nil |
| + } |
| + b, err := json.Marshal(prefixes) |
| + if err != nil { |
| + return "", err |
| + } |
| + return string(b), nil |
| +} |
| + |
| +// parseStoredCommands reads the column back. Only a list that passes |
| +// runs.CheckCommandPrefixes, stored in the exact canonical form this |
| +// console writes, is accepted: anything else is a row this console did not |
| +// write, and a run credential with an unreadable list must not resolve. |
| +func parseStoredCommands(s string) ([][]string, bool) { |
| + if s == "" { |
| + return nil, false |
| + } |
| + var out [][]string |
| + if err := json.Unmarshal([]byte(s), &out); err != nil { |
| + return nil, false |
| + } |
| + if runs.CheckCommandPrefixes(out) != nil { |
| + return nil, false |
| + } |
| + if canon, err := canonicalCommands(out); err != nil || canon != s { |
| + return nil, false |
| + } |
| + return out, true |
| +} |
| + |
| // 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 |
| @@ -198,13 +275,17 @@ var ErrRungsNeedDeploy = errors.New("console: only a deploy credential takes run |
| // began exactly when the credential did. RotateServicePrincipal advances it |
| // on every later rotation without touching created_at. |
| func (s *Store) claimRawNewServicePrincipal(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) |
| + return s.NewServicePrincipalScoped(accountID, name, app, role, nil, 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) claimRawNewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (string, error) { |
| +// only a deploy credential takes, and a command list, which only a run |
| +// credential takes. The rules live here rather than in a handler for the |
| +// reason the Service rule does: there are two mint doors. |
| +// |
| +// Every mint draws a fresh credential_id, including one that reuses a |
| +// revoked credential's name. |
| +func (s *Store) claimRawNewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, commands [][]string, mintedBy string, now time.Time) (string, error) { |
| if name == "" { |
| return "", errors.New("console: a service credential needs a name") |
| } |
| @@ -239,6 +320,29 @@ func (s *Store) claimRawNewServicePrincipalScoped(accountID int64, name, app str |
| } else if canon != "" { |
| return "", ErrRungsNeedDeploy |
| } |
| + // The run rules, the deploy rules' twin. Commands and rungs are |
| + // separate fields: a run credential takes no rungs (refused above). |
| + if role == svcrole.Run { |
| + if app == "" { |
| + return "", ErrAccountScopedRun |
| + } |
| + if len(commands) == 0 { |
| + return "", ErrRunNeedsCommands |
| + } |
| + if err := runs.CheckCommandPrefixes(commands); err != nil { |
| + return "", err |
| + } |
| + } else if len(commands) != 0 { |
| + return "", ErrCommandsNeedRun |
| + } |
| + cmds, err := canonicalCommands(commands) |
| + if err != nil { |
| + return "", err |
| + } |
| + credID, err := newCredentialID() |
| + if err != nil { |
| + return "", err |
| + } |
| tx, err := s.db.Begin() |
| if err != nil { |
| return "", err |
| @@ -257,9 +361,11 @@ func (s *Store) claimRawNewServicePrincipalScoped(accountID int64, name, app str |
| 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, rungs) |
| -VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?, ?)`, |
| - hashToken(raw), mintedBy, now.Unix(), now.Unix(), cliKindService, accountID, app, string(role), name, canon); err != nil { |
| +(token_hash, address, label, remote_addr, created_at, rotated_at, expires_at, kind, account_id, app, role, name, rungs, |
| +credential_id, commands) |
| +VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)`, |
| + hashToken(raw), mintedBy, now.Unix(), now.Unix(), cliKindService, accountID, app, string(role), name, canon, |
| + credID, cmds); err != nil { |
| return "", err |
| } |
| if err := tx.Commit(); err != nil { |
| @@ -279,7 +385,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, rungs |
| +role, name, created_at, expires_at, last_used, revoked_at, rotated_at, rungs, commands |
| 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 { |
| @@ -291,7 +397,9 @@ ORDER BY created_at DESC, token_hash`, accountID, cliKindService, cliKindApp) |
| // RotateServicePrincipal mints a fresh secret for a live, NAMED credential |
| // and retires the old one in the same statement: same row, same name, same |
| // role — only the secret moves, rotated_at advances to now, and last_used |
| -// resets, because a fresh secret has not been used yet. created_at is |
| +// resets, because a fresh secret has not been used yet. credential_id and |
| +// commands are not touched: a rotated credential is the same credential, |
| +// so it keeps its identity and what it may run. created_at is |
| // deliberately left alone: overwriting it would make a credential granted a |
| // year ago read as newly minted on the very page an owner uses to decide |
| // what is stale. rotated_at is the answer to that question instead — "how |
| @@ -518,12 +626,12 @@ func (s *Store) NewServicePrincipalContext(claimContext context.Context, account |
| }) |
| return |
| } |
| -func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (claimResult0 string, claimResult1 error) { |
| - return s.NewServicePrincipalScopedContext(context.Background(), accountID, name, app, role, rungs, mintedBy, now) |
| +func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, commands [][]string, mintedBy string, now time.Time) (claimResult0 string, claimResult1 error) { |
| + return s.NewServicePrincipalScopedContext(context.Background(), accountID, name, app, role, rungs, commands, mintedBy, now) |
| } |
| -func (s *Store) NewServicePrincipalScopedContext(claimContext context.Context, accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (claimResult0 string, claimResult1 error) { |
| +func (s *Store) NewServicePrincipalScopedContext(claimContext context.Context, accountID int64, name, app string, role svcrole.Role, rungs []string, commands [][]string, mintedBy string, now time.Time) (claimResult0 string, claimResult1 error) { |
| claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.NewServicePrincipalScoped", func(claimView *Store) error { |
| - claimResult0, claimResult1 = claimView.claimRawNewServicePrincipalScoped(accountID, name, app, role, rungs, mintedBy, now) |
| + claimResult0, claimResult1 = claimView.claimRawNewServicePrincipalScoped(accountID, name, app, role, rungs, commands, mintedBy, now) |
| return claimResult1 |
| }) |
| return |
diff --git a/internal/console/svcprincipal_run_test.go b/internal/console/svcprincipal_run_test.go| new file mode 100644 |
| index 000000000..ca381dc69 |
| --- /dev/null |
| +++ b/internal/console/svcprincipal_run_test.go |
| @@ -0,0 +1,416 @@ |
| +package console |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + "net/url" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/runs" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +var importClassic = [][]string{{"import", "classic"}} |
| + |
| +func mintRun(t *testing.T, st *Store, name string, commands [][]string, now time.Time) string { |
| + t.Helper() |
| + raw, err := st.NewServicePrincipalScoped(1, name, "hello", svcrole.Run, nil, commands, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatalf("mint run %s: %v", name, err) |
| + } |
| + return raw |
| +} |
| + |
| +func credentialIDOf(t *testing.T, st *Store, raw string) string { |
| + t.Helper() |
| + var id string |
| + if err := st.db.QueryRow(`SELECT credential_id FROM cli_tokens WHERE token_hash = ?`, hashToken(raw)).Scan(&id); err != nil { |
| + t.Fatal(err) |
| + } |
| + return id |
| +} |
| + |
| +func TestRunCredentialCarriesItsCommands(t *testing.T) { |
| + st := newDeployStore(t) |
| + now := time.Now() |
| + raw := mintRun(t, st, "importer", [][]string{{"import", "classic"}, {"import", "sync"}}, now) |
| + p, ok := st.CLIPrincipal(raw, now) |
| + if !ok { |
| + t.Fatal("a run credential does not resolve") |
| + } |
| + if p.Role != svcrole.Run || p.App != "hello" || p.Name != "importer" { |
| + t.Fatalf("CLIPrincipal = %+v", p) |
| + } |
| + if want := [][]string{{"import", "classic"}, {"import", "sync"}}; !reflect.DeepEqual(p.Commands, want) { |
| + t.Fatalf("Commands = %q, want %q", p.Commands, want) |
| + } |
| + if p.CredentialID == "" || p.CredentialID != credentialIDOf(t, st, raw) { |
| + t.Fatalf("CredentialID = %q, want the row's own non-empty id", p.CredentialID) |
| + } |
| + // At least 128 bits: 16 random bytes, hex. |
| + if len(p.CredentialID) < 32 { |
| + t.Fatalf("CredentialID %q is shorter than 128 bits of hex", p.CredentialID) |
| + } |
| + if strings.Contains(p.CredentialID, "importer") { |
| + t.Fatalf("CredentialID %q is derived from the name", p.CredentialID) |
| + } |
| + // Stored canonically: JSON, in the order given. |
| + var stored string |
| + if err := st.db.QueryRow(`SELECT commands FROM cli_tokens WHERE token_hash = ?`, hashToken(raw)).Scan(&stored); err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := `[["import","classic"],["import","sync"]]`; stored != want { |
| + t.Fatalf("stored commands = %s, want %s", stored, want) |
| + } |
| + |
| + // Every other service principal has an id too, and no commands. |
| + pub, err := st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, nil, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pp, ok := st.CLIPrincipal(pub, now) |
| + if !ok || pp.CredentialID == "" || pp.Commands != nil { |
| + t.Fatalf("publish principal = %+v, %v; want an id and nil Commands", pp, ok) |
| + } |
| + if pp.CredentialID == p.CredentialID { |
| + t.Fatal("two credentials share one id") |
| + } |
| + // The listing carries the stored form. |
| + rows, err := st.ServicePrincipals(1) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, r := range rows { |
| + if r.Name == "importer" && r.CommandList() != "import classic, import sync" { |
| + t.Fatalf("listing CommandList = %q", r.CommandList()) |
| + } |
| + if r.Name == "pub" && r.Commands != "" { |
| + t.Fatalf("publish listing Commands = %q, want empty", r.Commands) |
| + } |
| + } |
| +} |
| + |
| +func TestRunCredentialNeedsCommands(t *testing.T) { |
| + st := newDeployStore(t) |
| + now := time.Now() |
| + mint := func(app string, role svcrole.Role, commands [][]string) error { |
| + _, err := st.NewServicePrincipalScoped(1, "x", app, role, nil, commands, "paul@example.test", now) |
| + return err |
| + } |
| + if err := mint("hello", svcrole.Run, nil); !errors.Is(err, ErrRunNeedsCommands) { |
| + t.Errorf("run with nil commands: %v, want ErrRunNeedsCommands", err) |
| + } |
| + if err := mint("hello", svcrole.Run, [][]string{}); !errors.Is(err, ErrRunNeedsCommands) { |
| + t.Errorf("run with no commands: %v, want ErrRunNeedsCommands", err) |
| + } |
| + if err := mint("hello", svcrole.Instance, importClassic); !errors.Is(err, ErrCommandsNeedRun) { |
| + t.Errorf("instance with commands: %v, want ErrCommandsNeedRun", err) |
| + } |
| + if err := mint("hello", svcrole.Admin, importClassic); !errors.Is(err, ErrCommandsNeedRun) { |
| + t.Errorf("admin with commands: %v, want ErrCommandsNeedRun", err) |
| + } |
| + if err := mint("", svcrole.Run, importClassic); !errors.Is(err, ErrAccountScopedRun) { |
| + t.Errorf("run with no app: %v, want ErrAccountScopedRun", err) |
| + } |
| + if err := mint("hello", svcrole.Run, [][]string{{"import"}, {}}); !errors.Is(err, runs.ErrEmptyCommand) { |
| + t.Errorf("run with a zero-word prefix: %v, want runs.ErrEmptyCommand", err) |
| + } |
| + if err := mint("hello", svcrole.Run, [][]string{{"import", ""}}); !errors.Is(err, runs.ErrEmptyCommandWord) { |
| + t.Errorf("run with an empty word: %v, want runs.ErrEmptyCommandWord", err) |
| + } |
| + // Rungs and commands are separate fields: a run credential takes no rungs. |
| + if _, err := st.NewServicePrincipalScoped(1, "x", "hello", svcrole.Run, []string{"edge"}, importClassic, "paul@example.test", now); !errors.Is(err, ErrRungsNeedDeploy) { |
| + t.Errorf("run with rungs: %v, want ErrRungsNeedDeploy", err) |
| + } |
| + // The old entry point carries no commands, so it cannot mint a run credential. |
| + if _, err := st.NewServicePrincipal(1, "x", "hello", svcrole.Run, "paul@example.test", now); !errors.Is(err, ErrRunNeedsCommands) { |
| + t.Errorf("NewServicePrincipal(run): %v, want ErrRunNeedsCommands", err) |
| + } |
| + if rows, err := st.ServicePrincipals(1); err != nil || len(rows) != 0 { |
| + t.Fatalf("ServicePrincipals = %d rows %v after refused mints, want none", len(rows), err) |
| + } |
| +} |
| + |
| +// TestRunCredentialRefusedWithAnEmptyPrefix: a stored run row whose |
| +// commands are missing, unparseable, empty, non-canonical or hold a |
| +// zero-word prefix is refused outright, never read as an unscoped principal. |
| +func TestRunCredentialRefusedWithAnEmptyPrefix(t *testing.T) { |
| + st := newDeployStore(t) |
| + now := time.Now() |
| + for i, stored := range []string{ |
| + `[[]]`, |
| + `[["import"],[]]`, |
| + `[null]`, |
| + ``, |
| + `null`, |
| + `[]`, |
| + `not json`, |
| + `[["import",""]]`, |
| + `[["import", "classic"]]`, // parses, but not the canonical form this console writes |
| + `{"import":"classic"}`, |
| + } { |
| + raw := mintRun(t, st, "run"+string(rune('a'+i)), importClassic, now) |
| + if _, err := st.db.Exec(`UPDATE cli_tokens SET commands = ? WHERE token_hash = ?`, stored, hashToken(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, ok := st.CLIPrincipal(raw, now); ok { |
| + t.Errorf("commands %q: resolved to %+v; want refused", stored, p) |
| + } |
| + } |
| + // A run row with no credential id is refused: Task 3 keys runs on it. |
| + raw := mintRun(t, st, "noid", importClassic, now) |
| + if _, err := st.db.Exec(`UPDATE cli_tokens SET credential_id = '' WHERE token_hash = ?`, hashToken(raw)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, ok := st.CLIPrincipal(raw, now); ok { |
| + t.Errorf("a run row with no credential id resolved to %+v; want refused", p) |
| + } |
| + // A run row whose app was emptied is refused: never account-wide. |
| + raw = mintRun(t, st, "noapp", importClassic, now) |
| + 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 run row with app '' resolved to %+v; want refused", p) |
| + } |
| + // Any other role carrying commands is refused too. |
| + pub, err := st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, nil, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.db.Exec(`UPDATE cli_tokens SET commands = '[["import"]]' WHERE token_hash = ?`, hashToken(pub)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, ok := st.CLIPrincipal(pub, now); ok { |
| + t.Errorf("a publish row with commands resolved to %+v; want refused", p) |
| + } |
| +} |
| + |
| +func TestRotationKeepsCredentialIDAndCommands(t *testing.T) { |
| + st := newDeployStore(t) |
| + now := time.Now() |
| + raw := mintRun(t, st, "importer", importClassic, now) |
| + before, ok := st.CLIPrincipal(raw, now) |
| + if !ok { |
| + t.Fatal("does not resolve") |
| + } |
| + fresh, err := st.RotateServicePrincipal(1, "importer", now.Add(time.Minute)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := st.CLIPrincipal(raw, now); ok { |
| + t.Fatal("the old secret still resolves after rotate") |
| + } |
| + after, ok := st.CLIPrincipal(fresh, now) |
| + if !ok { |
| + t.Fatal("the rotated secret does not resolve") |
| + } |
| + if after.CredentialID != before.CredentialID { |
| + t.Fatalf("rotation moved the credential id: %q -> %q", before.CredentialID, after.CredentialID) |
| + } |
| + if !reflect.DeepEqual(after.Commands, importClassic) { |
| + t.Fatalf("rotation changed the commands: %q", after.Commands) |
| + } |
| +} |
| + |
| +func TestRemintGetsANewCredentialID(t *testing.T) { |
| + st := newDeployStore(t) |
| + now := time.Now() |
| + old := mintRun(t, st, "importer", importClassic, now) |
| + oldID := credentialIDOf(t, st, old) |
| + if err := st.RevokeServicePrincipal(1, "importer", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + fresh := mintRun(t, st, "importer", importClassic, now) |
| + p, ok := st.CLIPrincipal(fresh, now) |
| + if !ok { |
| + t.Fatal("the re-minted credential does not resolve") |
| + } |
| + if p.CredentialID == "" || p.CredentialID == oldID { |
| + t.Fatalf("re-minted credential id = %q, old = %q; want a fresh one", p.CredentialID, oldID) |
| + } |
| +} |
| + |
| +// TestCredentialIDBackfillIsIdempotent: rows written before the column |
| +// existed (an empty credential_id) get a fresh, distinct id at Open, and a |
| +// later Open keeps every id it finds. |
| +func TestCredentialIDBackfillIsIdempotent(t *testing.T) { |
| + path := t.TempDir() + "/console.db" |
| + st, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now() |
| + svc, err := st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, nil, "paul@example.test", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + app, err := st.NewAppToken(1, "hello", "paul@example.test", "router", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + user := mintUserToken(t, st, "paul@example.test", now) |
| + // Pretend these were written by a console before the column existed. |
| + if _, err := st.db.Exec(`UPDATE cli_tokens SET credential_id = ''`); err != nil { |
| + t.Fatal(err) |
| + } |
| + st.Close() |
| + |
| + st, err = Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + svcID, appID, userID := credentialIDOf(t, st, svc), credentialIDOf(t, st, app), credentialIDOf(t, st, user) |
| + if svcID == "" || appID == "" { |
| + t.Fatalf("backfill left a service row without an id: service %q, app %q", svcID, appID) |
| + } |
| + if svcID == appID { |
| + t.Fatal("backfill gave two rows one id") |
| + } |
| + if userID != "" { |
| + t.Fatalf("backfill gave a person's token an id %q", userID) |
| + } |
| + st.Close() |
| + |
| + st, err = Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + if got := credentialIDOf(t, st, svc); got != svcID { |
| + t.Fatalf("a second Open moved the service id: %q -> %q", svcID, got) |
| + } |
| + if got := credentialIDOf(t, st, app); got != appID { |
| + t.Fatalf("a second Open moved the app id: %q -> %q", appID, got) |
| + } |
| + if p, ok := st.CLIPrincipal(app, now); !ok || p.CredentialID != appID { |
| + t.Fatalf("legacy app principal = %+v, %v; want CredentialID %q", p, ok, appID) |
| + } |
| + if p, ok := st.CLIPrincipal(user, now); !ok || p.CredentialID != "" { |
| + t.Fatalf("person principal = %+v, %v; want no CredentialID", p, ok) |
| + } |
| +} |
| + |
| +func TestAPIMintRun(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-run-mint") |
| + u := fx.ts.URL + "/api/cli/services/bab" |
| + status, out := postJSON(t, u, map[string]any{"name": "importer", "role": "run", "app": "hello", |
| + "commands": [][]string{{"import", "classic"}, {"import", "sync"}}}, token) |
| + if status != http.StatusCreated { |
| + t.Fatalf("mint run = %d %v", status, out) |
| + } |
| + if got := out["commands"]; !reflect.DeepEqual(got, []any{[]any{"import", "classic"}, []any{"import", "sync"}}) { |
| + t.Fatalf("commands in response = %#v", got) |
| + } |
| + for _, tc := range []struct { |
| + body map[string]any |
| + want string |
| + }{ |
| + {map[string]any{"name": "a1", "role": "run", "commands": importClassic}, "must name one app"}, |
| + {map[string]any{"name": "a2", "role": "run", "app": "hello"}, "must name the commands it may run"}, |
| + {map[string]any{"name": "a3", "role": "run", "app": "hello", "commands": [][]string{{}}}, "a command prefix has no words"}, |
| + {map[string]any{"name": "a4", "role": "run", "app": "hello", "commands": [][]string{{"import", ""}}}, "empty word"}, |
| + {map[string]any{"name": "a5", "role": "publish", "app": "hello", "commands": importClassic}, "only a run credential takes commands"}, |
| + } { |
| + 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) |
| + } |
| + } |
| + 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 c, ok := out["commands"].([]any); !ok || len(c) != 0 { |
| + t.Fatalf("publish mint commands = %#v, want []", out["commands"]) |
| + } |
| + status, out = getJSON(t, u, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("list = %d %v", status, out) |
| + } |
| + rows, _ := out["services"].([]any) |
| + if len(rows) != 2 { |
| + t.Fatalf("listing = %v", rows) |
| + } |
| + for _, r := range rows { |
| + row := r.(map[string]any) |
| + c, ok := row["commands"].([]any) |
| + switch row["name"] { |
| + case "importer": |
| + if !reflect.DeepEqual(c, []any{[]any{"import", "classic"}, []any{"import", "sync"}}) { |
| + t.Errorf("listing commands for importer = %#v", row["commands"]) |
| + } |
| + case "pub": |
| + if !ok || len(c) != 0 { |
| + t.Errorf("listing commands for pub = %#v, want []", row["commands"]) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestFormMintRun(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) |
| + } |
| + for _, bad := range []struct { |
| + why string |
| + form url.Values |
| + want string |
| + }{ |
| + {"no app", url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"run"}, "commands": {"import classic"}}, "A run credential must name one app. Nothing was minted."}, |
| + {"no commands", url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"run"}, "app": {"kass"}}, "A run credential must name the commands it may run"}, |
| + {"only commas", url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"run"}, "app": {"kass"}, "commands": {" , ,"}}, "A run credential must name the commands it may run"}, |
| + {"a word too long", url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"run"}, "app": {"kass"}, "commands": {"import " + strings.Repeat("x", 129)}}, "128 bytes"}, |
| + {"commands on publish", url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"publish"}, "app": {"kass"}, "commands": {"import classic"}}, "Only a run credential takes commands. 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)) |
| + } |
| + } |
| + // The form offers the field. |
| + _, _, page := get(t, c, fx.u("/settings")) |
| + if !strings.Contains(page, `name="commands"`) { |
| + t.Fatalf("the mint form has no commands field:\n%s", page) |
| + } |
| + |
| + status, _, body := postGated(t, fx, c, fx.u("/services/new"), |
| + url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"run"}, "app": {"kass"}, "commands": {"import classic, import sync"}}) |
| + if status != http.StatusOK { |
| + t.Fatalf("run mint = %d:\n%s", status, body) |
| + } |
| + if !strings.Contains(body, "commands import classic, import sync") { |
| + t.Fatalf("the secret banner does not name the commands:\n%s", body) |
| + } |
| + secret := paneSecret(t, body) |
| + p, ok := fx.st.CLIPrincipal(secret, time.Now()) |
| + if !ok || !reflect.DeepEqual(p.Commands, [][]string{{"import", "classic"}, {"import", "sync"}}) { |
| + t.Fatalf("minted credential resolves to %+v, %v", p, ok) |
| + } |
| + _, _, page = get(t, c, fx.u("/settings")) |
| + if !strings.Contains(page, "import classic, import sync") { |
| + t.Fatalf("the credential listing does not show the commands:\n%s", page) |
| + } |
| +} |
diff --git a/internal/console/templates/account_settings.html b/internal/console/templates/account_settings.html| index 921d193de..03bbfb703 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}} |
| - {{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}} |
| + {{if .Rungs}}<p><strong>Minted {{.Name}}: role {{.Role}}, over {{.Scope}}, rungs {{.Rungs}}:</strong></p>{{else if .Commands}}<p><strong>Minted {{.Name}}: role {{.Role}}, over {{.Scope}}, commands {{.Commands}}:</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>Rungs</th><th>Label</th> |
| + <th>Name</th><th>Role</th><th>Scope</th><th>Rungs</th><th>Commands</th><th>Label</th> |
| <th>Secret since</th><th>Last used</th><th>Minted by</th><th></th> |
| </tr> |
| {{range .Services}} |
| @@ -111,6 +111,7 @@ |
| <td>{{.Role}}</td> |
| <td>{{if .App}}app {{.App}}{{else}}account-wide{{end}}</td> |
| <td>{{emptyOrDash .RungList}}</td> |
| + <td>{{emptyOrDash .CommandList}}</td> |
| <td>{{emptyOrDash .Label}}</td> |
| <td>{{if .RotatedAt.IsZero}}—{{else}}{{fmtTime .RotatedAt}}{{end}}</td> |
| <td>{{if .LastUsed.IsZero}}—{{else}}{{fmtTime .LastUsed}}{{end}}</td> |
| @@ -154,6 +155,8 @@ |
| </select> |
| <span class="small">Rungs, for a deploy credential:</span> |
| {{range .ServiceRungs}}<label class="small"><input type="checkbox" name="rungs" value="{{.}}"> {{.}}</label>{{end}} |
| + <label class="small">Commands, for a run credential: |
| + <input type="text" name="commands" placeholder="import classic, import sync"></label> |
| <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. |
diff --git a/internal/runs/commands.go b/internal/runs/commands.go| new file mode 100644 |
| index 000000000..6ba9d272e |
| --- /dev/null |
| +++ b/internal/runs/commands.go |
| @@ -0,0 +1,101 @@ |
| +package runs |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| +) |
| + |
| +// A `run` service credential carries the command prefixes it may run |
| +// (service-runs spec § 1). These are the limits on that list, and the one |
| +// function that decides whether an argv is inside it. The console mints |
| +// with CheckCommandPrefixes, reads a stored list back through it, and gates |
| +// a run's create on CommandGranted. |
| +const ( |
| + MaxCommandPrefixes = 32 |
| + MaxCommandWordBytes = 128 |
| +) |
| + |
| +var ( |
| + ErrNoCommands = errors.New("runs: no command prefixes") |
| + ErrTooManyCommands = errors.New("runs: more than 32 command prefixes") |
| + ErrEmptyCommand = errors.New("runs: a command prefix has no words") |
| + ErrEmptyCommandWord = errors.New("runs: a command prefix holds an empty word") |
| + ErrCommandWordTooLong = errors.New("runs: a command word over 128 bytes") |
| +) |
| + |
| +// CheckCommandPrefixes is the shape every stored command list has: at least |
| +// one prefix and at most MaxCommandPrefixes, each with at least one word, |
| +// each word 1 to MaxCommandWordBytes bytes. A zero-word prefix is refused |
| +// because it would match every argv. |
| +func CheckCommandPrefixes(prefixes [][]string) error { |
| + if len(prefixes) == 0 { |
| + return ErrNoCommands |
| + } |
| + if len(prefixes) > MaxCommandPrefixes { |
| + return ErrTooManyCommands |
| + } |
| + for _, p := range prefixes { |
| + if len(p) == 0 { |
| + return ErrEmptyCommand |
| + } |
| + for _, w := range p { |
| + if w == "" { |
| + return ErrEmptyCommandWord |
| + } |
| + if len(w) > MaxCommandWordBytes { |
| + return ErrCommandWordTooLong |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// CommandGranted reports whether argv starts with every word of at least one |
| +// prefix, compared word for word and exactly: no globbing, no case folding, |
| +// no substring. It fails closed: a zero-word prefix matches nothing, and so |
| +// does an empty argv. |
| +func CommandGranted(argv []string, prefixes [][]string) bool { |
| + for _, p := range prefixes { |
| + if len(p) == 0 || len(argv) < len(p) { |
| + continue |
| + } |
| + match := true |
| + for i, w := range p { |
| + if argv[i] != w { |
| + match = false |
| + break |
| + } |
| + } |
| + if match { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// ParseCommandList reads the text form both mint doors take, the CLI's |
| +// --commands flag and the console form's field: prefixes separated by |
| +// commas, a prefix's words by spaces. "import classic,import sync" is two |
| +// prefixes of two words each. An entry with no words is dropped, the way an |
| +// empty rung entry is, so a trailing comma grants nothing rather than |
| +// everything. It returns nil when nothing was named; validation is |
| +// CheckCommandPrefixes's. |
| +func ParseCommandList(s string) [][]string { |
| + var out [][]string |
| + for _, entry := range strings.Split(s, ",") { |
| + if words := strings.Fields(entry); len(words) > 0 { |
| + out = append(out, words) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// FormatCommandList is a command list for a person to read: |
| +// "import classic, import sync". |
| +func FormatCommandList(prefixes [][]string) string { |
| + parts := make([]string, len(prefixes)) |
| + for i, p := range prefixes { |
| + parts[i] = strings.Join(p, " ") |
| + } |
| + return strings.Join(parts, ", ") |
| +} |
diff --git a/internal/runs/commands_test.go b/internal/runs/commands_test.go| new file mode 100644 |
| index 000000000..7621cfde9 |
| --- /dev/null |
| +++ b/internal/runs/commands_test.go |
| @@ -0,0 +1,99 @@ |
| +package runs |
| + |
| +import ( |
| + "errors" |
| + "reflect" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestCommandGranted is Review Focus 4's pin among others: a prefix matches |
| +// word for word and exactly, never as a substring, never case-folded, and a |
| +// zero-word prefix matches nothing. |
| +func TestCommandGranted(t *testing.T) { |
| + ic := [][]string{{"import", "classic"}} |
| + for _, tc := range []struct { |
| + name string |
| + argv []string |
| + prefixes [][]string |
| + want bool |
| + }{ |
| + {"prefix then more words", []string{"import", "classic", "--slug", "x"}, ic, true}, |
| + {"exactly the prefix", []string{"import", "classic"}, ic, true}, |
| + {"shorter than the prefix", []string{"import"}, ic, false}, |
| + {"a word that only starts with the prefix word", []string{"importer", "classic"}, ic, false}, |
| + {"a word the prefix word only starts", []string{"imp", "classic"}, ic, false}, |
| + {"case differs", []string{"Import", "classic"}, ic, false}, |
| + {"empty argv", []string{}, ic, false}, |
| + {"nil argv", nil, ic, false}, |
| + {"empty argv under a zero-word prefix", []string{}, [][]string{{}}, false}, |
| + {"second of two prefixes", []string{"import", "sync", "--all"}, [][]string{{"import", "classic"}, {"import", "sync"}}, true}, |
| + {"neither of two prefixes", []string{"export", "sync"}, [][]string{{"import", "classic"}, {"import", "sync"}}, false}, |
| + {"zero-word prefix", []string{"anything"}, [][]string{{}}, false}, |
| + {"nil prefix", []string{"anything"}, [][]string{nil}, false}, |
| + {"no prefixes", []string{"anything"}, nil, false}, |
| + {"zero-word prefix beside a real one does not widen it", []string{"rm", "-rf"}, [][]string{nil, {"import"}}, false}, |
| + } { |
| + if got := CommandGranted(tc.argv, tc.prefixes); got != tc.want { |
| + t.Errorf("%s: CommandGranted(%q, %q) = %v, want %v", tc.name, tc.argv, tc.prefixes, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +func TestCheckCommandPrefixes(t *testing.T) { |
| + if err := CheckCommandPrefixes(nil); !errors.Is(err, ErrNoCommands) { |
| + t.Errorf("nil: err = %v, want ErrNoCommands", err) |
| + } |
| + if err := CheckCommandPrefixes([][]string{}); !errors.Is(err, ErrNoCommands) { |
| + t.Errorf("empty: err = %v, want ErrNoCommands", err) |
| + } |
| + word128 := strings.Repeat("a", MaxCommandWordBytes) |
| + many := func(n int) [][]string { |
| + out := make([][]string, n) |
| + for i := range out { |
| + out[i] = []string{word128} |
| + } |
| + return out |
| + } |
| + for name, bad := range map[string][][]string{ |
| + "zero-word prefix": {{"import"}, {}}, |
| + "nil prefix": {nil}, |
| + "empty word": {{"import", ""}}, |
| + "129-byte word": {{"import", word128 + "a"}}, |
| + "33 prefixes": many(MaxCommandPrefixes + 1), |
| + "zero-word prefix only": {{}}, |
| + } { |
| + if err := CheckCommandPrefixes(bad); err == nil { |
| + t.Errorf("%s: accepted; want refused", name) |
| + } |
| + } |
| + if err := CheckCommandPrefixes(many(MaxCommandPrefixes)); err != nil { |
| + t.Errorf("32 prefixes of 128-byte words: %v; want accepted", err) |
| + } |
| + if err := CheckCommandPrefixes([][]string{{"import", "classic"}, {"import", "sync"}}); err != nil { |
| + t.Errorf("two prefixes: %v", err) |
| + } |
| +} |
| + |
| +// TestParseCommandList is the one text form both the CLI flag and the |
| +// console form take: prefixes split on commas, words on spaces, empty |
| +// entries dropped. |
| +func TestParseCommandList(t *testing.T) { |
| + for _, tc := range []struct { |
| + in string |
| + want [][]string |
| + }{ |
| + {"", nil}, |
| + {" , ,", nil}, |
| + {"import classic", [][]string{{"import", "classic"}}}, |
| + {"import classic,import sync", [][]string{{"import", "classic"}, {"import", "sync"}}}, |
| + {" import classic , import sync ,", [][]string{{"import", "classic"}, {"import", "sync"}}}, |
| + } { |
| + if got := ParseCommandList(tc.in); !reflect.DeepEqual(got, tc.want) { |
| + t.Errorf("ParseCommandList(%q) = %q, want %q", tc.in, got, tc.want) |
| + } |
| + } |
| + if got := FormatCommandList([][]string{{"import", "classic"}, {"import", "sync"}}); got != "import classic, import sync" { |
| + t.Errorf("FormatCommandList = %q", got) |
| + } |
| +} |
diff --git a/services.go b/services.go| index 777c470de..446a8973e 100644 |
| --- a/services.go |
| +++ b/services.go |
| @@ -25,12 +25,13 @@ import ( |
| "text/tabwriter" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/runs" |
| "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| const servicesUsage = `usage: carlos services <create|ls|rotate|revoke> [flags] |
| - create --role <publish|instance|operate|service|admin> [--app <app>] [--account <sqid|name>] <name> |
| + create --role <publish|deploy|instance|operate|service|run|admin> [--app <app>] [--account <sqid|name>] <name> |
| mint a credential a SERVER holds, bound to this account — |
| not to you. Unlike your own CLI token, its reach is fixed |
| at mint: adding you to another account later does not widen |
| @@ -43,6 +44,9 @@ const servicesUsage = `usage: carlos services <create|ls|rotate|revoke> [flags] |
| convenience bundle. Prefer the narrowest role that works. |
| --app binds it to ONE app, which also means it cannot create |
| apps. Omit --app for an account-scoped credential. |
| + --role run needs --app and --commands, the command |
| + prefixes it may run: --commands "import classic,import sync". |
| + Words are matched exactly, from the start of the command. |
| ls [--account <sqid|name>] |
| list every LIVE credential the account owns — name, role, |
| @@ -154,16 +158,17 @@ func validServiceHandle(handle string) error { |
| func runServicesCreate(args []string) { |
| fs := flag.NewFlagSet("services create", flag.ExitOnError) |
| - role := fs.String("role", "", "role: publish, deploy, instance, operate, service or admin (required)") |
| + role := fs.String("role", "", "role: publish, deploy, instance, operate, service, run or admin (required)") |
| app := appFlag(fs, "bind the credential to one app (default: account-wide)") |
| rungsRaw := fs.String("rungs", "", "rungs a deploy credential may move, comma-separated: canary, edge, beta (required with --role deploy)") |
| + commandsRaw := fs.String("commands", "", `command prefixes a run credential may run, comma-separated, words space-separated: "import classic,import sync" (required with --role run)`) |
| acctRaw := fs.String("account", "", "account (sqid or name) to mint the credential in (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| consoleFlag := consoleOption(fs, "console to create it through (default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| - const usageMsg = "usage: carlos services create --role <publish|deploy|instance|operate|service|admin> [--app <app>] " + |
| - "[--rungs <list>] [--account <sqid|name>] [--account-id <sqid>] [--console <origin>] <name>" |
| + const usageMsg = "usage: carlos services create --role <publish|deploy|instance|operate|service|run|admin> [--app <app>] " + |
| + "[--rungs <list>] [--commands <list>] [--account <sqid|name>] [--account-id <sqid>] [--console <origin>] <name>" |
| if fs.NArg() != 1 || strings.TrimSpace(*role) == "" { |
| log.Fatal(usageMsg) |
| } |
| @@ -184,16 +189,25 @@ func runServicesCreate(args []string) { |
| if len(rungsV) > 0 && roleV != string(svcrole.Deploy) { |
| log.Fatal("services create: --rungs is only for --role deploy") |
| } |
| + // The console is the authority on a command list's shape; these two |
| + // are refused here because the server is certain to refuse them. |
| + commandsV := runs.ParseCommandList(*commandsRaw) |
| + if len(commandsV) > 0 && roleV != string(svcrole.Run) { |
| + log.Fatal("services create: --commands is only for --role run") |
| + } |
| + if len(commandsV) == 0 && roleV == string(svcrole.Run) { |
| + log.Fatal(`services create: --role run needs --commands, the command prefixes it may run: --commands "import classic,import sync"`) |
| + } |
| appV := strings.TrimSpace(*app) |
| sess := servicesSignedInSession(*consoleFlag) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| - token, doors, gotRungs, err := sess.createService(context.Background(), acct, name, roleV, appV, rungsV) |
| + token, doors, gotRungs, gotCommands, err := sess.createService(context.Background(), acct, name, roleV, appV, rungsV, commandsV) |
| if err != nil { |
| log.Fatalf("services create: %v", err) |
| } |
| - printServiceCreated(acct.Seg, name, roleV, appV, gotRungs, token, doors) |
| + printServiceCreated(acct.Seg, name, roleV, appV, gotRungs, gotCommands, token, doors) |
| } |
| // roleNames is svcrole's catalogue as strings, read from the package rather |
| @@ -243,13 +257,15 @@ func deliverLine(appHint string) string { |
| // credential's door list must not read as "any app"; the {app} wildcard in |
| // every releases/promote/etc. pattern below is only ever true for THIS app), |
| // the doors it reaches, how to deliver it, and how to revoke it. |
| -func printServiceCreated(acctSeg, name, role, app string, rungs []string, token string, doors []string) { |
| +func printServiceCreated(acctSeg, name, role, app string, rungs []string, commands [][]string, token string, doors []string) { |
| scope := "account-wide" |
| if app != "" { |
| scope = "app " + app |
| } |
| if role == string(svcrole.Deploy) { |
| fmt.Fprintf(stdout, "minted service:%s/%s (role deploy, app %s, rungs %s)\n\n", acctSeg, name, app, strings.Join(rungs, ", ")) |
| + } else if role == string(svcrole.Run) { |
| + fmt.Fprintf(stdout, "minted service:%s/%s (role run, app %s, commands %s)\n\n", acctSeg, name, app, runs.FormatCommandList(commands)) |
| } else { |
| fmt.Fprintf(stdout, "minted service:%s/%s (role %s, %s)\n\n", acctSeg, name, role, scope) |
| } |
| @@ -303,7 +319,7 @@ func runServicesList(args []string) { |
| return |
| } |
| w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| - fmt.Fprintln(w, "NAME\tROLE\tAPP\tRUNGS\tLABEL\tCREATED\tROTATED\tLAST USED\tMINTED BY") |
| + fmt.Fprintln(w, "NAME\tROLE\tAPP\tRUNGS\tCOMMANDS\tLABEL\tCREATED\tROTATED\tLAST USED\tMINTED BY") |
| for _, r := range rows { |
| name, label, app := r.Name, r.Label, r.App |
| if name == "" { |
| @@ -319,6 +335,16 @@ func runServicesList(args []string) { |
| if rungs == "" { |
| rungs = "-" |
| } |
| + // Comma-separated, words space-separated: the --commands form, so |
| + // a list can be copied straight into another mint. |
| + commands := make([]string, len(r.Commands)) |
| + for i, c := range r.Commands { |
| + commands[i] = strings.Join(c, " ") |
| + } |
| + cmds := strings.Join(commands, ",") |
| + if cmds == "" { |
| + cmds = "-" |
| + } |
| created, rotated, lastUsed := r.Created, r.Rotated, r.LastUsed |
| if created == "" { |
| created = "-" |
| @@ -329,8 +355,8 @@ func runServicesList(args []string) { |
| if lastUsed == "" { |
| lastUsed = "-" |
| } |
| - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", |
| - name, r.Role, app, rungs, label, created, rotated, lastUsed, r.MintedBy) |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", |
| + name, r.Role, app, rungs, cmds, label, created, rotated, lastUsed, r.MintedBy) |
| } |
| w.Flush() |
| } |
diff --git a/services_cli_test.go b/services_cli_test.go| index 9684f14e8..2b681b31e 100644 |
| --- a/services_cli_test.go |
| +++ b/services_cli_test.go |
| @@ -106,7 +106,7 @@ func TestAPISessionServiceCreateAndList(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - token, doors, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil) |
| + token, doors, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil, nil) |
| if err != nil { |
| t.Fatalf("createService: %v", err) |
| } |
| @@ -143,7 +143,7 @@ func TestAPISessionServiceCreateExactAccountID(t *testing.T) { |
| })) |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - if _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab", Exact: true}, "x", "publish", "", nil); err != nil { |
| + if _, _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab", Exact: true}, "x", "publish", "", nil, nil); err != nil { |
| t.Fatalf("createService: %v", err) |
| } |
| if gotQuery != "exact=sqid" { |
| @@ -160,7 +160,7 @@ func TestAPISessionServiceCreateDuplicateName(t *testing.T) { |
| })) |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - if _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil); err == nil || |
| + if _, _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil, nil); err == nil || |
| err.Error() != "a credential named amadan-hub already exists in this account" { |
| t.Fatalf("createService duplicate = %v, want the server's sentence", err) |
| } |
| @@ -225,7 +225,7 @@ func TestAPISessionServiceDoorsSurfaceServerErrors(t *testing.T) { |
| s := newTestSession(ts.URL) |
| acct := apiAcct{Seg: "bab"} |
| - if _, _, _, err := s.createService(context.Background(), acct, "x", "publish", "", nil); err == nil { |
| + if _, _, _, _, err := s.createService(context.Background(), acct, "x", "publish", "", nil, nil); err == nil { |
| t.Fatal("createService against a 403 must error") |
| } |
| if _, err := s.listServices(context.Background(), acct); err == nil { |
| @@ -253,7 +253,7 @@ func TestAPISessionServiceCreateUnreadableBodyTellsOperatorToRecover(t *testing. |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil) |
| + _, _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil, nil) |
| if err == nil { |
| t.Fatal("createService with an unreadable 201 body must error") |
| } |
| @@ -790,6 +790,9 @@ func TestServicesCLIUsageErrors(t *testing.T) { |
| {"services create missing role", []string{"services", "create", "-account", "bab", "amadan-hub"}, "usage: carlos services create"}, |
| {"services create bad role", []string{"services", "create", "-role", "wizard", "-account", "bab", "amadan-hub"}, "unknown role"}, |
| {"services create bad name", []string{"services", "create", "-role", "publish", "-account", "bab", "Bad Name"}, "not a usable service credential name"}, |
| + {"services create commands without run", []string{"services", "create", "-role", "publish", "-commands", "import classic", "-account", "bab", "hub"}, "--commands is only for --role run"}, |
| + {"services create run without commands", []string{"services", "create", "-role", "run", "-app", "hello", "-account", "bab", "importer"}, "--role run needs --commands"}, |
| + {"services create run with only commas", []string{"services", "create", "-role", "run", "-app", "hello", "-commands", " , ", "-account", "bab", "importer"}, "--role run needs --commands"}, |
| {"services rotate no args", []string{"services", "rotate"}, "usage: carlos services rotate"}, |
| {"services rotate bad name", []string{"services", "rotate", "-account", "bab", "Bad Name"}, "not a usable service credential name"}, |
| {"services revoke no args", []string{"services", "revoke"}, "usage: carlos services revoke"}, |
| @@ -847,7 +850,7 @@ func TestAPISessionServiceCreateCarriesRungs(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, _, gotRungs, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "ci", "deploy", "hello", []string{"edge", "canary"}) |
| + _, _, gotRungs, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "ci", "deploy", "hello", []string{"edge", "canary"}, nil) |
| if err != nil { |
| t.Fatalf("createService: %v", err) |
| } |
| @@ -871,16 +874,68 @@ func TestAPISessionServiceCreateCarriesRungs(t *testing.T) { |
| // first line names its rungs; every other role keeps today's line. |
| func TestServicesCreatePrintsTheRungsForADeployCredential(t *testing.T) { |
| buf := captureStdout(t) |
| - printServiceCreated("bab", "ci", "deploy", "hello", []string{"canary", "edge"}, "tok", nil) |
| + printServiceCreated("bab", "ci", "deploy", "hello", []string{"canary", "edge"}, nil, "tok", nil) |
| first, _, _ := strings.Cut(buf.String(), "\n") |
| if want := "minted service:bab/ci (role deploy, app hello, rungs canary, edge)"; first != want { |
| t.Fatalf("first line = %q, want %q", first, want) |
| } |
| buf2 := captureStdout(t) |
| - printServiceCreated("bab", "hub", "publish", "", nil, "tok", nil) |
| + printServiceCreated("bab", "hub", "publish", "", nil, nil, "tok", nil) |
| first, _, _ = strings.Cut(buf2.String(), "\n") |
| if want := "minted service:bab/hub (role publish, account-wide)"; first != want { |
| t.Fatalf("publish first line = %q, want %q", first, want) |
| } |
| } |
| + |
| +// TestAPISessionServiceCreateCarriesCommands: the request body carries the |
| +// command prefixes as given, the response's commands come back as the |
| +// fourth return, and a listing row's commands decode into |
| +// apiServiceView.Commands. |
| +func TestAPISessionServiceCreateCarriesCommands(t *testing.T) { |
| + var gotRaw []byte |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method == http.MethodPost { |
| + gotRaw, _ = io.ReadAll(r.Body) |
| + w.WriteHeader(http.StatusCreated) |
| + w.Write([]byte(`{"token":"t","doors":[],"rungs":[],"commands":[["import","classic"]]}`)) |
| + return |
| + } |
| + w.Write([]byte(`{"services":[{"id":"x","name":"imp","role":"run","app":"hello","rungs":[],"commands":[["import","classic"],["import","sync"]]}]}`)) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + _, _, _, gotCommands, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "imp", "run", "hello", nil, |
| + [][]string{{"import", "classic"}}) |
| + if err != nil { |
| + t.Fatalf("createService: %v", err) |
| + } |
| + if !strings.Contains(string(gotRaw), `"commands":[["import","classic"]]`) { |
| + t.Fatalf("request body = %s, want it to carry the commands", gotRaw) |
| + } |
| + if strings.Contains(string(gotRaw), `"rungs"`) { |
| + t.Fatalf("request body = %s, want no rungs for a run credential", gotRaw) |
| + } |
| + if want := [][]string{{"import", "classic"}}; !reflect.DeepEqual(gotCommands, want) { |
| + t.Fatalf("gotCommands = %v, want %v", gotCommands, want) |
| + } |
| + rows, err := s.listServices(context.Background(), apiAcct{Seg: "bab"}) |
| + if err != nil { |
| + t.Fatalf("listServices: %v", err) |
| + } |
| + if len(rows) != 1 || !reflect.DeepEqual(rows[0].Commands, [][]string{{"import", "classic"}, {"import", "sync"}}) { |
| + t.Fatalf("listServices rows = %+v", rows) |
| + } |
| +} |
| + |
| +// TestServicesCreatePrintsTheCommandsForARunCredential: a run credential's |
| +// first line names what it may run. |
| +func TestServicesCreatePrintsTheCommandsForARunCredential(t *testing.T) { |
| + buf := captureStdout(t) |
| + printServiceCreated("bab", "imp", "run", "hello", nil, [][]string{{"import", "classic"}, {"import", "sync"}}, "tok", nil) |
| + first, _, _ := strings.Cut(buf.String(), "\n") |
| + if want := "minted service:bab/imp (role run, app hello, commands import classic, import sync)"; first != want { |
| + t.Fatalf("first line = %q, want %q", first, want) |
| + } |
| +} |