From 106c33cb13e112596d26c2ab37f17bca65d761e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:58:24 +0000 Subject: [PATCH] refactor(security): migrate handleSecurity admin gate to shouldDenyAnonymous (#2567 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /security/suggested-bindings admin surface was the last HTTP seam still hand-rolling the anonymous check. It now delegates to the shared shouldDenyAnonymous decision with requireAuth:true hardcoded, preserving its UNCONDITIONAL semantics (an admin surface denies anonymous even on a requireAuth:false deployment — pinned by the existing test that constructs the dispatcher with no options and still expects 401). The 401 body adopts the shared shape (code: 'unauthenticated'), asserted in the test. Deliberately NOT migrated: handleNotification's !userId check — a "needs a user identity" predicate (inbox is keyed by userId; a system context has no inbox), not an anonymous-posture decision. Verified: http-dispatcher + http-dispatcher.requireauth suites 185/185, lint clean, check-single-authz-resolver intact. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ShknNUYoSTspJLBiqorD8V --- .changeset/authz-2567-handlesecurity-seam.md | 17 +++++++++++++++++ packages/runtime/src/http-dispatcher.test.ts | 5 +++++ packages/runtime/src/http-dispatcher.ts | 12 ++++++++++-- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 .changeset/authz-2567-handlesecurity-seam.md diff --git a/.changeset/authz-2567-handlesecurity-seam.md b/.changeset/authz-2567-handlesecurity-seam.md new file mode 100644 index 0000000000..7bb1aba435 --- /dev/null +++ b/.changeset/authz-2567-handlesecurity-seam.md @@ -0,0 +1,17 @@ +--- +'@objectstack/runtime': patch +--- + +refactor(security): migrate the handleSecurity admin gate to shouldDenyAnonymous (#2567 follow-up) + +The dispatcher's `/security/suggested-bindings` admin surface was the last HTTP +seam still hand-rolling the `!userId && !isSystem → 401` check. It now delegates +to the shared `shouldDenyAnonymous` decision like every other seam — with +`requireAuth: true` hardcoded, preserving its UNCONDITIONAL semantics (an admin +surface denies anonymous callers even on a `requireAuth: false` demo deployment). +The 401 body adopts the shared shape (`code: 'unauthenticated'`). + +Deliberately NOT migrated: `handleNotification`'s `!userId` check — that is a +"needs a user identity" predicate (the inbox is keyed by userId; a system +context has no inbox), not an anonymous-posture decision; migrating it would +change semantics. diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index ab2b738446..c8e39a9b18 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -558,10 +558,15 @@ describe('HttpDispatcher', () => { it('401s an anonymous request without touching the service', async () => { const service = makeService(); + // NOTE: no options — requireAuth defaults false, yet the admin + // surface still denies anonymous (the gate is UNCONDITIONAL, + // hardcoded requireAuth:true into shouldDenyAnonymous — #2567). const d = new HttpDispatcher(secKernel(service)); const result = await d.handleSecurity('/suggested-bindings', 'GET', undefined, {}, ctx()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(401); + // Shared anonymous-deny body shape (locks the seam migration). + expect(result.response?.body?.error?.details?.code).toBe('unauthenticated'); expect(service.listAudienceBindingSuggestions).not.toHaveBeenCalled(); }); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 7a2e9865d8..2b683418c2 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -2337,8 +2337,16 @@ export class HttpDispatcher { } const ec = context.executionContext; - if (!ec?.userId && !ec?.isSystem) { - return { handled: true, response: this.error('Authentication required', 401) }; + // Admin surface — anonymous is denied UNCONDITIONALLY (`requireAuth: + // true` hardcoded), independent of the deployment posture: even a + // `requireAuth: false` demo must not let anonymous callers list or + // confirm audience bindings. Shares the decision + body with every + // other HTTP seam (#2567). + if (shouldDenyAnonymous({ requireAuth: true, userId: ec?.userId, isSystem: ec?.isSystem })) { + return { + handled: true, + response: this.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; } const m = method.toUpperCase();