// Service principals: the console API's non-address caller. // // A console API token has always resolved to a Keymail address, and an // address's authority is the union of its memberships evaluated at request // time (apiAccount). That is right for a human at a terminal and wrong for a // server holding a credential: the reach grows without the holder acting, and // the rate limits are the operator's own. This file is the principal that is // not an address. // // It WIDENS an existing model rather than inventing one. cli_tokens has // carried kind='app' — bound to one account and one app, minted by a member, // never expiring — since the instances work, and apiInstancePrincipal has // gated six doors on it. A legacy row reads back here as an app-scoped // service principal holding role `instance`, which is defined by observation // of what those tokens reach TODAY (instances, domains, logs — spec §5), not // by what the word sounds like. // // Spec: docs/superpowers/specs/2026-08-18-service-principals-design.md §2. 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" ) // PrincipalKind separates the two things a bearer token can be. It is a typed // constant rather than a string compare at each call site so that "is this a // person?" cannot be written two subtly different ways in two files. type PrincipalKind int const ( PrincipalUser PrincipalKind = iota PrincipalService ) // Principal is one resolved caller. // // Address is the CALLER for a person and the MINTER for a service — a fact // recorded so a human stays answerable, never an authority. Nothing in the // gate path may read a service's Address as a set of memberships; that // mistake is exactly what issue #220 is about. type Principal struct { Kind PrincipalKind Address string AccountID int64 App string // "" means account-scoped Role svcrole.Role Name string // "" for a person and for legacy kind='app' rows // Rungs is a deploy credential's scope, in ladder order. nil for every // other role, and never empty for a deploy principal: CLIPrincipal // refuses a deploy row it cannot read a scope from. Rungs []string // 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 // already holds a live credential of that name. var ErrServiceNameTaken = errors.New("console: a service credential of that name already exists") // ErrNoServicePrincipal is returned by RotateServicePrincipal, // RevokeServicePrincipal and RevokeAccountToken when nothing matched — // zero rows affected, not a database failure. A door needs to tell the two // apart (404 vs 500) without string-matching an error, which an anonymous // errors.New at each call site would force it to do. var ErrNoServicePrincipal = errors.New("console: no such credential") // CLIPrincipal resolves a bearer token to whichever kind of caller minted it. // ok is false for unknown, revoked, expired tokens, and — for a kind='service' // row — for a role name this binary does not recognise: a caller that reads // "unparseable" as "no authority" would still be wrong if this returned a // role-less service principal instead of refusing outright, so it refuses. func (s *Store) CLIPrincipal(rawToken string, now time.Time) (Principal, bool) { if rawToken == "" { return Principal{}, false } hash := hashToken(rawToken) 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, credential_id, commands FROM cli_tokens WHERE token_hash = ?`, hash). Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs, &credID, &commands) if err != nil { return Principal{}, false } if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 { return Principal{}, false } if revoked != 0 { return Principal{}, false } // Expiry, and it is NOT one rule for every kind. // // For an app or service row, expires_at = 0 means never: both minters // write it deliberately (NewAppToken and NewServicePrincipal both // document the trade — a production path that dies on day 91 is worse // than a long-lived credential whose rotation is one command, and // revocation is the control). // // For a PERSON's row, 0 means the Unix epoch, which is to say expired, // exactly as CLITokenAddress has always read it. NewCLIToken always // stamps now+CLITokenTTL so no such row exists today — but this // function is on its way to becoming the ONLY resolver, cliUser // included, and at that moment its reading of 0 becomes the person's // expiry rule. A kind that never expires is a decision each minter // makes; inheriting it by accident would turn any user row that ever // carried 0 into an immortal credential. if kind == cliKindUser { if now.Unix() > expires { return Principal{}, false } } else if !(expires == 0 || now.Unix() <= expires) { return Principal{}, false } var p Principal switch kind { case cliKindUser: // A person carries no role and no scope — nothing else here, ever. p = Principal{Kind: PrincipalUser, Address: addr} case cliKindApp: // 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, CredentialID: credID} case cliKindService: r, ok := svcrole.Parse(role) if !ok { return Principal{}, false } var scope []string if r == svcrole.Deploy { // Fail closed: a deploy row with no readable scope, or with no // app, is not treated as unscoped (spec §5.5). scope, ok = parseStoredRungs(rungs) if !ok || app == "" { return Principal{}, false } } else if rungs != "" { // Rungs on any other role would read as a limit and enforce // nothing; a row this console did not write does not resolve. return Principal{}, false } 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 } // Best-effort: "last used" is for the owner's benefit on the tokens page, // and a failed write must not fail the request it is annotating. s.db.Exec(`UPDATE cli_tokens SET last_used = ? WHERE token_hash = ?`, now.Unix(), hash) return p, true } // ErrAccountScopedService is a Service credential minted without an app. It is // its own sentinel because the mint doors match sentinels rather than strings, // and a scope refusal is the caller's to fix while a busy database is not. var ErrAccountScopedService = errors.New("console: a service-role credential must be bound to one app") // ErrAccountScopedDeploy is a deploy credential minted without an app. var ErrAccountScopedDeploy = errors.New("console: a deploy credential must be bound to one app") // ErrDeployNeedsRungs is a deploy credential minted with no rungs. There is // no default set: the minter says what the credential may move. var ErrDeployNeedsRungs = errors.New("console: a deploy credential needs at least one rung") // ErrRungsNeedDeploy is rungs named for any other role, where they would // read as a limit and enforce nothing. var ErrRungsNeedDeploy = errors.New("console: only a deploy credential takes rungs") // 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 // it: it goes in `address`, the same field NewAppToken uses for the same // reason — the credential speaks for a service, but a human stays // answerable for it. // // name must be non-empty. An empty name is not one a caller chose — it is the value // every legacy kind='app' row already carries by default, and both // RotateServicePrincipal and RevokeServicePrincipal refuse to match it for // exactly that reason (a call with an empty name must never reach a legacy // row it did not name). Minting a kind='service' row with an empty name would // create a credential neither of those doors could ever address again except // by RevokeAccountToken's raw id, so the store refuses it at the door // instead of producing an unaddressable row later. Grammar validation for // what a GOOD name looks like stays at the console API (Task 6); this is // only "not empty". // // The token never expires (expires_at = 0), the same deliberate trade // NewAppToken documents: a production path that silently dies on day 91 is a // worse failure than a long-lived credential whose rotation is one command. // Revocation is the control. // // rotated_at is stamped equal to created_at at mint: the current secret // began exactly when the credential did. RotateServicePrincipal advances it // on every later rotation without touching created_at. 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, nil, mintedBy, now) } // NewServicePrincipalScoped is NewServicePrincipal with a rung scope, which // 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") } if _, ok := svcrole.Parse(string(role)); !ok { return "", fmt.Errorf("console: unknown service role %q", role) } // A Service credential must name one app. // // apiAccountAppBy binds a principal to its app only when p.App != "", so an // account-scoped credential reaches every app in the account — which is // tolerable for a role a person mints for their own tooling and not for one // an APP holds at runtime to spend the deployment's CPU on its own data. // // The check lives here rather than in a handler because there are two mint // doors, the API and the browser form, and the form explicitly offers an // account-wide credential. A handler-level check would leave one of them // open. if role == svcrole.Service && app == "" { return "", ErrAccountScopedService } canon, err := canonicalRungs(rungs) if err != nil { return "", err } if role == svcrole.Deploy { if app == "" { return "", ErrAccountScopedDeploy } if canon == "" { return "", ErrDeployNeedsRungs } } else if canon != "" { return "", ErrRungsNeedDeploy } // 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 } defer tx.Rollback() var count int if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind = ? AND account_id = ? AND name = ? AND revoked_at = 0`, cliKindService, accountID, name).Scan(&count); err != nil { return "", err } if count > 0 { return "", ErrServiceNameTaken } raw, err := randomToken() if err != nil { 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, 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 { return "", err } return raw, nil } // ServicePrincipals lists one account's live service credentials, newest // first: kind='service' rows AND legacy kind='app' rows, normalized by // scanCLITokenRows to the instance role they have always held. Absorbing the // legacy kind into this model must not make it invisible to the account it // is bound to. // // revoked_at = 0: a revoked row is a tombstone (see RevokeServicePrincipal), // not history to display — it stays out of this listing the same way it // 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, 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 { return nil, err } return scanCLITokenRows(rows) } // 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. 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 // old is the secret in use right now" — which is why now is taken as a // parameter here rather than read from the clock inline: this write, like // every other Store method's, is testable without sleeping. Zero rows // affected — including a call with name="" — is ErrNoServicePrincipal, which // the door renders as 404. // // The name != "" guard excludes a legacy kind='app' row, whose name column // is always empty: without it, RotateServicePrincipal(acctID, "") would match that // row (or, if the account somehow held more than one, all of them) and // rotate a credential the caller never named. func (s *Store) RotateServicePrincipal(accountID int64, name string, now time.Time) (string, error) { raw, err := randomToken() if err != nil { return "", err } res, err := s.db.Exec(`UPDATE cli_tokens SET token_hash = ?, rotated_at = ?, last_used = 0 WHERE kind = ? AND account_id = ? AND name = ? AND name != '' AND revoked_at = 0`, hashToken(raw), now.Unix(), cliKindService, accountID, name) if err != nil { return "", err } n, err := res.RowsAffected() if err != nil { return "", err } if n == 0 { return "", ErrNoServicePrincipal } return raw, nil } // RevokeServicePrincipal soft-revokes a live, NAMED credential: UPDATE, not // DELETE. The row is kept as a TOMBSTONE, not as history for a listing to // show — nothing lists a revoked row (ServicePrincipals and CLITokens both // filter revoked_at = 0). It is kept for two reasons: so CLIPrincipal goes on // refusing this exact secret rather than a gap in the table quietly // resolving it again, and so a later NewServicePrincipal minting the same // name creates a NEW row rather than resurrecting this one's identity — // the unique index (cli_tokens_service_name) is scoped to live rows for // exactly that reason. The same name != "" guard applies here, for // the same reason RotateServicePrincipal needs it. func (s *Store) RevokeServicePrincipal(accountID int64, name string, now time.Time) error { res, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ? WHERE kind = ? AND account_id = ? AND name = ? AND name != '' AND revoked_at = 0`, now.Unix(), cliKindService, accountID, name) if err != nil { return err } n, err := res.RowsAffected() if err != nil { return err } if n == 0 { return ErrNoServicePrincipal } return nil } // RevokeAccountToken soft-revokes one credential by its listing id (the // token hash), scoped to the ACCOUNT rather than to the address that minted // it — the universal handle: it is the only path that works for every row // the account pane lists, service-named or legacy-nameless alike, so that // pane needs exactly one Revoke button rather than one per kind. // RevokeCLIToken scopes by address instead, which is right for a person // killing their own terminal — but a legacy kind='app' row has no name to // address by RevokeServicePrincipal, so this is the only handle it has, and // an owner revoking a credential a colleague minted needs the account, not // the minter's address, as the scope. // // The broad query (no kind filter) is not a hazard: a person's cli_tokens // row always carries account_id = 0 (see CLIToken), so an account-scoped // revoke can never reach a human's terminal token — there is no account id // for it to match. That is also exactly why accountID == 0 is refused below // rather than merely producing a query that happens to match nothing: the // same fact that makes the broad query SAFE for every real caller makes a // zero-value accountID CATASTROPHIC rather than harmless — a caller that // reached this door with an unset or mis-looked-up account id, holding any // person's own token_hash as `id` (a listing id, not a secret — see // RevokeCLIToken), would revoke that PERSON's terminal token instead of a // credential the account actually owns. Refusing it here makes that // precondition structural, not a caller obligation. func (s *Store) RevokeAccountToken(accountID int64, id string, now time.Time) error { if accountID == 0 { return ErrNoServicePrincipal } res, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ? WHERE token_hash = ? AND account_id = ? AND revoked_at = 0`, now.Unix(), id, accountID) if err != nil { return err } n, err := res.RowsAffected() if err != nil { return err } if n == 0 { return ErrNoServicePrincipal } return nil } // ---- rate limits keyed on the principal (Task 5, issue #220 consequence 1) // principalKey is what a rate limiter counts against. // // A person keys on their ADDRESS, byte for byte as before — no human's // limits move in this change. A NAMED service keys on its NAME, not on its // token hash, so that rotating a credential does not hand it a fresh // budget: the name is stable across a secret change precisely so budgets // and audit rows stay continuous. // // A legacy kind='app' row carries no name (Name == ""), and mirrors // auditActor's own branch for exactly the same row: without this branch, // EVERY app token in an account — whichever app it is bound to — would // collapse onto the single key "service:/", sharing one budget between // credentials that have nothing to do with each other. Concretely: titogo's // routers each hold their own app token, several bound to different apps in // the same account; a mistyped CNAME on one router's wildFail budget must // not 429 every other router's wildcard enable. Keying on App instead of // Name gives each of them back its own bucket, the same scope App already // carries for authorization. // // The account id rather than the sqid because this string is never shown to // anyone — it is a map key, and the id is what the resolver already has on // the Principal without a further lookup. func principalKey(p Principal) string { if p.Kind == PrincipalUser { return p.Address } if p.Name == "" { return "app:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.App } return "service:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.Name } // allowCreate, allowWrite and allowShip pick the person or the service // limiter from the principal, so that no call site has to remember which // one it is talking to — a call site that got it wrong would be a server // drawing down an operator's budget, which is the bug this whole change is // about. func (s *Server) allowCreate(p Principal, now time.Time) bool { if p.Kind == PrincipalUser { return s.cli.create.allow(principalKey(p), now) } return s.cli.svcCreate.allow(principalKey(p), now) } // allowTemporaryCreate is /new-temporary's budget: one limiter for both // principal kinds, keyed like allowCreate (principalKey), separate from it // so neither kind of create spends the other's day. func (s *Server) allowTemporaryCreate(p Principal, now time.Time) bool { return s.cli.tempCreate.allow(principalKey(p), now) } func (s *Server) allowWrite(p Principal, now time.Time) bool { if p.Kind == PrincipalUser { return s.cli.write.allow(principalKey(p), now) } return s.cli.svcWrite.allow(principalKey(p), now) } // allowShip is the one picker that does NOT key a person on principalKey: // the person branch keeps handleAPIShip's existing key, the TOKEN HASH, not // the address — a laptop and a CI runner sharing one human's identity share // one shipping budget between them otherwise, which is the limit doing less // than it should. That reasoning is unchanged by this task; only the // service branch is new. // // It is also the one picker that reports HOW LONG a refusal lasts, because // ship is the one budget a legitimate caller meets in a loop (an agent // iterating ship-test-ship). The limiter and its key are resolved together // and the wait is read from THAT limiter — a caller that picked the wait // from the other one would be told a number belonging to a budget it does // not spend. The wait is zero, and meaningless, when ok is true. func (s *Server) allowShip(p Principal, r *http.Request, now time.Time) (ok bool, wait time.Duration) { lim, key := s.cli.svcShip, principalKey(p) if p.Kind == PrincipalUser { lim, key = s.cli.ship, hashToken(bearer(r)) } if lim.allow(key, now) { return true, 0 } return false, lim.resetIn(key, now) } // allowWildFail and wildFailTripped are wildFail's own picker pair — the // task's premise is that no call site reaches into s.cli by hand to choose // a limiter, and wildFail is no exception even though it is a single // counter shared by every principal kind rather than a person/service pair // like the three above. // // wildFail's caller needs BOTH a peek and a consume (C33 review): a live, // sequential DNS round trip on the request path must not run at all once a // principal has already tripped the bound, but only a FAILED lookup may // ever add to the count — a caller whose CNAME is correct must never // accumulate toward it no matter how many times it (harmlessly) succeeds. func (s *Server) allowWildFail(p Principal, now time.Time) bool { return s.cli.wildFail.allow(principalKey(p), now) } func (s *Server) wildFailTripped(p Principal, now time.Time) bool { return s.cli.wildFail.tripped(principalKey(p), now) } // allowDomains is the attach/detach convergence-rate ceiling // (domainsCeilingBurst) — not a workload budget (domains doors are exempt // from write/instances, spec §4a), a cap on how fast one credential can // make every box look at control/serial again. One limiter for every // principal kind, the same shape as allowWildFail and for the same reason: // the exemption this ceiling sits behind never depended on person vs. // service, so the ceiling behind it does not either. func (s *Server) allowDomains(p Principal, now time.Time) bool { return s.cli.domainsCeiling.allow(principalKey(p), now) } func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (claimResult0 string, claimResult1 error) { return s.NewServicePrincipalContext(context.Background(), accountID, name, app, role, mintedBy, now) } func (s *Store) NewServicePrincipalContext(claimContext context.Context, accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (claimResult0 string, claimResult1 error) { claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.NewServicePrincipal", func(claimView *Store) error { claimResult0, claimResult1 = claimView.claimRawNewServicePrincipal(accountID, name, app, role, mintedBy, now) return claimResult1 }) return } 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, 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, commands, mintedBy, now) return claimResult1 }) return }