service principals: design for issue #220
A console API token resolves to a Keymail address, so a server holding one inherits every account its owner joins. This is the design for a principal that is not an address: minted per account, role-scoped from a closed catalogue, rotatable and revocable, with its own rate-limit buckets. Three findings from the code that shaped it, recorded in §1a and §5: - the platform ALREADY has a non-address principal (kind='app', bound to one account and one app), so this widens an existing model rather than inventing one; - internal/caps is the wrong vocabulary to reuse — it says what a unit may do on a box, not what a caller may do at a door; - kind='app' tokens today reach domains and logs, not just the instance verbs, and titogo's router depends on the domains half — so the legacy role is defined by observation, and a test pins it. Wildcard DNS (already built, woodstar branch) removes the per-repo hostname attach a Pages hub would otherwise make, so the domains door is exempt from the service budgets rather than uplifted — with a bound kept on FAILED wildcard enables only, because those do a live DNS lookup on the request path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
1 file changed,
+414
−0
docs/superpowers/specs/2026-08-18-service-principals-design.md+414 −0
diff --git a/docs/superpowers/specs/2026-08-18-service-principals-design.md b/docs/superpowers/specs/2026-08-18-service-principals-design.md| new file mode 100644 |
| index 000000000..78579c486 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-18-service-principals-design.md |
| @@ -0,0 +1,414 @@ |
| +# Service principals for the console API |
| + |
| +Issue: carlosframework/platform#220 |
| +Date: 2026-08-18 |
| +Status: approved design, not implemented |
| + |
| +## 1. The problem |
| + |
| +`apiAccount` (`internal/console/apicli.go:91`) resolves a request to a caller |
| +in two steps: |
| + |
| +```go |
| +addr, ok := s.cliUser(w, r) // bearer -> Keymail address |
| +accts, err := s.store.AccountsFor(addr) // -> EVERY account that address joins |
| +``` |
| + |
| +A console API token *is* a person, and that person's authority is the union of |
| +their memberships evaluated at request time. That is the right model for a |
| +human at a terminal and the wrong one for a server holding a credential. |
| +amadan Pages is now such a server: a push lands and the hub creates an app, |
| +ships a `static` release, promotes, and manages a hostname, on its own behalf. |
| + |
| +Three consequences, as filed: |
| + |
| +1. **Rate limits are keyed on the human**, so a server competes with its own |
| + operator's budgets. |
| +2. **The credential's reach is unbounded and grows without the holder acting** — |
| + add the address to another account next month and every server holding its |
| + token silently gains that account. |
| +3. **The workaround is a person-shaped hole with a server pushed into it** — a |
| + dedicated Keymail identity per service, kept single-account by discipline. |
| + |
| +### 1a. What the issue got wrong, and it matters |
| + |
| +Two corrections found while reading the code. Both change the design. |
| + |
| +**The platform already has a non-address principal.** `cli_tokens` has carried |
| +a `kind` column since the instances work. `kind='app'` tokens are minted by a |
| +member (`NewAppToken`, `authstore.go:427`), bound to one `account_id` and one |
| +`app`, never expire, resolve through `CLIAppToken` — which refuses to answer |
| +the person door at all — audit as `app:<sqid>/<app>`, and already hold their |
| +own token-keyed rate bucket (`cliInstanceBurst`). `apiInstancePrincipal` |
| +(`apiinstances.go:70`) is, almost line for line, the gate the issue sketches. |
| + |
| +So the gap is not that no service principal exists. It is that the one that |
| +exists is bound to a single **app**, and amadan Pages' first call is |
| +`apps/{account}/new` — the subject is the account, not an app. |
| + |
| +**`internal/caps` is the wrong vocabulary to reuse.** The issue suggests it as |
| +an existing closed vocabulary. It is not the same kind of thing: `caps` |
| +describes what a *unit may do on a box* — systemd directives, privileged |
| +ports, database paths, the `host-role` grant an operator types on the machine. |
| +This design needs a vocabulary for what an *API caller may do at a door*. |
| +Fusing them would couple a manifest format to a permission model and give both |
| +a reason to change that has nothing to do with the other. A separate package, |
| +`internal/svcrole`, with no shared type. |
| + |
| +**Ship is already safe.** The issue lists `cliShipBurst` among the collisions. |
| +It is keyed on the token hash, not the address, and `apicli.go:976` states the |
| +reason: "a laptop and a CI runner sharing one human's identity share one |
| +shipping budget between them, which is the limit doing less than it should." |
| +A service holding its own token already gets its own ship budget today. The |
| +two limits that actually collide are `create` and `write`. |
| + |
| +## 2. The principal |
| + |
| +A service principal is a row in `cli_tokens`, not a new table. Three additive |
| +columns join the existing `kind`/`account_id`/`app` set, each defaulting to |
| +what every existing row already means — the idiom the `kind` columns used: |
| + |
| +```sql |
| +ALTER TABLE cli_tokens ADD COLUMN role TEXT NOT NULL DEFAULT ''; |
| +ALTER TABLE cli_tokens ADD COLUMN name TEXT NOT NULL DEFAULT ''; |
| +ALTER TABLE cli_tokens ADD COLUMN revoked_at INTEGER NOT NULL DEFAULT 0; |
| +``` |
| + |
| +`name` is the stable handle in `service:<sqid>/<name>`; it survives rotation, |
| +which is what makes rate-limit keys and audit rows continuous across a secret |
| +change. `revoked_at` lets rotate keep the name while retiring the secret. |
| + |
| +Three kinds, one resolver: |
| + |
| +| `kind` | principal | scope | role | |
| +|---|---|---|---| |
| +| `''` | a person | their memberships, resolved per request | — authority is membership | |
| +| `'app'` | legacy instance token | `account_id` + `app` | reads as `instance` (§5) | |
| +| `'service'` | service principal | `account_id`, optionally + `app` | explicit, from the catalogue | |
| + |
| +`CLITokenAddress` keeps refusing anything that is not `kind=''`, unchanged: a |
| +service credential presented to a person's door stays indistinguishable from a |
| +token that does not exist. |
| + |
| +A new `Store.CLIPrincipal(raw, now)` returns a typed value: |
| + |
| +```go |
| +type Principal struct { |
| + Kind PrincipalKind // user | service |
| + Address string // person: the caller. service: minted_by, a fact only |
| + AccountID int64 |
| + AccountSqid string |
| + App string // "" for account-scoped |
| + Role string // "" for a person |
| + Name string // "" for a person |
| +} |
| +``` |
| + |
| +`CLIAppToken` becomes a thin wrapper over `CLIPrincipal` for the transition and |
| +is then deleted. |
| + |
| +The audit actor is `service:<sqid>/<name>`, the same shape as today's |
| +`app:<sqid>/<app>` and for the same reason: a row written by a machine must |
| +never read as a row written by the human who minted its token. The `address` |
| +column keeps its name and becomes semantically `minted_by` — recorded as a |
| +fact, never as the owner. |
| + |
| +### 2a. Where a credential lives |
| + |
| +Account-scoped, not person-scoped. Today's app tokens list under `address` on |
| +the minter's `/settings`, which is half of what issue #220 objects to: an owner |
| +leaving orphans a credential nobody can find. Service principals list on the |
| +account, and any owner of that account can rotate or revoke. |
| + |
| +Absorbing the legacy rows therefore moves them out of a person's page. To keep |
| +nothing from silently vanishing, the minter's `/settings` keeps showing |
| +credentials they minted, read-only, with "lives on account `<sqid>` — manage |
| +there". |
| + |
| +## 3. The role catalogue |
| + |
| +Package `internal/svcrole`. Four roles, closed set. |
| + |
| +| role | doors | |
| +|---|---| |
| +| `publish` | apps new · channels · releases (ship) · promote · rollback · releases list · tags · version-target · retention · domains list/attach/detach · apps delete/restore | |
| +| `instance` | the legacy app-token set, defined by observation — see §5 | |
| +| `operate` | restart · config get/put/sync · logs | |
| +| `admin` | `publish` ∪ `instance` ∪ `operate` ∪ ledgers ∪ store ∪ place | |
| + |
| +Two families no role ever reaches. This is the escalation cut and the reason |
| +the catalogue can stay small: |
| + |
| +- **Every mint door** — `instance-token`, and the service create/rotate/revoke |
| + doors of §6. A credential can never mint or rotate a credential, including |
| + itself. Without this, a `publish` service quietly becomes an `admin` one. |
| +- **`fleets/*`** — enrolling a box is infrastructure, owner-with-passkey work. |
| + A service holding a bearer token has no business attaching machines. |
| + |
| +`store` sits in `admin` only. Issue #220 names `store grant` as the thing a |
| +publishing credential should not reach, and it is a genuine authority transfer: |
| +it mints a path-scoped IAM user. `admin` is therefore not "everything |
| +harmless", and the CLI's `create` output says so in words. |
| + |
| +`publish` carries the wildcard/catchall flip because that flip is a field on |
| +the attach body (§4), not a door of its own. A credential that already owns a |
| +claim changing the TLS posture of a name it holds is not a new authority. |
| + |
| +### 3a. Enforcement is structural |
| + |
| +All ~50 `/api/cli/` registrations live in one function |
| +(`authcli.go:219-329`). That function stops calling `mux.HandleFunc` and calls: |
| + |
| +```go |
| +s.cliRoute("POST /api/cli/apps/{account}/new", svcrole.Allow(svcrole.Publish), s.handleAPICreateApp) |
| +``` |
| + |
| +`Allow` names the roles that reach the door *directly*. `admin` is never |
| +listed: it is defined as the union of the others plus ledgers/store/place, so |
| +`svcrole.Allow` resolves it from the catalogue rather than from ~50 hand-typed |
| +repetitions that could disagree with the definition. A door reachable ONLY by |
| +`admin` — the store family, ledgers, place — says `svcrole.Allow(svcrole.Admin)` |
| +explicitly. |
| + |
| +`cliRoute` wraps the handler with the role gate, so a door registered without |
| +naming its roles does not compile. Two tests hold the line: |
| + |
| +- every `/api/cli/` pattern in the registered set went through `cliRoute`; |
| +- the union of all role bindings contains no mint door and no `fleets/*` door. |
| + |
| +Default-deny falls out of this: a door added tomorrow is unreachable by every |
| +service principal until someone puts it in a role deliberately. |
| + |
| +### 3a-i. The two doors the table above does not cover |
| + |
| +Reading the registration list turned up two families that need a stated rule |
| +rather than an inferred one. |
| + |
| +**`GET /api/cli/apps`** has no `{account}` segment: it is the door `carlos |
| +apps` uses to list what the caller can see, and for a person that is every app |
| +in every account they belong to. A service principal has no such set. It |
| +answers with **exactly its own scope** — the one account it is bound to, or the |
| +single app if it is app-scoped — and never enumerates anything else. It is |
| +readable by every role, because a credential being able to see the thing it is |
| +already bound to discloses nothing it does not already hold. This is the one |
| +door whose *answer* is scoped rather than whose access is gated. |
| + |
| +**The `/auth/cli/` family** — `whoami`, `revoke`, `tokens/revoke`, |
| +`accounts` (which creates an ACCOUNT), and the `/settings` page — is outside |
| +the `/api/cli/` prefix and therefore outside `cliRoute`. It needs no role |
| +bindings and gets none: every one of those handlers gates on `cliUser`, which |
| +answers only for `kind=''`, so a service credential meets the same 401 a |
| +fabricated token meets. That is not an accident to preserve by luck. A test |
| +asserts that no handler outside `/api/cli/` resolves a bearer through anything |
| +but `cliUser`, so the day someone reaches for `CLIPrincipal` over there, the |
| +build says so. |
| + |
| +Default-deny therefore has two halves: inside `/api/cli/`, a door is |
| +unreachable until `cliRoute` names a role; outside it, a door is unreachable |
| +because `cliUser` refuses the principal outright. |
| + |
| +### 3b. Gate order |
| + |
| +Unchanged in shape from `instanceGate`, with one step inserted: |
| + |
| +``` |
| +bearer -> principal -> scope match (404) -> role (403) -> budget (429) -> handler |
| +``` |
| + |
| +Scope mismatch stays the byte-identical 404 the family pins: for a person that |
| +is the membership lookup, for a service the sqid-exact path match |
| +(`apiInstancePrincipal`'s existing rule — a service holds no memberships, so |
| +there is no set within which to resolve a name, and resolving globally then |
| +comparing would be a name-collision oracle). |
| + |
| +Role refusal is a **403 that names the role**: |
| + |
| +``` |
| +service:bdm/amadan-hub holds role "publish"; this door needs "operate" |
| +``` |
| + |
| +That is not a leak. The caller has already proved it holds this account, so |
| +the only thing disclosed is its own configuration. A silent 404 there would |
| +cost an afternoon of debugging for every operator who ever mis-scopes a |
| +credential, and buy nothing. |
| + |
| +## 4. Rate limits |
| + |
| +Both address-keyed limiters change from `addr` to `principalKey`: |
| + |
| +```go |
| +func principalKey(p Principal) string { |
| + if p.Kind == PrincipalUser { |
| + return p.Address // unchanged for people, byte for byte |
| + } |
| + return "service:" + p.AccountSqid + "/" + p.Name |
| +} |
| +``` |
| + |
| +Keyed on the **name**, not the token hash, so rotating a credential does not |
| +hand it a fresh budget and a rotation mid-flood buys nothing. No human's |
| +limits move. |
| + |
| +| limiter | person | service | |
| +|---|---|---| |
| +| create | 10 / day | 100 / day | |
| +| write | 30 / min | 120 / min | |
| +| ship | 10 / hour (per token) | 60 / hour | |
| +| instance | 60 / min | 60 / min (unchanged) | |
| +| domains attach/detach | instance budget | exempt (§4a) | |
| + |
| +The service numbers are a judgement calibrated on "100 repos enabling Pages in |
| +a day" and "a push every 30 seconds sustained", not a derivation. |
| + |
| +`cliCreateBurst`'s existing comment warns that its keys are real member |
| +addresses accumulating for a whole day, and that >8192 distinct keys trips |
| +`maxRateKeys`' wholesale map drop. Service principals add keys to that same |
| +map. Still orders of magnitude away for this fleet, and the wholesale drop |
| +fails open on limits rather than closed on service — recorded, not defended, |
| +exactly as today. |
| + |
| +### 4a. Wildcard DNS is not rate limited |
| + |
| +Wildcard already exists end to end and predates this design (woodstar |
| +branch): `handleAPIDomainAttach` takes `wildcard`/`catchall` in the attach |
| +body (`apidomains.go:170`), `setDomainCapabilities` gates the false->true |
| +transition on a configured delegation zone plus a live `_acme-challenge` |
| +CNAME, and a `Wildcard+Catchall` claim contributes a `*.<host>` route alias |
| +(`internal/domains/aliases.go:6`). |
| + |
| +This changes the budget question. A Pages hub with wildcard DNS does not |
| +attach a hostname per repo at all: one claim on `pages.<domain>` with both |
| +flags, and every repo's subdomain serves off the catchall. The per-repo |
| +domains traffic that would have justified a service-scale domains budget does |
| +not exist. So the door is exempt rather than uplifted. |
| + |
| +One hazard the exemption has to survive. The CNAME check is a **live, |
| +sequential DNS round trip on the request path**, and it runs only on the |
| +enabling transition. A *succeeding* wildcard enable is therefore genuinely |
| +once-per-domain and safe to exempt. A *failing* one — the CNAME is not there |
| +yet — can be retried forever, and each retry blocks a console goroutine on |
| +outbound DNS. Therefore: |
| + |
| +- the door is exempt from the write and instance budgets; |
| +- a separate small bound applies to **failed enables only**: 20/min per |
| + principal, roomy enough for a resolver blip, closed enough that an open door |
| + is not a way to make the console do unbounded DNS work. |
| + |
| +Idempotent re-attaches — which titogo's router performs on every reload pass — |
| +touch neither, because `enabling` is a false->true transition and a re-send of |
| +`wildcard:true` on an already-wildcard claim skips the lookup entirely. |
| + |
| +## 5. Compatibility: what `kind='app'` really reaches |
| + |
| +**The legacy role is defined by observation, not by its name.** Reading the |
| +call sites rather than the name shows an app token today reaches more than the |
| +instance verbs: |
| + |
| +``` |
| +instances manifest · list · create · get · update · delete apiinstances.go |
| +domains list · attach · detach apidomains.go:154, 391, 473 |
| +logs read apilogs.go:48 |
| +``` |
| + |
| +titogo's router uses its `CARLOS_INSTANCE_TOKEN` for the domains half — it |
| +re-attaches every hostname on every reload pass. A tidy `instance` role meaning |
| +"the instance verbs" would 403 that router on the next console deploy. |
| + |
| +So `instance` is defined as exactly the set above. Not `instance-token`: a |
| +credential cannot mint a credential, and it cannot today either |
| +(`handleAPIInstanceToken` gates on `apiAccountApp` directly, which refuses app |
| +tokens at `cliUser`). |
| + |
| +The role table carries a comment saying the domains and logs entries are |
| +compatibility, discovered from the call sites, and that removing either is a |
| +breaking change for titogo. A test pins it: walk every door reachable through |
| +`instanceGate`/`apiInstancePrincipal` and assert the `instance` role covers |
| +precisely that set, so drift is a build failure rather than an incident. |
| + |
| +`apiInstancePrincipal` then collapses into the general gate, and |
| +`instanceGate` becomes the general gate with the instance budget bound to it. |
| + |
| +## 6. Surfaces |
| + |
| +### API |
| + |
| +Owner-gated. Never reachable by a service principal (§3). |
| + |
| +``` |
| +POST /api/cli/services/{account} create -> secret, once |
| +GET /api/cli/services/{account} list |
| +POST /api/cli/services/{account}/{name}/rotate -> new secret, once |
| +POST /api/cli/services/{account}/{name}/revoke |
| +``` |
| + |
| +Modelled on `fleets.go`: the same owner check, the same mint-once discipline |
| +(the plaintext is shown at mint and rotate and nowhere else, because only the |
| +hash is stored), the same `apiFleet`-style byte-identical refusals. |
| + |
| +### CLI |
| + |
| +New `services.go` at the repo root, alongside `fleets.go`: |
| + |
| +``` |
| +carlos services create -account bdm -name amadan-hub -role publish [-app X] |
| +carlos services list [-account bdm] |
| +carlos services rotate -account bdm -name amadan-hub |
| +carlos services revoke -account bdm -name amadan-hub |
| +``` |
| + |
| +`create` and `rotate` print the secret once with the "you will not see this |
| +again" framing `fleets add-box` uses. Per the no-infra rule (CLAUDE.md), each |
| +command completes and verifies its own job with no box access: `create` echoes |
| +the resolved role's door list, so an operator sees what they granted without |
| +reading this document. |
| + |
| +### Console |
| + |
| +`/a/{sqid}/settings` gains a **Service credentials** pane: name, role, scope, |
| +last used, minted-by, and rotate/revoke. Owner-gated, CSRF, `requireFreshAuth` |
| +last — the ordered gate this repo pins (session -> CSRF -> owner 404 -> |
| +fresh auth). Revocation is the emergency control, so it belongs where someone |
| +in a hurry will look, and it must work without a terminal. |
| + |
| +## 7. Testing |
| + |
| +Following the neutrality-test shape `internal/caps/neutrality_test.go` and |
| +`apicli_test.go` already establish. |
| + |
| +- **Role table**: every `/api/cli/` route has exactly one binding; no role |
| + reaches a mint door or a `fleets/*` door; `admin` is the union it claims. |
| +- **Legacy pin**: the `instance` role covers precisely the `instanceGate` / |
| + `apiInstancePrincipal` call-site set (§5). |
| +- **Principal resolution**: a service token at a person's door is a 401 |
| + indistinguishable from a fabricated one; a person's token still resolves |
| + through memberships unchanged; a service naming an account it is not bound |
| + to gets the byte-identical 404; resolution stays sqid-only for services. |
| +- **Refusal ordering**: scope before role, so a 403 never reveals that an app |
| + exists in an account the caller does not hold. |
| +- **Rate limits**: service keys never touch person keys; rotation does not |
| + reset a budget; a failing wildcard enable is bounded, a succeeding one is |
| + not; an idempotent re-attach touches neither. |
| +- **Migration**: a `cli_tokens` row written before this change reads back with |
| + identical authority, asserted against a fixture database. |
| + |
| +## 8. Risks carried |
| + |
| +1. **`admin` includes `store grant`**, which mints an IAM user. It is the |
| + honest place for it, but `admin` is a real handover, not a convenience |
| + bundle. Stated in the CLI output, not just here. |
| +2. **Deploy ordering.** The role gate ships closed, so any mis-bound door is a |
| + 403 for a live caller — titogo's router the sharp one. The §5 pinned-set |
| + test is the defence; deploy tito first, as it is the deployment with a real |
| + app-token consumer, and verify the router's re-attach pass before flagship. |
| +3. **The account-page move.** Legacy app tokens leave their minter's |
| + `/settings` as the authority. The read-only mirror keeps them visible, but |
| + it is a change to a page people know. |
| + |
| +## 9. Out of scope |
| + |
| +- **Scoped signing keys** — issue #26, a different axis (what a release is |
| + signed with, not who the API thinks is calling). |
| +- **Roles for people.** A person's authority stays their membership set. This |
| + design adds no role column meaning to `kind=''` rows. |
| +- **Cross-account service principals.** A credential is bound to one account |
| + at mint. A hub serving two tenants holds two credentials, deliberately. |