diff --git a/.changeset/authz-2567-handlesecurity-seam.md b/.changeset/authz-2567-handlesecurity-seam.md new file mode 100644 index 000000000..7bb1aba43 --- /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 ab2b73844..c8e39a9b1 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 7a2e9865d..2b683418c 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();