service principals: check role before scope
A role refusal reads neither the account segment nor the database — it is a constant function of the door and the caller's own role, so it leaks nothing whatever the segment names. Checking scope first would instead make the 404/403 pair a membership oracle: 404 for an account you do not hold, 403 for one you do. Role-first is also the only order enforceable structurally: it runs in the cliRoute wrapper before the handler is entered, rather than inside ~50 handlers after apiAccount returns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016UimU7fAfeAoBMm6HKCPwQ
1 file changed,
+31
−13
docs/superpowers/specs/2026-08-18-service-principals-design.md+31 −13
diff --git a/docs/superpowers/specs/2026-08-18-service-principals-design.md b/docs/superpowers/specs/2026-08-18-service-principals-design.md| index 78579c486..2ebfa250a 100644 |
| --- a/docs/superpowers/specs/2026-08-18-service-principals-design.md |
| +++ b/docs/superpowers/specs/2026-08-18-service-principals-design.md |
| @@ -209,28 +209,46 @@ 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 |
| +bearer -> principal (401) -> role (403) -> scope match (404) -> 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 is checked BEFORE scope**, which reverses this design's first draft. The |
| +reversal is both safer and simpler, and the reasoning is worth keeping because |
| +the instinct runs the other way. |
| + |
| +A role refusal is a **constant function of two things the caller already |
| +knows**: which door it dialled, and which role its own credential holds. It |
| +does not read the account segment, does not touch the database, and answers |
| +identically whether the named account exists, belongs to someone else, or |
| +belongs to the caller. There is therefore nothing for it to leak. Checking |
| +scope first would make the pair of answers informative in the one direction |
| +that matters — 404 for an account you do not hold, 403 for one you do — which |
| +is a membership oracle that role-first simply does not have. |
| + |
| +It is also the only order that can be enforced structurally. Role-first runs |
| +in the `cliRoute` wrapper, before the handler is entered, so a door cannot |
| +skip it. Scope-first would put the role check inside each handler, after |
| +`apiAccount` returns, which is ~50 opportunities to forget. |
| -Role refusal is a **403 that names the role**: |
| +The refusal 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. |
| +That is not a leak for the same reason: it discloses only the caller's own |
| +configuration. A silent 404 there would cost an afternoon of debugging for |
| +every operator who ever mis-scopes a credential, and buy nothing. |
| + |
| +Everything after the role gate is unchanged from `instanceGate`'s existing |
| +order, including the rule its comment pins: the budget is answered AFTER the |
| +principal and the scope are proven, because "this path is rate limited" is |
| +itself an answer about whether something exists here. |
| + |
| +To avoid resolving the bearer twice, `cliRoute` puts the resolved `Principal` |
| +on the request context and `apiAccount` reads it from there. The context key is |
| +unexported, so nothing outside this package can forge one. |
| ## 4. Rate limits |