Skip to content

finding: anonymous-deny ordering inverted on /security too — the 503 "Security service not available" answers unauthenticated callers ahead of the gate #7911

Description

@hotlong

Found while fixing #7653 (/ai/** anonymous-deny ordering, PR #7910). Filed rather than folded into that PR — different domain, and the card scoped that work to domains/ai.ts.

The defect

packages/runtime/src/domains/handleSecurityRequest (domains/security.ts) resolves the security service and returns a 503 when the slot is empty or the occupant does not duck-type — before it consults the anonymous-deny gate:

// domains/security.ts:71-74constservice=awaitdeps.resolveService(context,'security',context.environmentId);if(!service||typeofservice.listAudienceBindingSuggestions!=='function'){return{handled: true,response: deps.error('Security service not available',503)};}constec=context.executionContext;// …// domains/security.ts:92if(!ec||shouldDenyAnonymous({userId: ec.userId,isSystem: ec.isSystem})){

So on any deployment where the security slot is empty or stubbed, an unauthenticated caller to /api/v1/security/suggested-bindings gets 503 Security service not available instead of 401 UNAUTHENTICATED — a capability answer served ahead of the gate. That is the same inversion #7653 records for /ai/**, on a surface the handler's own comment calls an "Admin surface — anonymous is denied UNCONDITIONALLY (#2567, #3963)".

Why it is worth a ticket even though the string is bland

Smaller disclosure than the AI one: the message names no remedy and no package, so what leaks is only whether this deployment serves the security slot. The value is the contract, not the secret — /security is meant to sit on the same anonymous-deny floor as /data, /meta, /actions and /automation (ADR-0056 D2 → #3963), and an anonymous caller should not be able to tell a 503-vs-401 apart on an admin surface. It is also the last of the six dispatcher domains still ordered this way.

Surveyed, for whoever picks this up

Every sibling in packages/runtime/src/domains/ was checked for the same shape while verifying #7653. Exactly one is inverted:

domaingatefirst capability returnverdict
automation.ts:156capabilityUnavailable:174✅ gate first — the reference, with a comment giving the reason
meta.ts:172✅ gate first
actions.ts:129✅ gate first
packages.ts:224✅ gate first
ai.ts:128 (in-loop)courtesy :68, 501 :79#7653 — fixed in #7910
security.ts:92503 :73this issue

domains/automation.ts is the shape to copy: hoist the gate above the service probe.

Suggested fix

Move the !ec || shouldDenyAnonymous(...) block above the resolveService probe. Note the !ec arm is already documented as behaviour-preserving (#4127 batch 3), so hoisting does not change what it decides — only when. No route-level auth: false opt-out exists on this domain, so unlike /ai this is a straight hoist with a single consult site.

Verification the fix would need

Positive: anonymous GET /api/v1/security/suggested-bindings with an empty security slot → 401 with error.code === 'UNAUTHENTICATED' (assert code and status — ADR-0112). Negative: with a valid principal and an empty slot, the 503 Security service not available answer is unchanged; with a serveable slot the route still works for an authenticated caller and still denies anonymous.

Not assigned — recording, not claiming.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions