carlos / platform Public

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
Paul Campbell f4eb698bbf756a6fea07618abef855ae8f1d8220 parent b607af7
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