carlos / platform Public

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
Paul Campbell b607af73a7592b199fefa7265cb55962e3923dfb parent 59d2d43
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.