carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev bede3194e46fcf7fe45ff11c42a5aa34d72ef193 parent 151c946
16 files changed, +1072 −81
  • apimode.go +18 −13
  • internal/console/apiservices.go +71 −9
  • internal/console/authstore.go +86 −6
  • internal/console/deploycred_test.go +5 −5
  • internal/console/deploydoors_test.go +1 −1
  • internal/console/deploymixed_test.go +1 −1
  • internal/console/deployscope_test.go +1 −1
  • internal/console/deploysign_test.go +2 −2
  • internal/console/svcpage.go +41 −5
  • internal/console/svcprincipal.go +126 −18
  • internal/console/svcprincipal_run_test.go +416 −0
  • internal/console/templates/account_settings.html +5 −2
  • internal/runs/commands.go +101 −0
  • internal/runs/commands_test.go +99 −0
  • services.go +36 −10
  • services_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)
+ }
+}