diff --git a/.changeset/authz-2567-anonymous-posture-surfaces.md b/.changeset/authz-2567-anonymous-posture-surfaces.md new file mode 100644 index 0000000000..46650de61e --- /dev/null +++ b/.changeset/authz-2567-anonymous-posture-surfaces.md @@ -0,0 +1,37 @@ +--- +'@objectstack/runtime': minor +'@objectstack/plugin-hono-server': minor +--- + +fix(security): enforce the anonymous-deny posture uniformly across HTTP surfaces (#2567) + +The ADR-0056 D2 `requireAuth` flip made REST `/data/*` deny-anonymous by +default, but three sibling surfaces reached ObjectQL without passing through the +gate — so the platform's anonymous posture was **inconsistent by surface**: an +anonymous caller denied on `/data` could read the same object data through a +different door. This closes the remaining two gaps (the `/meta` gate had already +landed) and pins every surface with a conformance row. + +- **Dispatcher GraphQL** (`runtime/http-dispatcher.ts`, `dispatcher-plugin.ts`): + `POST /graphql` reached `kernel.graphql`, whose security middleware falls + **open** for an anonymous context. `handleGraphQL` now applies the same + `requireAuth` gate as `/data` and `/meta`, resolving identity for the direct + route that does not flow through `dispatch()`. The dispatcher's `requireAuth` + default is aligned with the REST plugin's (`?? true`) so a bare host no longer + denies anonymous `/data` while serving the same rows over `/graphql`; an + explicit `requireAuth: false` opt-out is honoured and logs a boot warning. + +- **Raw-hono standard `/data` routes** (`plugin-hono-server/hono-plugin.ts`): + these delegate straight to ObjectQL and were only *shadowed* when the REST + plugin registered the same paths first — so secure-by-default depended on + plugin registration order. Each route now consults `requireAuth` (secure by + default, mirroring `rest-server.ts`), making the deny decision a property of + this entry point too. Order no longer affects the anonymous posture. + +**Behaviour change:** on a `requireAuth` deployment (the secure default), +anonymous `POST /graphql` and anonymous raw-hono `/data` now return 401. +Deployments that intentionally serve these surfaces publicly set +`requireAuth: false` (a boot warning is logged). Proven end-to-end on the +platform default in `showcase-anonymous-deny-surfaces.dogfood.test.ts`, with +handler-level regression coverage in `http-dispatcher.requireauth.test.ts` and +`hono-anonymous-deny.test.ts`, and pinned by three new authz-conformance rows. diff --git a/packages/client/src/client.hono.test.ts b/packages/client/src/client.hono.test.ts index a98b43b8be..ddaab3f0a5 100644 --- a/packages/client/src/client.hono.test.ts +++ b/packages/client/src/client.hono.test.ts @@ -15,9 +15,17 @@ describe('ObjectStackClient (with Hono Server)', () => { kernel.use(new ObjectQLPlugin()); // 2. Setup Hono Plugin - const honoPlugin = new HonoServerPlugin({ + // This suite exercises the CLIENT's data operations over the raw-hono + // standard endpoints; it wires no auth service. Opt out of the + // secure-by-default anonymous-deny posture (#2567) so the anonymous + // data CRUD under test stays reachable — the same explicit + // `requireAuth: false` a deployment that intentionally serves data + // publicly would set. The gate itself is proven in + // plugin-hono-server/hono-anonymous-deny.test.ts. + const honoPlugin = new HonoServerPlugin({ port: 0, - registerStandardEndpoints: true + registerStandardEndpoints: true, + restConfig: { api: { requireAuth: false } } as any, }); kernel.use(honoPlugin); diff --git a/packages/dogfood/test/authz-conformance.matrix.ts b/packages/dogfood/test/authz-conformance.matrix.ts index 476937a9e2..a191b03eb2 100644 --- a/packages/dogfood/test/authz-conformance.matrix.ts +++ b/packages/dogfood/test/authz-conformance.matrix.ts @@ -50,6 +50,21 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ note: 'An organization_admin holds the superuser bit via its `*` wildcard, so it used to also get the Layer 0 exemption and read/write EVERY tenant\'s rows on private tenant objects. The exemption now requires a platform-exclusive capability (manage_metadata/manage_platform_settings/studio.access/manage_users), which org_admin deliberately lacks — a SECURITY NARROWING: org admin is walled to its own org, a true platform admin still crosses, the better-auth carve-out is untouched. Unit-proven in plugin-security/authz-matrix-gate.test.ts ([Finding 2 / #2937] Layer 0 cross-tenant exemption requires the platform posture).' }, { id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced', enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' }, + // ── #2567 — the anonymous-deny posture is UNIFORM across HTTP surfaces, not + // just REST `/data`. Each sibling surface that reaches ObjectQL now consults + // the same `requireAuth` gate; these rows pin every entry point so a new + // ungated surface (or a silent regression) fails CI, not review. + { id: 'anonymous-deny-meta', summary: 'anonymous-deny on the metadata endpoints (#2567 surface 1)', state: 'enforced', + enforcement: 'rest/rest-server.ts registerMetadataEndpoints guarded registrar (enforceAuth) — every /meta route inherits the gate; runtime/http-dispatcher.ts handleMetadata mirrors it for the dispatcher metadata catch-all', + proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts' }, + { id: 'anonymous-deny-graphql', summary: 'anonymous-deny on the dispatcher GraphQL endpoint (#2567 surface 2)', state: 'enforced', + enforcement: 'runtime/http-dispatcher.ts handleGraphQL (requireAuth gate, resolves identity for the direct /graphql route) + runtime/dispatcher-plugin.ts requireAuth default(true), mirroring rest-server.ts', + proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', + note: 'GraphQL reaches the same object data as /data through kernel.graphql, whose security middleware falls OPEN for an anonymous context. Unit-proven in runtime/http-dispatcher.requireauth.test.ts (GraphQL block); e2e on the platform default in the surfaces proof.' }, + { id: 'anonymous-deny-hono-data', summary: 'anonymous-deny on the raw-hono standard /data routes (#2567 surface 3)', state: 'enforced', + enforcement: 'plugin-hono-server/hono-plugin.ts denyAnonymous gate on the standard /data routes (requireAuth ?? true, mirroring rest-server.ts)', + proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', + note: 'These routes delegate straight to ObjectQL and were only shadowed when the REST plugin registered the same paths FIRST — so the posture depended on plugin registration order (a load-order change silently reopened it, no test failing). Gating each route makes the deny decision a property of this entry point too. Handler-level proof in plugin-hono-server/hono-anonymous-deny.test.ts.' }, { id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced', enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' }, diff --git a/packages/dogfood/test/authz-conformance.test.ts b/packages/dogfood/test/authz-conformance.test.ts index 2ad4bbcd3d..4b4a2c6e9a 100644 --- a/packages/dogfood/test/authz-conformance.test.ts +++ b/packages/dogfood/test/authz-conformance.test.ts @@ -18,7 +18,12 @@ describe('ADR-0056 D10 — authorization conformance matrix', () => { it('is a sound conformance ledger (ADR-0060 checkLedger)', () => { const problems = checkLedger(AUTHZ_CONFORMANCE, { proofRoot: HERE, // proofs are dogfood test files alongside this one - highRisk: ['owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile'], + highRisk: [ + 'owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile', + // #2567 — every anonymous-deny HTTP surface is high-risk: it guards the + // same object data as REST `/data` through a sibling entry point. + 'anonymous-deny-meta', 'anonymous-deny-graphql', 'anonymous-deny-hono-data', + ], }); expect(problems, problems.join('\n')).toEqual([]); }); diff --git a/packages/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts new file mode 100644 index 0000000000..35375cde64 --- /dev/null +++ b/packages/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #2567 — anonymous posture must be UNIFORM across HTTP surfaces, not just the +// REST `/data` routes proven by showcase-anonymous-deny.dogfood.test.ts. Before +// this fix, on a `requireAuth` deployment `/data/*` denied anonymous callers +// while three sibling surfaces reached ObjectQL without the gate: +// - the metadata endpoints (`/meta`) +// - the dispatcher GraphQL endpoint (`/graphql`) +// - the raw-hono standard `/data` routes (order-dependent shadowing) +// +// This proof boots the real showcase HTTP stack ON THE PLATFORM DEFAULT (the +// verify harness passes no `requireAuth` override, so the flipped secure default +// is what a fresh production deployment gets) and asserts every surface denies +// an anonymous caller with 401 while an authenticated member is unaffected. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const OBJ = '/data/showcase_private_note'; + +describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => { + let stack: VerifyStack; + let memberToken: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); // platform default (deny anonymous) + await stack.signIn(); + memberToken = await stack.signUp('surfaces-member@verify.test'); + }, 60_000); + + afterAll(async () => { await stack?.stop(); }); + + // ── /meta ────────────────────────────────────────────────────────────── + it('anonymous GET /meta is denied (401)', async () => { + const r = await stack.api('/meta', { method: 'GET' }); + expect(r.status, 'anonymous metadata read must be 401').toBe(401); + }); + + it('an authenticated member is NOT denied on /meta (deny targets anonymity)', async () => { + const r = await stack.apiAs(memberToken, 'GET', '/meta'); + expect(r.status, 'authenticated metadata read must clear the auth gate').not.toBe(401); + }); + + // ── /graphql ───────────────────────────────────────────────────────────── + it('anonymous POST /graphql is denied (401)', async () => { + const r = await stack.api('/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ __typename }' }), + }); + expect(r.status, 'anonymous GraphQL query must be 401').toBe(401); + }); + + it('an authenticated member clears the /graphql gate (not 401)', async () => { + // Past the gate the query may 200 or 501 (depending on whether a GraphQL + // service is wired) — the point is it is NOT the anonymous 401. + const r = await stack.apiAs(memberToken, 'POST', '/graphql', { query: '{ __typename }' }); + expect(r.status, 'authenticated GraphQL must clear the auth gate').not.toBe(401); + }); + + // ── /data (surface-level; raw-hono handler proven in plugin-hono-server) ── + it('anonymous READ of the data surface is denied (401)', async () => { + const r = await stack.api(OBJ, { method: 'GET' }); + expect(r.status, 'anonymous data read must be 401').toBe(401); + }); + + it('an authenticated member is allowed on the data surface', async () => { + const r = await stack.apiAs(memberToken, 'GET', OBJ); + expect(r.status).toBe(200); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts b/packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts new file mode 100644 index 0000000000..21392ca8e5 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #2567 — the raw-hono standard `/data` endpoints must honour the same +// secure-by-default (`requireAuth`) anonymous-deny posture as the REST `/data` +// routes. Before the gate, these routes delegated straight to ObjectQL and were +// only *shadowed* when the REST plugin registered the same paths first — so the +// anonymous posture depended on plugin registration order. These tests drive the +// real routes on a real Hono app (no REST plugin in the picture) to prove the +// gate stands on its own. + +import { describe, it, expect } from 'vitest'; +import { HonoServerPlugin } from './hono-plugin'; + +/** + * Boot a plugin and register ONLY the standard CRUD routes on its real Hono + * app, then hand back the app so tests can drive HTTP requests directly. No + * listening socket, no CORS/static — we exercise the data routes in isolation. + */ +function bootStandardEndpoints(opts: { + restConfig?: { api?: { requireAuth?: boolean } }; + services: Record; +}) { + const plugin = new HonoServerPlugin({ port: 0, restConfig: opts.restConfig as any }); + const ctx: any = { + logger: { info() {}, debug() {}, warn() {}, error() {} }, + getKernel: () => ({ getService: (n: string) => opts.services[n] }), + registerService: () => {}, + hook: () => {}, + getService: (n: string) => { + const s = opts.services[n]; + if (s === undefined) throw new Error(`no service: ${n}`); + return s; + }, + }; + (plugin as any).registerDiscoveryAndCrudEndpoints(ctx); + return (plugin as any).server.getRawApp(); +} + +// ObjectQL stub — every read returns empty, every write echoes an id. Enough +// for the routes to reach a 200 once the gate has been cleared. +const objectql = { + find: async () => [], + insert: async () => ({ id: 'new-id' }), +}; + +// Auth stub — resolves a session ONLY when the request carries `x-test-user`, +// so the same boot serves both anonymous and authenticated requests. +const auth = { + api: { + getSession: async ({ headers }: { headers: Headers }) => { + const uid = headers?.get?.('x-test-user'); + return uid ? { user: { id: uid }, session: {} } : null; + }, + }, +}; + +const REQ = 'http://localhost/api/v1/data/thing'; + +describe('raw-hono /data — anonymous-deny gate (#2567)', () => { + it('secure-by-default (no restConfig): anonymous LIST is 401', async () => { + const app = bootStandardEndpoints({ services: { objectql, auth } }); + const res = await app.request(REQ, { method: 'GET' }); + expect(res.status).toBe(401); + }); + + it('secure-by-default: anonymous GET-by-id is 401', async () => { + const app = bootStandardEndpoints({ services: { objectql, auth } }); + const res = await app.request(`${REQ}/abc`, { method: 'GET' }); + expect(res.status).toBe(401); + }); + + it('secure-by-default: anonymous CREATE is 401', async () => { + const app = bootStandardEndpoints({ services: { objectql, auth } }); + const res = await app.request(REQ, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'x' }), + }); + expect(res.status).toBe(401); + }); + + it('an AUTHENTICATED caller is served (deny targets anonymity, not the route)', async () => { + const app = bootStandardEndpoints({ services: { objectql, auth } }); + const res = await app.request(REQ, { method: 'GET', headers: { 'x-test-user': 'u1' } }); + expect(res.status).toBe(200); + }); + + it('explicit opt-out (requireAuth:false) keeps the surface anonymously reachable', async () => { + const app = bootStandardEndpoints({ + restConfig: { api: { requireAuth: false } }, + services: { objectql, auth }, + }); + const res = await app.request(REQ, { method: 'GET' }); + expect(res.status).toBe(200); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index d5aec23cbe..e1e1736091 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -529,6 +529,46 @@ export class HonoServerPlugin implements Plugin { ctx.logger.info('Registered discovery endpoints', { prefix }); + // ── Anonymous-deny gate (ADR-0056 D2, #2567) ────────────────────────── + // These raw `/data/:object` routes delegate straight to ObjectQL. They + // are only *shadowed* by the REST plugin's gated `/data` routes when + // that plugin registers the same paths FIRST — so before this gate the + // platform's anonymous posture depended on plugin registration order: a + // load-order change silently reopened anonymous data access with no test + // failing. Gating here makes the deny decision a property of THIS entry + // point too, so security no longer depends on who registered first. + // + // Secure-by-default: `requireAuth` mirrors `rest-server.ts`'s `?? true` + // (ADR-0056 D2). A deployment that intentionally serves data publicly + // sets `restConfig.api.requireAuth = false` (a boot warning is logged, as + // in the REST plugin). No-op in that case — the previously-public surface + // is unchanged. An authenticated / system caller always passes. + // + // `requireAuth` is not in the typed `api` shape (rest-server.ts reads it + // via the same `as any` cast), so widen locally. + const requireAuth = + (this.options.restConfig?.api as { requireAuth?: boolean } | undefined)?.requireAuth ?? true; + if (!requireAuth) { + ctx.logger.warn( + 'Hono standard /data endpoints: requireAuth is OFF — anonymous callers can read/write object data. ' + + 'This is a deliberate opt-out; set restConfig.requireAuth=true to deny anonymous access (ADR-0056 D2, #2567).', + ); + } + // Returns a 401 Response when the caller is anonymous under the deny + // posture, else null (caller proceeds). `isSystem` is never set on + // inbound HTTP (internal-only), so it cannot be forged to bypass this. + const denyAnonymous = (c: any, execCtx: any): Response | null => { + if (!requireAuth) return null; + if (execCtx?.userId || execCtx?.isSystem) return null; + return c.json( + { + error: 'unauthenticated', + message: 'Authentication is required to access this endpoint.', + }, + 401, + ); + }; + // Basic CRUD data endpoints — delegate to ObjectQL service directly const getObjectQL = () => ctx.getService('objectql'); @@ -675,6 +715,8 @@ export class HonoServerPlugin implements Plugin { const object = c.req.param('object'); const data = await c.req.json().catch(() => ({})); const execCtx = await resolveCtx(c); + const denied = denyAnonymous(c, execCtx); + if (denied) return denied; try { const res = await ql.insert(object, data, { context: execCtx } as any); const record = { ...data, ...res }; @@ -694,6 +736,8 @@ export class HonoServerPlugin implements Plugin { const object = c.req.param('object'); const id = c.req.param('id'); const execCtx = await resolveCtx(c); + const denied = denyAnonymous(c, execCtx); + if (denied) return denied; try { let all = await ql.find(object, { context: execCtx } as any); if (!all) all = []; @@ -713,6 +757,8 @@ export class HonoServerPlugin implements Plugin { if (!ql) return c.json({ error: 'Data service not available' }, 503); const object = c.req.param('object'); const execCtx = await resolveCtx(c); + const denied = denyAnonymous(c, execCtx); + if (denied) return denied; try { let all = await ql.find(object, { context: execCtx } as any); if (!Array.isArray(all) && all && (all as any).value) all = (all as any).value; diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 0757b18ba5..64bfcffedf 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -49,16 +49,19 @@ export interface DispatcherPluginConfig { enforceProjectMembership?: boolean; /** - * Reject anonymous requests to `auth: true` service routes (AI, etc.) with - * HTTP 401, mirroring the REST API's `requireAuth` gate. Must match the - * REST plugin's `api.requireAuth` so `/ai` and `/meta` stay in lockstep - * with `/data` — otherwise the AI routes' declared `auth: true` contract is - * never enforced and anonymous callers reach adapter/model status routes. + * Reject anonymous requests to `auth: true` service routes (AI, etc.), the + * `/graphql` endpoint and the `/meta` catch-all with HTTP 401, mirroring the + * REST API's `requireAuth` gate. Must match the REST plugin's + * `api.requireAuth` so `/ai`, `/graphql` and `/meta` stay in lockstep with + * `/data` — otherwise the AI routes' declared `auth: true` contract is never + * enforced and anonymous callers reach adapter/model status routes or read + * object data over GraphQL that `/data/*` 401s (#2567). * - * Defaults to `false` (backward-compatible: previously nothing enforced - * `RouteDefinition.auth` here). Hosts pass their `api.requireAuth` through — - * the framework `serve` command and the cloud apps do so from the same - * stack `api` config the REST plugin reads. + * Defaults to `true` — secure-by-default, matching the REST plugin's + * `api.requireAuth` default (ADR-0056 D2). Hosts pass their `api.requireAuth` + * through (the framework `serve` command and the cloud apps do so from the + * same stack `api` config the REST plugin reads); a deployment that serves + * these surfaces publicly sets `requireAuth: false` explicitly. */ requireAuth?: boolean; @@ -417,9 +420,25 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // Secure-by-default alignment with the REST plugin's `requireAuth`. // The cloud apps pass the whole stack `api` block as `scoping` // (which carries `requireAuth`), so honour it there too; an explicit - // top-level `requireAuth` wins. Off → unchanged (routes stay open). + // top-level `requireAuth` wins. + // + // Defaults to `true` — matching `rest-server.ts`'s `?? true` + // (ADR-0056 D2). The dispatcher gates the same object data as REST + // through sibling surfaces (`/graphql`, `/ai`, the `/meta` + // catch-all, service routes); defaulting it OFF while REST defaults + // ON is exactly the by-surface inconsistency #2567 closes — a bare + // host would deny anonymous `/data` yet serve the same rows over + // `/graphql`. A deployment that intentionally serves these surfaces + // publicly opts out with an explicit `requireAuth: false` (a + // boot warning is logged, mirroring the REST plugin). const requireAuth = - config.requireAuth ?? (config.scoping as { requireAuth?: boolean } | undefined)?.requireAuth ?? false; + config.requireAuth ?? (config.scoping as { requireAuth?: boolean } | undefined)?.requireAuth ?? true; + if (!requireAuth) { + ctx.logger?.warn?.( + '[dispatcher] requireAuth is OFF — /graphql, /ai and the /meta catch-all serve anonymous callers. ' + + 'This is a deliberate opt-out; set api.requireAuth=true to deny anonymous access (ADR-0056 D2, #2567).', + ); + } const dispatcher = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: enforceMembership, requireAuth, diff --git a/packages/runtime/src/http-dispatcher.requireauth.test.ts b/packages/runtime/src/http-dispatcher.requireauth.test.ts index 94cdb0748e..b8e29ab291 100644 --- a/packages/runtime/src/http-dispatcher.requireauth.test.ts +++ b/packages/runtime/src/http-dispatcher.requireauth.test.ts @@ -66,6 +66,40 @@ describe('HttpDispatcher requireAuth gate — AI routes (handleAI)', () => { }); }); +describe('HttpDispatcher requireAuth gate — GraphQL (handleGraphQL)', () => { + // A valid-enough body so the missing-query 400 branch isn't hit first. + const gqlBody = { query: '{ __typename }' }; + + it('401s an anonymous caller when requireAuth is on (#2567)', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + await expect(d.handleGraphQL(gqlBody, anon)).rejects.toMatchObject({ statusCode: 401 }); + }); + + it('lets an authenticated caller PAST the gate', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + // The mock kernel has no graphql service, so a caller that clears the + // gate hits 501 — which PROVES the gate passed (an anonymous caller + // throws 401 before this point). + await expect(d.handleGraphQL(gqlBody, authed)).rejects.toMatchObject({ statusCode: 501 }); + }); + + it('lets an internal system context past the gate', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + await expect(d.handleGraphQL(gqlBody, system)).rejects.toMatchObject({ statusCode: 501 }); + }); + + it('serves anonymously when requireAuth is off (unchanged legacy behaviour)', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: false }); + // Gate is a no-op → reaches the (absent) graphql service → 501, NOT 401. + await expect(d.handleGraphQL(gqlBody, anon)).rejects.toMatchObject({ statusCode: 501 }); + }); + + it('400s a missing query before the auth gate is consulted', async () => { + const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + await expect(d.handleGraphQL({} as any, anon)).rejects.toMatchObject({ statusCode: 400 }); + }); +}); + describe('HttpDispatcher requireAuth gate — metadata catch-all (handleMetadata)', () => { it('401s an anonymous caller when requireAuth is on', async () => { const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 61ed2137b6..557afea37a 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1489,16 +1489,76 @@ export class HttpDispatcher { if (!body || !body.query) { throw { statusCode: 400, message: 'Missing query in request body' }; } - + + // Anonymous-deny gate — the same `requireAuth` posture the REST `/data` + // and `/meta` surfaces enforce. GraphQL reaches ObjectQL through + // `kernel.graphql`, whose security middleware falls OPEN for an + // anonymous context (no userId/roles → next()), so without this gate an + // anonymous query could read exactly the object data the sibling + // `/data/*` 401 denies (#2567). Mirrors {@link handleMetadata}: no-op + // when `requireAuth` is off (demo / single-tenant), an authenticated or + // system caller passes exactly as on `/data`. + // + // The dispatcher-plugin's direct `/graphql` route calls us WITHOUT + // resolving identity first (unlike `dispatch()`, which populates + // `context.executionContext`), so resolve it here when absent. + if (this.requireAuth) { + let ec: any = context.executionContext; + if (!ec) { + ec = await this.resolveRequestExecutionContext(context); + if (ec) context.executionContext = ec; + } + if (!ec?.userId && !ec?.isSystem) { + throw { + statusCode: 401, + message: 'Authentication is required to access this endpoint.', + code: 'unauthenticated', + }; + } + } + if (typeof this.kernel.graphql !== 'function') { throw { statusCode: 501, message: 'GraphQL service not available' }; } - return this.kernel.graphql(body.query, body.variables, { - request: context.request + return this.kernel.graphql(body.query, body.variables, { + request: context.request }); } + /** + * Resolve the RBAC/RLS execution context for a request that did NOT flow + * through {@link dispatch} (which resolves and caches it on + * `context.executionContext`). The dispatcher-plugin's direct `/graphql` + * route is the current caller. Mirrors the identity resolution `dispatch()` + * performs so an anonymous-deny gate can tell an authenticated caller from + * an anonymous one. Best-effort: returns `undefined` on failure (treated as + * anonymous, i.e. denied under `requireAuth`). + */ + private async resolveRequestExecutionContext( + context: HttpProtocolContext, + ): Promise { + try { + return await resolveExecutionContext({ + getService: (n: string) => this.resolveService(n, context.environmentId), + getQl: async () => { + const k: any = this.kernel; + if (k && typeof k.getServiceAsync === 'function') { + const ql = await k.getServiceAsync('objectql').catch(() => undefined); + if (ql && (ql.registry || typeof ql.find === 'function')) return ql; + } + return this.getObjectQLService(context.environmentId); + }, + request: context.request, + // OAuth access tokens are honoured only on the MCP surface + // (#2698); GraphQL enforces no per-scope tool gating. + acceptOAuthAccessToken: false, + }); + } catch { + return undefined; + } + } + /** * Handles Auth requests * path: sub-path after /auth/