diff --git a/.changeset/drop-require-auth.md b/.changeset/drop-require-auth.md new file mode 100644 index 0000000000..c635da6c6f --- /dev/null +++ b/.changeset/drop-require-auth.md @@ -0,0 +1,49 @@ +--- +"@objectstack/spec": major +"@objectstack/rest": major +"@objectstack/runtime": major +"@objectstack/core": minor +"@objectstack/cli": minor +"@objectstack/plugin-hono-server": minor +"@objectstack/plugin-dev": minor +--- + +feat(auth)!: retire the `api.requireAuth` opt-out — anonymous access to object data is always denied (#3963) + +`api.requireAuth: false` let a deployment open its ENTIRE data plane with one +config key. It is removed. Auth is a kernel concern, not a deployment posture: +anonymous callers are denied on every HTTP surface that reaches object data, +unconditionally. + +Every surface that legitimately serves a session-less caller already derives its +own narrow authorization from a DECLARATION, so none of them needed the global +switch: + +- control plane (`/auth/*`, `/health`, `/ready`, `/discovery`, ADR-0069 + remediation) — the auth-gate allowlist; +- public form submission — `publicFormGrant` (ADR-0056 Option A); +- share links — the capability token, validated then read as SYSTEM; +- a `book.audience: 'public'` read — the ADR-0046 §6.7 audience gate (#3995); +- MCP — an OAuth token or API key. + +**Breaking changes.** + +- `api.requireAuth` is a retired key. It is tombstoned (`retiredKey`) in both + `RestApiConfigSchema` and the stack `api` block, so authoring it now fails with + a fix-it message rather than being silently stripped (the ADR-0104 / #3733 + quiet-failure this whole line of work has been closing). `os migrate meta` + drops it via the protocol-18 conversion `stack-api-require-auth-removed`. +- `shouldDenyAnonymous` (@objectstack/core) no longer takes a `requireAuth` + input; it denies any anonymous, non-system caller outside the control-plane + allowlist. +- A stack that mounts **no auth at all** now FAILS AT BOOT when it would serve a + data API (`objectstack serve`, plugin-dev), instead of getting an explicit + fail-open. Enable auth (the `auth` tier or AuthPlugin), or run without the data + API. There is no anonymous-data carve-out any more — publishing a public + surface is done by declaration (see above). + +**Migration.** Delete `api.requireAuth` from the stack config (or run +`os migrate meta`). If you were serving data publicly with `requireAuth: false`, +replace it with the declaration that fits: a public form view, a share link, or +`book.audience: 'public'`. If you have an auth-less stack that intentionally +served data, it must now mount auth or stop serving the data API. diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index 73976d56c9..be30370d07 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -192,7 +192,7 @@ const result = BatchEndpointsConfig.parse(data); | **enableOpenApi** | `boolean` | ✅ | Enable OpenAPI 3.1 spec & docs viewer endpoints | | **enableProjectScoping** | `boolean` | ✅ | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | ✅ | Project ID resolution strategy | -| **requireAuth** | `boolean` | ✅ | Reject anonymous requests on ALL HTTP surfaces that reach object data (REST /data, /meta, raw-hono /data) with HTTP 401 (secure-by-default; set false to serve them publicly) | +| **requireAuth** | `any` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 18 (#3963). Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. | | **documentation** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config | | **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options | diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index c45e67d8d1..58db52e3eb 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1728,26 +1728,34 @@ export default class Serve extends Command { // (env-native auth IS the membership — ADR-0024 D9) by setting // `api.enforceProjectMembership: false`. Undefined → dispatcher default. const enforceProjectMembership = apiConfig.enforceProjectMembership; - // `requireAuth: true` rejects anonymous requests on `/api/v1/data/*` - // with HTTP 401 before they reach ObjectQL. The platform default is - // now secure-by-default (ADR-0056 D2): deny anonymous. The CLI keeps - // one deliberate carve-out — a stack with NO `auth` tier has no way - // to authenticate anyone, so denying would brick its data API - // entirely; such auth-less playgrounds get an EXPLICIT `false` - // (fail-open), which the REST plugin surfaces with a boot warning. - // Apps can always override via stack `api.requireAuth`. - // Auth availability = tier auto-registers it OR the stack mounts - // AuthPlugin explicitly (hasAuthPlugin, computed above). Keying only on - // the tier would hand an explicit fail-open to a stack that ships auth - // via `plugins:` under a minimal tier set — re-opening the very hole - // the flip closes. Only a stack with NO auth at all gets the carve-out. - const requireAuth = apiConfig.requireAuth - ?? ((tierEnabled('auth') || hasAuthPlugin) ? true : false); + // [#3963] Anonymous access to object data is denied unconditionally — + // there is no `api.requireAuth` opt-out any more (auth is a kernel + // concern; every legitimately session-less surface derives its own narrow + // authorization from a declaration instead). + // + // The CLI used to hand an EXPLICIT fail-open to a stack with no auth at + // all, reasoning that nobody could authenticate against it so denying + // would brick its data API. Under A1 that inverts the conclusion: a stack + // with no auth has no security model, so it must not serve a data API — + // and it should say so at boot instead of quietly serving object data to + // the internet. Auth availability = the tier auto-registers it OR the + // stack mounts AuthPlugin explicitly. + if (flags.server && !(tierEnabled('auth') || hasAuthPlugin)) { + throw new Error( + 'This stack mounts no auth, so no caller can authenticate — and anonymous access to object ' + + 'data is always denied (#3963), which would leave the data API unusable.\n' + + 'Fix it one of two ways:\n' + + ` • enable auth — add the 'auth' tier (or mount AuthPlugin in \`plugins\`);\n` + + ' • or serve without the data API — run with --no-server, or drop the REST/dispatcher plugins.\n' + + "Publishing a genuinely public surface does not need anonymous data access: use a public form " + + "view, a share link, or `book.audience: 'public'`.", + ); + } try { const { createRestApiPlugin } = await import('@objectstack/rest'); await kernel.use( - createRestApiPlugin({ api: { api: { enableProjectScoping, projectResolution, requireAuth } } as any }), + createRestApiPlugin({ api: { api: { enableProjectScoping, projectResolution } } as any }), ); trackPlugin('RestAPI'); } catch (e: any) { @@ -1767,9 +1775,6 @@ export default class Serve extends Command { createDispatcherPlugin({ scoping: { enableProjectScoping, projectResolution }, enforceProjectMembership, - // Keep the dispatcher's `auth: true` service routes (AI) in - // lockstep with the REST `/data` gate above — same `requireAuth`. - requireAuth, observability, }), ); diff --git a/packages/cli/src/utils/merge-boot-config.test.ts b/packages/cli/src/utils/merge-boot-config.test.ts index a335a602f0..c7ff88d3e5 100644 --- a/packages/cli/src/utils/merge-boot-config.test.ts +++ b/packages/cli/src/utils/merge-boot-config.test.ts @@ -7,28 +7,27 @@ import { mergeBootConfig } from './merge-boot-config.js'; const BOOT_API = { enableProjectScoping: false, projectResolution: 'none' } as const; describe('mergeBootConfig (#4002)', () => { - it('keeps the authored api keys the boot result does not set', () => { + it('keeps an authored api key the boot result does not set', () => { const merged: any = mergeBootConfig( - { api: { requireAuth: false, enforceProjectMembership: false } }, + { api: { enforceProjectMembership: false } }, { api: { ...BOOT_API }, plugins: [] }, ); - // The two live knobs the CLI reads a few lines later — both were dropped - // by the old shallow spread. - expect(merged.api.requireAuth).toBe(false); + // `enforceProjectMembership` is a live knob the CLI reads a few lines + // later — dropped by the old shallow spread, kept by the per-key merge. expect(merged.api.enforceProjectMembership).toBe(false); }); it('lets the boot result win on the keys it decides', () => { // Environment scoping is not the author's call on a standalone host. const merged: any = mergeBootConfig( - { api: { enableProjectScoping: true, projectResolution: 'auto', requireAuth: false } }, + { api: { enableProjectScoping: true, projectResolution: 'auto', enforceProjectMembership: false } }, { api: { ...BOOT_API } }, ); expect(merged.api.enableProjectScoping).toBe(false); expect(merged.api.projectResolution).toBe('none'); - expect(merged.api.requireAuth).toBe(false); // untouched by boot → survives + expect(merged.api.enforceProjectMembership).toBe(false); // untouched by boot → survives }); it('still replaces every other top-level key wholesale', () => { @@ -49,8 +48,8 @@ describe('mergeBootConfig (#4002)', () => { }); it('carries an api block through when only one side has one', () => { - expect((mergeBootConfig({ api: { requireAuth: false } }, {}) as any).api) - .toEqual({ requireAuth: false }); + expect((mergeBootConfig({ api: { enforceProjectMembership: false } }, {}) as any).api) + .toEqual({ enforceProjectMembership: false }); expect((mergeBootConfig({}, { api: { ...BOOT_API } }) as any).api) .toEqual({ ...BOOT_API }); }); @@ -60,16 +59,16 @@ describe('mergeBootConfig (#4002)', () => { // character-indexed keys from a string spread. expect((mergeBootConfig({ api: 'nonsense' as any }, { api: { ...BOOT_API } }) as any).api) .toEqual({ ...BOOT_API }); - expect((mergeBootConfig({ api: { requireAuth: false } }, { api: null as any }) as any).api) - .toEqual({ requireAuth: false }); + expect((mergeBootConfig({ api: { enforceProjectMembership: false } }, { api: null as any }) as any).api) + .toEqual({ enforceProjectMembership: false }); }); it('does not mutate either input', () => { - const authored = { api: { requireAuth: false } }; + const authored = { api: { enforceProjectMembership: false } }; const boot = { api: { ...BOOT_API } }; mergeBootConfig(authored, boot); - expect(authored).toEqual({ api: { requireAuth: false } }); + expect(authored).toEqual({ api: { enforceProjectMembership: false } }); expect(boot).toEqual({ api: { ...BOOT_API } }); }); }); diff --git a/packages/client/src/client.batch-transaction.test.ts b/packages/client/src/client.batch-transaction.test.ts index cddc0b1f2b..2fec232c9e 100644 --- a/packages/client/src/client.batch-transaction.test.ts +++ b/packages/client/src/client.batch-transaction.test.ts @@ -37,6 +37,17 @@ describe('data.batchTransaction (live Hono, #1604)', () => { beforeAll(async () => { kernel = new LiteKernel(); kernel.use(new ObjectQLPlugin()); + // [#3963] The anonymous-deny gate is unconditional now, so the live-server + // client suites need an authenticated session. Register a minimal auth + // service that resolves a fixed user for every request. + kernel.use({ + metadata: { name: 'test-auth', version: '1.0.0' }, + async init(ctx: any) { + ctx.registerService('auth', { + api: { getSession: async () => ({ user: { id: 'test-user' } }) }, + }); + }, + } as any); kernel.use( new HonoServerPlugin({ diff --git a/packages/client/src/client.environment-scoping.test.ts b/packages/client/src/client.environment-scoping.test.ts index aead0f2d05..7fb99a3e56 100644 --- a/packages/client/src/client.environment-scoping.test.ts +++ b/packages/client/src/client.environment-scoping.test.ts @@ -28,6 +28,17 @@ describe('Project-scoped REST routing (live Hono)', () => { beforeAll(async () => { kernel = new LiteKernel(); kernel.use(new ObjectQLPlugin()); + // [#3963] The anonymous-deny gate is unconditional now, so the live-server + // client suites need an authenticated session. Register a minimal auth + // service that resolves a fixed user for every request. + kernel.use({ + metadata: { name: 'test-auth', version: '1.0.0' }, + async init(ctx: any) { + ctx.registerService('auth', { + api: { getSession: async () => ({ user: { id: 'test-user' } }) }, + }); + }, + } as any); const honoPlugin = new HonoServerPlugin({ port: 0, diff --git a/packages/client/src/client.hono.test.ts b/packages/client/src/client.hono.test.ts index ddaab3f0a5..72bb866214 100644 --- a/packages/client/src/client.hono.test.ts +++ b/packages/client/src/client.hono.test.ts @@ -13,6 +13,17 @@ describe('ObjectStackClient (with Hono Server)', () => { // 1. Setup Kernel kernel = new LiteKernel(); kernel.use(new ObjectQLPlugin()); + // [#3963] The anonymous-deny gate is unconditional now, so the live-server + // client suites need an authenticated session. Register a minimal auth + // service that resolves a fixed user for every request. + kernel.use({ + metadata: { name: 'test-auth', version: '1.0.0' }, + async init(ctx: any) { + ctx.registerService('auth', { + api: { getSession: async () => ({ user: { id: 'test-user' } }) }, + }); + }, + } as any); // 2. Setup Hono Plugin // This suite exercises the CLIENT's data operations over the raw-hono @@ -25,7 +36,6 @@ describe('ObjectStackClient (with Hono Server)', () => { const honoPlugin = new HonoServerPlugin({ port: 0, registerStandardEndpoints: true, - restConfig: { api: { requireAuth: false } } as any, }); kernel.use(honoPlugin); diff --git a/packages/core/src/security/anonymous-deny.test.ts b/packages/core/src/security/anonymous-deny.test.ts index d123864a00..63aa2a784d 100644 --- a/packages/core/src/security/anonymous-deny.test.ts +++ b/packages/core/src/security/anonymous-deny.test.ts @@ -3,49 +3,48 @@ // #2567 Phase 2 — the shared anonymous-deny decision. These lock the exact // contract every HTTP seam now delegates to, including the load-bearing // `undefined`-path trap (a naive allowlist call would reopen GraphQL). +// +// [#3963] The `requireAuth` opt-out is gone: an anonymous, non-system caller +// outside the control-plane allowlist is denied unconditionally. There is no +// longer a posture that turns the decision off. import { describe, it, expect } from 'vitest'; import { shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS } from './anonymous-deny.js'; -describe('shouldDenyAnonymous — the shared HTTP anonymous-deny decision (#2567)', () => { - it('no-op when requireAuth is off (demo / single-tenant)', () => { - expect(shouldDenyAnonymous({ requireAuth: false })).toBe(false); - expect(shouldDenyAnonymous({ requireAuth: undefined })).toBe(false); - }); - - it('denies an anonymous caller under requireAuth', () => { - expect(shouldDenyAnonymous({ requireAuth: true })).toBe(true); +describe('shouldDenyAnonymous — the shared HTTP anonymous-deny decision (#2567, #3963)', () => { + it('denies an anonymous caller (unconditionally — no opt-out)', () => { + expect(shouldDenyAnonymous({})).toBe(true); }); it('passes an authenticated caller', () => { - expect(shouldDenyAnonymous({ requireAuth: true, userId: 'u1' })).toBe(false); + expect(shouldDenyAnonymous({ userId: 'u1' })).toBe(false); }); it('passes an internal system context', () => { - expect(shouldDenyAnonymous({ requireAuth: true, isSystem: true })).toBe(false); + expect(shouldDenyAnonymous({ isSystem: true })).toBe(false); }); it('passes an OPTIONS preflight even when anonymous', () => { - expect(shouldDenyAnonymous({ requireAuth: true, method: 'OPTIONS' })).toBe(false); - expect(shouldDenyAnonymous({ requireAuth: true, method: 'options' })).toBe(false); + expect(shouldDenyAnonymous({ method: 'OPTIONS' })).toBe(false); + expect(shouldDenyAnonymous({ method: 'options' })).toBe(false); }); it('exempts a real control-plane path (auth / health)', () => { - expect(shouldDenyAnonymous({ requireAuth: true, path: '/api/v1/auth/login' })).toBe(false); - expect(shouldDenyAnonymous({ requireAuth: true, path: '/api/v1/health' })).toBe(false); + expect(shouldDenyAnonymous({ path: '/api/v1/auth/login' })).toBe(false); + expect(shouldDenyAnonymous({ path: '/api/v1/health' })).toBe(false); }); it('denies a real data path', () => { - expect(shouldDenyAnonymous({ requireAuth: true, path: '/api/v1/data/sys_user' })).toBe(true); + expect(shouldDenyAnonymous({ path: '/api/v1/data/sys_user' })).toBe(true); }); // The trap: isAuthGateAllowlisted(undefined) === true. A body-routed seam // (GraphQL) passes no path; it MUST still deny anonymous, not fall through to // the allowlist. Guards against silently reopening #2567. it('denies when path is undefined/empty (body-routed seam — GraphQL trap guard)', () => { - expect(shouldDenyAnonymous({ requireAuth: true, path: undefined })).toBe(true); - expect(shouldDenyAnonymous({ requireAuth: true, path: null })).toBe(true); - expect(shouldDenyAnonymous({ requireAuth: true, path: '' })).toBe(true); + expect(shouldDenyAnonymous({ path: undefined })).toBe(true); + expect(shouldDenyAnonymous({ path: null })).toBe(true); + expect(shouldDenyAnonymous({ path: '' })).toBe(true); }); it('exposes a stable 401 body + status for seams to return', () => { diff --git a/packages/core/src/security/anonymous-deny.ts b/packages/core/src/security/anonymous-deny.ts index ddf7ec3a0f..a0c228e3be 100644 --- a/packages/core/src/security/anonymous-deny.ts +++ b/packages/core/src/security/anonymous-deny.ts @@ -3,18 +3,32 @@ /** * #2567 — the single anonymous-deny decision, shared by every HTTP seam. * - * ADR-0056 D2 made the platform deny anonymous callers by default - * (`requireAuth`). Phase 1 gated each surface (REST `/data`, dispatcher - * `/graphql` + `/meta`, raw-hono `/data`) but every seam hand-rolled the same - * `!userId && !isSystem → 401` check. This centralises that DECISION into one - * pure, tested function — the exact pattern {@link ./auth-gate.ts} established - * for the ADR-0069 auth-policy gate: keeping the decision in one function means - * the seams can never drift on who is denied. + * ADR-0056 D2 made the platform deny anonymous callers by default. Phase 1 gated + * each surface (REST `/data`, dispatcher `/graphql` + `/meta`, raw-hono `/data`) + * but every seam hand-rolled the same `!userId && !isSystem → 401` check. This + * centralises that DECISION into one pure, tested function — the exact pattern + * {@link ./auth-gate.ts} established for the ADR-0069 auth-policy gate: keeping + * the decision in one function means the seams can never drift on who is denied. * - * It deliberately does NOT own identity resolution or the dynamic exemptions - * (public-form submission, share-link tokens): those run UPSTREAM and set the - * execution context (a `userId`, or `isSystem`) before a seam calls this, so - * this function only ever inspects the already-resolved context. + * ## The `requireAuth` opt-out is gone (#3963) + * + * This used to take a `requireAuth` posture and no-op when it was falsy, so a + * deployment could open its ENTIRE data plane with one config key. That key is + * retired: auth is a kernel concern, and every surface that legitimately serves + * a caller with no session derives its own narrow authorization from a + * DECLARATION instead of from the deployment posture — + * + * - control plane (`/auth/*`, `/health`, `/ready`, `/discovery`, the ADR-0069 + * remediation paths) → the {@link isAuthGateAllowlisted} allowlist, below; + * - public form submission → `publicFormGrant` (ADR-0056 Option A), derived + * from the form view's own declaration; + * - share links → the capability token, validated then read as SYSTEM; + * - a `book.audience: 'public'` read → the ADR-0046 §6.7 audience gate (#3963); + * - MCP → an OAuth token or API key, never anonymous. + * + * Those run UPSTREAM of this function and set the execution context (a `userId`, + * or `isSystem`) or bypass the seam entirely, so this only ever inspects the + * already-resolved context. Nothing else gets in. */ import { isAuthGateAllowlisted } from './auth-gate.js'; @@ -32,8 +46,6 @@ export const ANONYMOUS_DENY_BODY = { } as const; export interface AnonymousDenyInput { - /** The `requireAuth` posture. Falsy ⇒ no-op (demo / single-tenant). */ - requireAuth: boolean | undefined; /** Resolved caller id, if any. */ userId?: string | null; /** Internal system context (never set on inbound HTTP; cannot be forged). */ @@ -54,7 +66,6 @@ export interface AnonymousDenyInput { * seam shares. */ export function shouldDenyAnonymous(input: AnonymousDenyInput): boolean { - if (!input.requireAuth) return false; // posture off if (typeof input.method === 'string' && input.method.toUpperCase() === 'OPTIONS') { return false; // CORS preflight } diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index f8336c13bf..4cd661f22a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2664,8 +2664,8 @@ export class ObjectStackProtocolImplementation implements // it was not swept into the implicit-filter bucket either: a caller's // `context` survived this spread and, because the assignment below is // conditional, became the operation's execution context whenever no - // server context resolved (an anonymous request on a deployment that - // set `requireAuth: false`). + // server context resolved (before #3963 an anonymous request on a + // `requireAuth: false` deployment; the invariant holds regardless). // // What rides on it is total: plugin-security's middleware opens with // `if (opCtx.context?.isSystem) return next()` — the entire RLS / FLS / diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 4f12e461cf..211882c579 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -639,9 +639,10 @@ export class ApprovalService implements IApprovalService { * (having also put them on the context). Those are the only two callers that * hold a trustworthy actor with no session behind them. * - * A caller with NO identity at all cannot act. That case is reachable: the - * REST anonymous-deny only fires when `api.requireAuth` is set, so without it - * an anonymous request previously decided approvals outright by naming one. + * A caller with NO identity at all cannot act. Belt-and-suspenders: the REST + * anonymous-deny now denies every anonymous request (#3963), but this service + * must not rely on a caller upstream — an anonymous actor could otherwise + * decide approvals outright by naming one. */ private async resolveActor( actorId: string | undefined, diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 5d43a72f53..6cc68578b1 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -603,21 +603,22 @@ export class DevPlugin implements Plugin { if (enabled('rest')) { try { const { createRestApiPlugin } = await import('@objectstack/rest') as any; - // Secure-by-default carve-out (mirrors `objectstack serve`, ADR-0056 - // D2): when NO auth mounted in this dev stack (service disabled or - // plugin-auth not installed), nobody could ever authenticate — the - // deny default would brick the local playground's data API. Pass an - // EXPLICIT fail-open for that case; the REST plugin's boot warning - // keeps the posture visible. - const restPlugin = authMounted - ? createRestApiPlugin() - : createRestApiPlugin({ api: { api: { requireAuth: false } } as any }); - this.childPlugins.push(restPlugin); - ctx.logger.info( - authMounted - ? ' ✔ REST API endpoints enabled (CRUD + metadata)' - : ' ✔ REST API endpoints enabled (CRUD + metadata; anonymous ALLOWED — no auth mounted)', - ); + // [#3963] The auth-less fail-open carve-out is gone. It used to pass an + // EXPLICIT `requireAuth: false` when no auth was mounted, on the grounds + // that nobody could authenticate so the deny default would brick the + // playground's data API. That reasoning inverts the right conclusion: a + // stack with no auth has no security model, so it should not serve a data + // API at all — and leaving the back door here would have made the dev + // plugin the one surface that still opens the whole data plane. + if (!authMounted) { + throw new Error( + '[dev] Cannot enable the data API: no auth is mounted in this stack, so no caller could ' + + 'ever authenticate and anonymous access to object data is always denied (#3963). ' + + 'Install/enable plugin-auth (or the `auth` tier), or drop the REST API from this dev stack.', + ); + } + this.childPlugins.push(createRestApiPlugin()); + ctx.logger.info(' ✔ REST API endpoints enabled (CRUD + metadata)'); } catch { ctx.logger.debug(' ℹ @objectstack/rest not installed — skipping REST endpoints'); } 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 index 21392ca8e5..8515d1964a 100644 --- a/packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-anonymous-deny.test.ts @@ -85,12 +85,13 @@ describe('raw-hono /data — anonymous-deny gate (#2567)', () => { expect(res.status).toBe(200); }); - it('explicit opt-out (requireAuth:false) keeps the surface anonymously reachable', async () => { + it('there is no opt-out — an anonymous read is 401 regardless of config (#3963)', async () => { + // `api.requireAuth: false` is retired: it no longer opens the surface. const app = bootStandardEndpoints({ - restConfig: { api: { requireAuth: false } }, + restConfig: { api: { requireAuth: false } as any }, services: { objectql, auth }, }); const res = await app.request(REQ, { method: 'GET' }); - expect(res.status).toBe(200); + expect(res.status).toBe(401); }); }); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index a6f08d7b46..27131cec2e 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -813,29 +813,16 @@ export class HonoServerPlugin implements Plugin { // 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). Delegates the decision to the + // [#3963] Anonymous access to object data is denied unconditionally — + // the `requireAuth` opt-out is retired, so there is no posture to read + // or warn about here. An authenticated / system caller always passes. + // Returns a 401 Response when the caller is anonymous, else null + // (caller proceeds). Delegates the decision to the // shared `shouldDenyAnonymous` (#2567) so every HTTP seam stays in // lockstep. `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 => - shouldDenyAnonymous({ requireAuth, userId: execCtx?.userId, isSystem: execCtx?.isSystem }) + shouldDenyAnonymous({ userId: execCtx?.userId, isSystem: execCtx?.isSystem }) ? c.json(ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS) : null; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index acdaa35c43..683c7ff9ad 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -771,7 +771,7 @@ export class SecurityPlugin implements Plugin { // form's declared target (set by the rest-server form-submit route). It // authorizes ONLY create + the immediate read-back on THAT object — never // anything else, and never the anonymous fall-open. This lets public forms - // work under secure-by-default (requireAuth) WITHOUT a deployment-configured + // work under secure-by-default (anonymous-deny) WITHOUT a deployment-configured // `guest_portal`, scoped to exactly the declared object (the field // allow-list is enforced at the route; the context is request-scoped). const formGrant = opCtx.context?.publicFormGrant; diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index d8e68137fb..dc723e0e26 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -77,15 +77,15 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ { 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. + // just REST `/data`. Each sibling surface that reaches ObjectQL consults the + // same unconditional anonymous-deny (#2567); 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 → shouldDenyAnonymous) — 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', covers: ['meta:rest-server.ts:registerMetadataEndpoints', 'meta:http-dispatcher.ts:handleMetadata'] }, { 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 (shouldDenyAnonymous) on the standard /data routes (requireAuth ?? true, mirroring rest-server.ts)', + enforcement: 'plugin-hono-server/hono-plugin.ts denyAnonymous gate (shouldDenyAnonymous) on the standard /data routes — unconditional anonymous-deny, mirroring rest-server.ts', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', covers: ['data:hono-plugin.ts:POST /data/:object', 'data:hono-plugin.ts:GET /data/:object/:id', 'data:hono-plugin.ts:GET /data/:object'], 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.' }, @@ -180,10 +180,10 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ note: 'REMOVED from spec (system/masking.zod.ts deleted). FLS (plugin-security field-masker) is the enforced field-visibility path; a masking/deny layer would be redesigned with the ADR-0066 ⑦/⑧ muting work anyway, so the dead config was pure drift risk.' }, { id: 'rls-config-global', summary: 'global RLSConfig / RLSAuditEvent', state: 'removed', note: 'REMOVED from spec (rls.zod.ts — RLSConfigSchema/RLSAuditEventSchema/RLSAuditConfigSchema deleted). The enforced RLS path (plugin-security computeRlsFilter) never read them; per-policy RowLevelSecurityPolicySchema is the live surface and is unchanged.' }, - { id: 'requireAuth-default-flip', summary: 'global requireAuth default is secure-by-default (deny anonymous)', state: 'enforced', - enforcement: 'spec/api/rest-server.zod.ts requireAuth default(true) + rest/rest-server.ts normalizeConfig ?? true; explicit requireAuth:false opt-out warns at boot (rest-api-plugin)', + { id: 'requireAuth-removed', summary: 'anonymous access to object data is denied unconditionally (no opt-out)', state: 'enforced', + enforcement: 'core/security/anonymous-deny.ts shouldDenyAnonymous — no `requireAuth` input; every seam denies an anonymous, non-system caller outside the control-plane allowlist. spec tombstones `api.requireAuth` (retiredKey).', proof: 'showcase-anonymous-deny.dogfood.test.ts', - note: 'ADR-0056 D2 flip LANDED. The verify harness boots on the platform default (no override), so anonymous-deny AND public-form survival (showcase-public-form.dogfood.test.ts — the publicFormGrant pre-req that unblocked the flip) are proven on the default posture. Share-links read as SYSTEM after token validation. CLI carve-out: auth-less stacks get an explicit fail-open (warned).' }, + note: 'ADR-0056 D2 → #3963: the `requireAuth: false` opt-out is RETIRED, not merely defaulted-on. Legitimate session-less surfaces survive by DECLARATION, not by posture: public-form submission (publicFormGrant), share-links (token → SYSTEM read), and public-book reads (audience:public, §6.7). A stack that mounts no auth now FAILS AT BOOT (cli/serve.ts, plugin-dev) instead of getting an explicit fail-open.' }, // ── Removed — by ADR-0049 (roadmap M2) ───────────────────────────────── { id: 'allow-transfer-restore-purge', summary: 'transfer/restore/purge ops (RBAC gate pre-mapped)', state: 'removed', diff --git a/packages/qa/http-conformance/src/conformance.integration.test.ts b/packages/qa/http-conformance/src/conformance.integration.test.ts index 75d4f11833..876c28eba6 100644 --- a/packages/qa/http-conformance/src/conformance.integration.test.ts +++ b/packages/qa/http-conformance/src/conformance.integration.test.ts @@ -46,6 +46,14 @@ const ADAPTERS: AdapterCase[] = [ async function bootStack(makePlugin: () => any, opts: { withAnalytics?: boolean } = {}) { const kernel = new LiteKernel(); kernel.use(new ObjectQLPlugin()); + // [#3963] Anonymous-deny is unconditional now; conformance focuses on + // transport parity, so authenticate every request with a stub session. + kernel.use({ + metadata: { name: 'test-auth', version: '1.0.0' }, + init: (c: any) => c.registerService('auth', { + api: { getSession: async () => ({ user: { id: 'test-user' } }) }, + }), + } as any); if (opts.withAnalytics !== false) { kernel.use({ name: 'com.test.analytics-stub', @@ -60,16 +68,8 @@ async function bootStack(makePlugin: () => any, opts: { withAnalytics?: boolean } as any); } kernel.use(makePlugin()); - kernel.use(createRestApiPlugin({ - api: { - api: { - // Conformance focuses on transport parity; the anonymous-deny - // posture is exercised separately with requireAuth on. - requireAuth: false, - } as any, - }, - })); - kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: false })); + kernel.use(createRestApiPlugin({})); + kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false })); await kernel.bootstrap(); diff --git a/packages/rest/src/analytics-routes.test.ts b/packages/rest/src/analytics-routes.test.ts index 6e85cff6f9..1128c23b67 100644 --- a/packages/rest/src/analytics-routes.test.ts +++ b/packages/rest/src/analytics-routes.test.ts @@ -38,6 +38,7 @@ function buildServer(analyticsProvider?: any) { undefined, undefined, undefined, undefined, analyticsProvider, ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); const route = rest.getRoutes().find((r) => r.method === 'POST' && r.path.endsWith('/analytics/dataset/query')); return { route }; diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index c50eab178e..d3407300b8 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -188,6 +188,7 @@ async function boot() { const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); const route = rest.getRoutes().find( (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', @@ -436,7 +437,8 @@ describe('export route — FLS column projection via getReadableFields (#3547)', undefined, // serviceExistsProvider securityServiceProvider, ); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = rest.getRoutes().find( (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', ); diff --git a/packages/rest/src/import-integration.test.ts b/packages/rest/src/import-integration.test.ts index c1667b0144..b3164b207b 100644 --- a/packages/rest/src/import-integration.test.ts +++ b/packages/rest/src/import-integration.test.ts @@ -159,6 +159,7 @@ async function boot() { const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); const route = rest.getRoutes().find( (r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/import', diff --git a/packages/rest/src/import-job-integration.test.ts b/packages/rest/src/import-job-integration.test.ts index 370da4e292..6edf42f451 100644 --- a/packages/rest/src/import-job-integration.test.ts +++ b/packages/rest/src/import-job-integration.test.ts @@ -133,6 +133,7 @@ async function boot(decorateDriver?: (driver: any) => void) { const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); const routes = rest.getRoutes(); const find = (method: string, path: string) => routes.find((r: any) => r.method === method && r.path === path); diff --git a/packages/rest/src/public-form-routes.test.ts b/packages/rest/src/public-form-routes.test.ts index 79b766382d..69cf29f2b9 100644 --- a/packages/rest/src/public-form-routes.test.ts +++ b/packages/rest/src/public-form-routes.test.ts @@ -69,6 +69,7 @@ function buildServer(sections: any[]) { createData, }; const rest = new RestServer(mockServer() as any, protocol, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); const find = (method: string, suffix: string) => rest.getRoutes().find((r) => r.method === method && r.path.endsWith(suffix))!; diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 4eda620961..cb957bfbd1 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -262,27 +262,18 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { ctx.logger.info('REST API successfully registered'); - // ADR-0056 D2 (warn → enforce, ENFORCED): the global default is - // secure-by-default — anonymous /data/* is denied unless the - // deployment explicitly opts out. The warning remains for that - // explicit opt-out so a fail-open posture is always visible. - if ((config.api as any)?.api?.requireAuth === false) { + // [#3963] The `api.requireAuth` opt-out is retired — anonymous + // callers are denied unconditionally, so there is no fail-open + // posture left to warn about (and no misplaced-nesting footgun). + // A surface that legitimately serves a session-less caller now + // derives its own narrow authorization from a declaration; see + // `shouldDenyAnonymous` in @objectstack/core for the list. + if ((config.api as any)?.requireAuth !== undefined + || (config.api as any)?.api?.requireAuth !== undefined) { ctx.logger.warn( - '[security] anonymous access to the data API is ALLOWED (api.requireAuth=false, explicit opt-out) — ' + - 'objects without OWD/RLS are world-readable. Remove the opt-out for secure-by-default and ' + - 'expose public records via share-links / publicSharing / public forms (ADR-0056 D2).', - ); - } - // Misplaced-key guard: the effective key is `api.api.requireAuth` - // (RestApiPluginConfig.api is the full RestServerConfig). A flat - // `api.requireAuth` is silently ignored by normalizeConfig — under - // the deny default that turns an INTENDED public deployment into a - // 401 outage with no diagnostic, so name the mistake loudly. - if ((config.api as any)?.requireAuth !== undefined) { - ctx.logger.warn( - '[security] `api.requireAuth` is set at the WRONG nesting level and has NO effect — ' + - 'move it to `api.api.requireAuth` (RestServerConfig.api.requireAuth). ' + - `The effective value this boot is ${(config.api as any)?.api?.requireAuth ?? true}.`, + '[security] `api.requireAuth` was removed (#3963) and is IGNORED — anonymous access to ' + + 'object data is always denied. Publish public surfaces by declaration instead: a public ' + + 'form view, a share link, or `book.audience: \'public\'`.', ); } } catch (err: any) { diff --git a/packages/rest/src/rest-batch-endpoint.test.ts b/packages/rest/src/rest-batch-endpoint.test.ts index 49a35b0c02..cd22f9e7b2 100644 --- a/packages/rest/src/rest-batch-endpoint.test.ts +++ b/packages/rest/src/rest-batch-endpoint.test.ts @@ -85,6 +85,7 @@ function buildServer(opts: { ql?: any; objects?: any[]; readonlyFields?: string[ undefined, undefined, undefined, undefined, // kernelManager, envRegistry, defaultEnvIdProvider, authServiceProvider objectQLProvider, // objectQLProvider (positional arg #8) ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); const route = rest.getRoutes().find( (r) => r.method === 'POST' && (r.metadata?.summary ?? '').startsWith('Cross-object'), diff --git a/packages/rest/src/rest-batch-size-cap.test.ts b/packages/rest/src/rest-batch-size-cap.test.ts index a531cdd005..546e3e2513 100644 --- a/packages/rest/src/rest-batch-size-cap.test.ts +++ b/packages/rest/src/rest-batch-size-cap.test.ts @@ -52,6 +52,7 @@ function setup(maxBatchSize?: number) { undefined, undefined, undefined, undefined, // kernelManager, envRegistry, defaultEnvIdProvider, authServiceProvider async () => ql, // objectQLProvider (positional arg #8) ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); return { rest, ql, ...spies }; } diff --git a/packages/rest/src/rest-bulk-path-object.test.ts b/packages/rest/src/rest-bulk-path-object.test.ts index e85690199a..7319c161c8 100644 --- a/packages/rest/src/rest-bulk-path-object.test.ts +++ b/packages/rest/src/rest-bulk-path-object.test.ts @@ -53,6 +53,7 @@ function setup() { deleteManyData, }; const rest = new RestServer(createMockServer() as any, protocol, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); return { rest, updateManyData, deleteManyData }; } @@ -101,16 +102,19 @@ describe('bulk writes bind to the object in the PATH (#3933)', () => { }); describe('updateMany ingress validation (#3933)', () => { - it('strips a body-supplied context so the principal cannot be forged', async () => { + it('never forwards a body-supplied context — the caller cannot forge the principal', async () => { const { rest, updateManyData } = setup(); - // `requireAuth: false` → no execution context resolves, so a body `context` - // used to be the only one the protocol (and then the engine) ever saw. + // An authenticated caller (the harness resolves `test-user`) tries to + // escalate by putting `isSystem: true` in the body. The protocol must see + // the RESOLVED context, never the body's — the body `context` is stripped. await post(rest, UPDATE_MANY, 'open', { records: ONE_UPDATE, context: { userId: 'nobody', isSystem: true, roles: ['admin'] }, }); - expect(updateManyData.mock.calls[0][0].context).toBeUndefined(); + const forwarded = updateManyData.mock.calls[0][0].context; + expect(forwarded?.userId).toBe('test-user'); + expect(forwarded?.isSystem).toBeUndefined(); }); it('forwards a well-formed request unchanged', async () => { diff --git a/packages/rest/src/rest-delete-many-ingress.test.ts b/packages/rest/src/rest-delete-many-ingress.test.ts index b81ad398c2..c8e1d48b05 100644 --- a/packages/rest/src/rest-delete-many-ingress.test.ts +++ b/packages/rest/src/rest-delete-many-ingress.test.ts @@ -41,6 +41,7 @@ function setup() { deleteManyData, }; const rest = new RestServer(createMockServer() as any, protocol, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); return { rest, deleteManyData }; } @@ -85,14 +86,15 @@ describe('POST /data/:object/deleteMany — ingress validation (#3897)', () => { expect(req.multi).toBeUndefined(); }); - it('strips a body-supplied context so the principal cannot be forged', async () => { + it('never forwards a body-supplied context — the caller cannot forge the principal', async () => { const { rest, deleteManyData } = setup(); - // This route is reachable with `requireAuth: false`, so no execution - // context is resolved — a body `context` would previously have been the - // only one the protocol saw. + // An authenticated caller (the harness resolves `test-user`) puts a forged + // principal in the body; the protocol must see the RESOLVED context, never + // the body's. await post(rest, { ids: ['a'], context: { userId: 'root', roles: ['admin'] } }); - expect(deleteManyData.mock.calls[0][0].context).toBeUndefined(); + const forwarded = deleteManyData.mock.calls[0][0].context; + expect(forwarded?.userId).toBe('test-user'); }); it('keeps the legitimate BatchOptions keys', async () => { diff --git a/packages/rest/src/rest-dropped-fields.test.ts b/packages/rest/src/rest-dropped-fields.test.ts index 8a8799b70d..b72ca28300 100644 --- a/packages/rest/src/rest-dropped-fields.test.ts +++ b/packages/rest/src/rest-dropped-fields.test.ts @@ -37,6 +37,7 @@ function buildServer(protocolOverrides: Record) { ...protocolOverrides, }; const rest = new RestServer(mockServer() as any, protocol, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); const find = (method: string, suffix: string) => rest.getRoutes().find((r) => r.method === method && r.path.endsWith(suffix))!; diff --git a/packages/rest/src/rest-env-resolution.test.ts b/packages/rest/src/rest-env-resolution.test.ts index 6aa3d640e1..f62166dc9c 100644 --- a/packages/rest/src/rest-env-resolution.test.ts +++ b/packages/rest/src/rest-env-resolution.test.ts @@ -295,8 +295,11 @@ describe('RestApiPlugin kernel-resolver adapter (D11④)', () => { setHeader: vi.fn(), headersSent: false, }; + // No resolver, no crash — and the shared anonymous-deny gate applies even in + // single-environment OSS mode: an anonymous data read is 401, never served + // (#3963). The control protocol is only reached once a caller authenticates. await listRoute![1]({ params: { object: 'account' }, query: {}, headers: {} }, res); - // Served by the boot-time control protocol — no resolver, no crash. - expect(services.protocol.findData).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(services.protocol.findData).not.toHaveBeenCalled(); }); }); diff --git a/packages/rest/src/rest-export-permission-gate.test.ts b/packages/rest/src/rest-export-permission-gate.test.ts index 5bb141e0fb..6715c3f1e3 100644 --- a/packages/rest/src/rest-export-permission-gate.test.ts +++ b/packages/rest/src/rest-export-permission-gate.test.ts @@ -86,6 +86,7 @@ function boot(security: any) { undefined, // serviceExistsProvider security === undefined ? undefined : async () => security, ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); rest.registerRoutes(); const route = rest.getRoutes().find( (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', @@ -185,7 +186,8 @@ describe('export route — user-level export axis (#3544)', () => { undefined, undefined, async () => ({ canExport: async () => false }), ); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = rest.getRoutes().find( (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', ); diff --git a/packages/rest/src/rest-meta-auth.test.ts b/packages/rest/src/rest-meta-auth.test.ts index 53394e1614..26f6cf56c8 100644 --- a/packages/rest/src/rest-meta-auth.test.ts +++ b/packages/rest/src/rest-meta-auth.test.ts @@ -35,10 +35,10 @@ const metaListHandler = (rest: RestServer) => { return route.handler as (req: any, res: any) => Promise; }; -describe('RestServer metadata routes — requireAuth gate', () => { - it('401s an anonymous caller when requireAuth is on', async () => { +describe('RestServer metadata routes — anonymous-deny gate (#3963)', () => { + it('401s an anonymous caller (unconditional — no opt-out)', async () => { const protocol: any = { getMetaItems: vi.fn().mockResolvedValue({ type: 'object', items: [] }) }; - const rest = new RestServer(makeServer() as any, protocol, { api: { requireAuth: true } } as any); + const rest = new RestServer(makeServer() as any, protocol, {} as any); rest.registerRoutes(); const handler = metaListHandler(rest); @@ -53,7 +53,7 @@ describe('RestServer metadata routes — requireAuth gate', () => { it('lets an authenticated caller read metadata (gate passes through)', async () => { const protocol: any = { getMetaItems: vi.fn().mockResolvedValue({ type: 'object', items: [{ name: 'sys_metadata' }] }) }; - const rest = new RestServer(makeServer() as any, protocol, { api: { requireAuth: true } } as any); + const rest = new RestServer(makeServer() as any, protocol, {} as any); // A resolved session — the same shape resolveExecCtx yields for a // signed-in request (per-env session via hostname / scoped id). (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' }); @@ -67,16 +67,20 @@ describe('RestServer metadata routes — requireAuth gate', () => { expect(protocol.getMetaItems).toHaveBeenCalled(); }); - it('serves anonymously when requireAuth is off (unchanged public behaviour)', async () => { + it('still 401s an anonymous /meta/object — the opt-out is retired (#3963)', async () => { + // `api.requireAuth: false` used to serve object schemas anonymously. The + // opt-out is gone: object metadata is never public. (Only the book/doc + // read surface is anonymously reachable, and only for `audience:'public'` + // — proven in meta-public-book-grant.test.ts.) const protocol: any = { getMetaItems: vi.fn().mockResolvedValue({ type: 'object', items: [] }) }; - const rest = new RestServer(makeServer() as any, protocol, { api: { requireAuth: false } } as any); + const rest = new RestServer(makeServer() as any, protocol, {} as any); rest.registerRoutes(); const handler = metaListHandler(rest); const { res, state } = makeRes(); await handler({ method: 'GET', params: { type: 'object' }, query: {}, headers: {} }, res); - expect(state.status).not.toBe(401); - expect(protocol.getMetaItems).toHaveBeenCalled(); + expect(state.status).toBe(401); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); }); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 50c798e3fb..21af7717ac 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -860,7 +860,6 @@ type NormalizedRestServerConfig = { enableSearch?: boolean; enableProjectScoping: boolean; projectResolution: 'required' | 'optional' | 'auto'; - requireAuth: boolean; documentation: RestApiConfig['documentation']; responseFormat: RestApiConfig['responseFormat']; }; @@ -1223,8 +1222,9 @@ export class RestServer { } /** - * Reject anonymous requests with HTTP 401 when `api.requireAuth` is set. - * Returns `true` if the response was sent and the caller should stop + * Reject anonymous requests with HTTP 401 — unconditionally (#3963: the + * `api.requireAuth` opt-out is retired). Returns `true` if the response was + * sent and the caller should stop * processing. Returns `false` to continue. * * The check is intentionally narrow: only `context?.userId` counts as @@ -1233,7 +1233,7 @@ export class RestServer { */ private enforceAuth(req: any, res: any, context: any): boolean { // ADR-0069 — authentication-policy gate (password expiry, enforced MFA). - // Independent of `requireAuth`: a gated session (carrying `authGate`) is + // Independent of the anonymous-deny: a gated session (carrying `authGate`) is // blocked from protected resources, while the core allow-list keeps auth // + remediation reachable. Runs before the anonymous check. const gate = context?.authGate; @@ -1247,7 +1247,6 @@ export class RestServer { // exactly as before (the allowlist is reserved for a future umbrella // seam). `isSystem` is never set on inbound HTTP, so it cannot bypass. if (shouldDenyAnonymous({ - requireAuth: this.config.api.requireAuth, userId: context?.userId, isSystem: context?.isSystem, method: req?.method, @@ -2059,7 +2058,6 @@ export class RestServer { enableSearch: (api as any).enableSearch ?? true, enableProjectScoping: api.enableProjectScoping ?? false, projectResolution: api.projectResolution ?? 'auto', - requireAuth: (api as any).requireAuth ?? true, // secure-by-default (ADR-0056 D2; mirrors RestApiConfigSchema) documentation: api.documentation, responseFormat: api.responseFormat, }, @@ -2547,23 +2545,22 @@ export class RestServer { } /** - * Register the metadata routes behind the SAME `requireAuth` gate the + * Register the metadata routes behind the SAME anonymous-deny gate the * `/data` routes use. * * `registerMetadataEndpoints` builds ~17 `/meta/*` routes but — unlike the * `/data` handlers — never calls {@link enforceAuth}: its handlers assumed - * the `requireAuth` gate rejected anonymous callers "upstream", yet nothing - * upstream covers `/meta`, so on a `requireAuth` deployment an anonymous - * caller could read object / field schemas. On a tenant-less runtime host - * those are SYSTEM-object schemas and the host is publicly reachable — a - * real leak. + * the anonymous-deny rejected anonymous callers "upstream", yet nothing + * upstream covers `/meta`, so an anonymous caller could read object / field + * schemas. On a tenant-less runtime host those are SYSTEM-object schemas and + * the host is publicly reachable — a real leak. * * Rather than add the gate to every handler (and have the next new route * forget it — the exact failure mode that caused this), wrap the route * registrar for the duration of registration so every meta route, present - * and future, inherits it. The check is a no-op when `requireAuth` is off - * (demo / single-tenant), so the previously-public metadata surface there - * is unchanged; an authenticated user passes exactly as on `/data`. + * and future, inherits it. An authenticated user passes exactly as on + * `/data`; the one exception is the declaration-derived public-book read + * (#3963), handled just below. */ private registerMetadataEndpoints(basePath: string): void { const realRouteManager = this.routeManager; @@ -2582,7 +2579,7 @@ export class RestServer { const context = await this.resolveExecCtx(environmentId, req).catch(() => undefined); // [#3963] `audience: 'public'` is a DECLARED capability, so it // must not depend on a deployment flipping its whole data plane - // open (`requireAuth: false`). An anonymous read of the + // open. An anonymous read of the // book/doc surface skips the anonymous-deny and is authorized // instead by the ADR-0046 §6.7 audience gate inside the handler // — the same declaration-derived shape ADR-0056 Option A chose @@ -2759,7 +2756,7 @@ export class RestServer { // privileged apps (Studio, Setup, etc.) and gated nav // items are stripped before reaching the client. We // intentionally leave anonymous responses untouched — - // the existing `requireAuth` gate (when enabled) blocks + // the anonymous-deny gate blocks // them upstream; when disabled, the demo / public // surface keeps its prior behaviour. // @@ -5094,14 +5091,14 @@ export class RestServer { * GET {basePath}/forms/:slug → resolved form spec * POST {basePath}/forms/:slug/submit → INSERT record (no auth required) * - * Both routes bypass `enforceAuth` even when `requireAuth=true` on the + * Both routes bypass `enforceAuth` even though anonymous-deny is on for the * deployment (e.g. ObjectOS multi-tenant). Security is delegated to the * `guest_portal` permission set carried on the execution context — the * SecurityPlugin enforces INSERT-only access to the target object. If * the deployment hasn't registered a `guest_portal` profile, the * security middleware falls open with `permissions: []` (no userId), * matching the existing anonymous-access semantics; deployers must - * keep `requireAuth=true` deployments paired with a `guest_portal` + * keep secure-by-default deployments paired with a `guest_portal` * profile (the CRM example does this) to enforce the INSERT-only * contract. * @@ -5373,7 +5370,7 @@ export class RestServer { // form — a narrow create grant scoped to exactly this form's target // object. The SecurityPlugin honors `publicFormGrant` (create + the // immediate read-back, that object ONLY), so public forms work under - // secure-by-default (requireAuth) WITHOUT a deployment-configured + // secure-by-default (anonymous-deny) WITHOUT a deployment-configured // `guest_portal`. `guest_portal` + `anonymous` are kept for back-compat // with object hooks (guest detection via falsy `ctx.user?.id`). const context: any = { @@ -5731,8 +5728,8 @@ export class RestServer { const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; if (!context?.userId) { - // Even on requireAuth=false deployments the explain surface - // stays authenticated-only — it is an admin diagnosis tool. + // The explain surface stays authenticated-only — it is an + // admin diagnosis tool. (Anonymous is already 401ed above.) return res.status(401).json({ code: 'UNAUTHORIZED', message: 'The access-explanation endpoint requires an authenticated caller.', @@ -7128,7 +7125,7 @@ export class RestServer { // (ADR-0049) was enforced on A while B was written. Zod also // strips unknown keys, which keeps a body `context` from // becoming the execution context on a deployment where none - // resolves (anonymous-reachable `requireAuth: false`). + // resolves (e.g. an anonymous public-book read, #3963). const { UpdateManyDataRequestSchema } = await import('@objectstack/spec/api'); const updateManyInput = { ...(req.body ?? {}), object: req.params.object }; const parsedUpdate = (UpdateManyDataRequestSchema as any).safeParse(updateManyInput); diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index a3d377dde3..18b04b4611 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -311,7 +311,8 @@ describe('RestServer', () => { describe('registerRoutes', () => { it('should register discovery, metadata, UI, CRUD, and batch routes by default', () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const routes = rest.getRoutes(); expect(routes.length).toBeGreaterThan(0); @@ -331,7 +332,8 @@ describe('RestServer', () => { const rest = new RestServer(server as any, protocol as any, { api: { apiPath: '/custom/path' }, } as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const paths = rest.getRoutes().map((r) => r.path); expect(paths.some((p) => p.startsWith('/custom/path'))).toBe(true); @@ -341,7 +343,8 @@ describe('RestServer', () => { const rest = new RestServer(server as any, protocol as any, { api: { enableCrud: false }, } as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const tags = rest.getRoutes().flatMap((r) => r.metadata?.tags ?? []); expect(tags).not.toContain('crud'); @@ -351,7 +354,8 @@ describe('RestServer', () => { const rest = new RestServer(server as any, protocol as any, { api: { enableMetadata: false }, } as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const routes = rest.getRoutes(); // Only the PUT /meta/:type/:name is always registered, but enableMetadata=false @@ -363,7 +367,8 @@ describe('RestServer', () => { const rest = new RestServer(server as any, protocol as any, { api: { enableDiscovery: false }, } as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const routes = rest.getRoutes(); // Neither basePath nor basePath/discovery should be registered @@ -377,7 +382,8 @@ describe('RestServer', () => { const rest = new RestServer(server as any, protocol as any, { api: { enableBatch: false }, } as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const tags = rest.getRoutes().flatMap((r) => r.metadata?.tags ?? []); expect(tags).not.toContain('batch'); @@ -390,7 +396,8 @@ describe('RestServer', () => { protocol.deleteManyData = vi.fn().mockResolvedValue([]); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const batchRoutes = rest.getRoutes().filter((r) => r.metadata?.tags?.includes('batch'), @@ -400,7 +407,8 @@ describe('RestServer', () => { it('should register UI view endpoint when enableUi is true', () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const uiRoutes = rest.getRoutes().filter((r) => r.metadata?.tags?.includes('ui'), @@ -410,7 +418,8 @@ describe('RestServer', () => { it('should not register i18n endpoints (i18n routes are self-registered by service-i18n)', () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const i18nRoutes = rest.getRoutes().filter((r) => r.metadata?.tags?.includes('i18n'), @@ -446,7 +455,8 @@ describe('RestServer', () => { it('should pass expand and select query params to protocol.getData', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const getByIdRoute = getRoute(rest, ':object/:id'); expect(getByIdRoute).toBeDefined(); @@ -481,7 +491,8 @@ describe('RestServer', () => { it('should omit expand/select when not present in query', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const getByIdRoute = getRoute(rest, ':object/:id'); @@ -504,7 +515,8 @@ describe('RestServer', () => { // Should NOT have expand or select keys in the call const callArg = protocol.getData.mock.calls[protocol.getData.mock.calls.length - 1][0]; - expect(callArg).toEqual({ object: 'contact', id: 'c_1' }); + // expand/select absent; the resolved caller context IS forwarded (#3963). + expect(callArg).toEqual({ object: 'contact', id: 'c_1', context: { userId: 'test-user' } }); }); }); @@ -518,13 +530,15 @@ describe('RestServer', () => { it('registers the clone route alongside CRUD', () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); expect(cloneRoute(rest)).toBeDefined(); }); it('forwards object/id and nested overrides to protocol.cloneData and responds 201', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = cloneRoute(rest); const req = { params: { object: 'account', id: 'a1' }, body: { overrides: { name: 'Copy' } } }; @@ -539,7 +553,8 @@ describe('RestServer', () => { it('treats a bare body (no `overrides` key) as the override map', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = cloneRoute(rest); const req = { params: { object: 'account', id: 'a1' }, body: { name: 'Bare' } }; @@ -553,7 +568,8 @@ describe('RestServer', () => { it('maps a CLONE_DISABLED protocol error to 403', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = cloneRoute(rest); protocol.cloneData.mockRejectedValueOnce( @@ -570,7 +586,8 @@ describe('RestServer', () => { it('returns 501 when the protocol does not implement cloneData', async () => { const noClone = { ...protocol, cloneData: undefined }; const rest = new RestServer(server as any, noClone as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = cloneRoute(rest); const req = { params: { object: 'account', id: 'a1' }, body: {} }; @@ -596,7 +613,8 @@ describe('RestServer', () => { it('GET /meta/:type forwards previewDrafts to protocol.getMetaItems', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type'); expect(route).toBeDefined(); @@ -611,7 +629,8 @@ describe('RestServer', () => { it('GET /meta/:type omits previewDrafts without the flag', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type'); await route!.handler( @@ -627,7 +646,8 @@ describe('RestServer', () => { // keyed on the published checksum). protocol.getMetaItemCached = vi.fn(); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type/:name'); expect(route).toBeDefined(); @@ -649,7 +669,8 @@ describe('RestServer', () => { // silently lost. protocol.getMetaItemCached = vi.fn(); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type/:name'); expect(route).toBeDefined(); @@ -691,7 +712,7 @@ describe('RestServer', () => { it('tree: anonymous caller is 401ed off a non-public book', async () => { protocol.getMetaItems = metaByType(); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree'); const res = mockRes(); await route!.handler({ params: { name: 'org_book' }, query: {}, headers: {} }, res); @@ -701,7 +722,7 @@ describe('RestServer', () => { it('tree: anonymous caller gets a public book, with non-public docs filtered OUT of the tree', async () => { protocol.getMetaItems = metaByType(); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree'); const res = mockRes(); await route!.handler({ params: { name: 'pub_book' }, query: {}, headers: {} }, res); @@ -719,7 +740,7 @@ describe('RestServer', () => { protocol.getMetaItems = metaByType(); const rest = new RestServer(server as any, protocol as any, ANON_API as any); (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' }); - rest.registerRoutes(); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree'); const res = mockRes(); await route!.handler({ params: { name: 'gated_book' }, query: {}, headers: {} }, res); @@ -733,7 +754,7 @@ describe('RestServer', () => { (rest as any).securityServiceProvider = async () => ({ resolvePermissionSetNames: async () => ['member_default', 'crm_admin'], }); - rest.registerRoutes(); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree'); const res = mockRes(); await route!.handler({ params: { name: 'gated_book' }, query: {}, headers: {} }, res); @@ -747,7 +768,7 @@ describe('RestServer', () => { it('list: anonymous /meta/book returns only public books', async () => { protocol.getMetaItems = metaByType(); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type'); const res = mockRes(); await route!.handler({ params: { type: 'book' }, query: {}, headers: {} }, res); @@ -758,7 +779,7 @@ describe('RestServer', () => { it('list: anonymous /meta/doc returns only docs whose effective audience is public', async () => { protocol.getMetaItems = metaByType(); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type'); const res = mockRes(); await route!.handler({ params: { type: 'doc' }, query: {}, headers: {} }, res); @@ -773,7 +794,7 @@ describe('RestServer', () => { (rest as any).securityServiceProvider = async () => ({ resolvePermissionSetNames: async () => ['member_default'], }); - rest.registerRoutes(); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type'); const res = mockRes(); await route!.handler({ params: { type: 'doc' }, query: {}, headers: {} }, res); @@ -786,7 +807,7 @@ describe('RestServer', () => { protocol.getMetaItem = vi.fn().mockImplementation(async ({ name }: any) => DOCS.find((d) => d.name === name)); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type/:name'); const resDenied = mockRes(); await route!.handler({ params: { type: 'doc', name: 'org_intro' }, query: {}, headers: {} }, resDenied); @@ -803,7 +824,7 @@ describe('RestServer', () => { protocol.getMetaItem = vi.fn().mockResolvedValue(BOOKS[2]); const rest = new RestServer(server as any, protocol as any, ANON_API as any); (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' }); - rest.registerRoutes(); + rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type/:name'); const res = mockRes(); await route!.handler({ params: { type: 'book', name: 'gated_book' }, query: {}, headers: {} }, res); @@ -839,7 +860,8 @@ describe('RestServer', () => { notModified: false, }); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getMetaItemRoute(rest); expect(route).toBeDefined(); @@ -866,7 +888,8 @@ describe('RestServer', () => { notModified: false, }); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getMetaItemRoute(rest); const res = mockRes(); @@ -890,7 +913,9 @@ describe('RestServer', () => { it('should pass query params including expand to protocol.findData', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const listRoute = getListRoute(rest); expect(listRoute).toBeDefined(); @@ -915,12 +940,14 @@ describe('RestServer', () => { expect(protocol.findData).toHaveBeenCalledWith({ object: 'order_item', query: { expand: 'order,product', top: '10' }, + context: { userId: 'test-user' }, }); }); it('should pass populate query param to protocol.findData', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const listRoute = getListRoute(rest); @@ -944,6 +971,7 @@ describe('RestServer', () => { expect(protocol.findData).toHaveBeenCalledWith({ object: 'task', query: { populate: 'assignee,project' }, + context: { userId: 'test-user' }, }); }); }); @@ -975,7 +1003,8 @@ describe('RestServer', () => { it('streams CSV with header row and quotes risky cells', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getExportRoute(rest); expect(route).toBeDefined(); @@ -1007,7 +1036,8 @@ describe('RestServer', () => { it('streams JSON array when format=json', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getExportRoute(rest); protocol.findData.mockResolvedValueOnce({ @@ -1028,7 +1058,8 @@ describe('RestServer', () => { it('rejects invalid JSON in filter query', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getExportRoute(rest); const { res } = makeRes(); @@ -1041,7 +1072,8 @@ describe('RestServer', () => { it('honours the hard 50k row cap', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getExportRoute(rest); // Always return full chunks so the loop is bounded only by `limit`. @@ -1111,7 +1143,8 @@ describe('RestServer', () => { it('formats values readably in CSV using field metadata', async () => { const p = protocolWithSchema([RAW_TASK_ROW]); const rest = new RestServer(server as any, p as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getExportRoute(rest); const { res, chunks, headers } = makeRes(); @@ -1128,7 +1161,8 @@ describe('RestServer', () => { it('omits the header row when header=false', async () => { const p = protocolWithSchema([RAW_TASK_ROW]); const rest = new RestServer(server as any, p as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getExportRoute(rest); const { res, chunks } = makeRes(); @@ -1142,7 +1176,8 @@ describe('RestServer', () => { it('injects $expand for reference fields into the findData query', async () => { const p = protocolWithSchema([RAW_TASK_ROW]); const rest = new RestServer(server as any, p as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getExportRoute(rest); const { res } = makeRes(); @@ -1156,7 +1191,8 @@ describe('RestServer', () => { it('formats values readably in JSON, leaving unknown keys untouched', async () => { const p = protocolWithSchema([{ ...RAW_TASK_ROW, extra: 'keep' }]); const rest = new RestServer(server as any, p as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getExportRoute(rest); const { res, chunks, headers } = makeRes(); @@ -1172,7 +1208,8 @@ describe('RestServer', () => { it('streams a valid xlsx workbook with formatted cells', async () => { const p = protocolWithSchema([RAW_TASK_ROW]); const rest = new RestServer(server as any, p as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getExportRoute(rest); const { res, getBuffer, headers } = makeBinRes(); @@ -1203,7 +1240,8 @@ describe('RestServer', () => { // id) and 404s with RECORD_NOT_FOUND instead of streaming the export. it('registers GET /export ahead of the greedy GET /:object/:id matcher', () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const routes = rest.getRoutes(); const exportIdx = routes.findIndex( @@ -1259,7 +1297,8 @@ describe('RestServer', () => { undefined, undefined, undefined, undefined, undefined, async () => i18n as any, ); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getExportRoute(rest); // locale=zh → Chinese header; `id` has no override so it keeps its label. @@ -1296,7 +1335,8 @@ describe('RestServer', () => { it('returns 501 when no email service provider is wired', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getEmailRoute(rest); expect(route).toBeDefined(); const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; @@ -1313,7 +1353,8 @@ describe('RestServer', () => { undefined, undefined, undefined, undefined, undefined, provider as any, ); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getEmailRoute(rest); const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; await route!.handler({ @@ -1333,7 +1374,8 @@ describe('RestServer', () => { undefined, undefined, undefined, undefined, undefined, provider as any, ); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getEmailRoute(rest); const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; await route!.handler({ body: { to: 'a@b.com', subject: '', text: 'x' } } as any, res as any); @@ -1360,7 +1402,8 @@ describe('RestServer', () => { it('returns 501 when no sharing service provider is wired', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const { list, grant, revoke } = getShareRoutes(rest); expect(list && grant && revoke).toBeDefined(); for (const route of [list, grant, revoke]) { @@ -1378,7 +1421,8 @@ describe('RestServer', () => { undefined, undefined, undefined, undefined, undefined, undefined, provider as any, ); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const { list } = getShareRoutes(rest); const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; await list!.handler({ params: { object: 'account', id: 'a1' } } as any, res as any); @@ -1394,7 +1438,8 @@ describe('RestServer', () => { undefined, undefined, undefined, undefined, undefined, undefined, provider as any, ); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const { grant: route } = getShareRoutes(rest); const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; await route!.handler({ @@ -1416,7 +1461,8 @@ describe('RestServer', () => { undefined, undefined, undefined, undefined, undefined, undefined, provider as any, ); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const { grant: route } = getShareRoutes(rest); const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; await route!.handler({ params: { object: 'account', id: 'a1' }, body: {} } as any, res as any); @@ -1432,7 +1478,8 @@ describe('RestServer', () => { undefined, undefined, undefined, undefined, undefined, undefined, provider as any, ); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const { revoke: route } = getShareRoutes(rest); const res = { json: vi.fn(), status: vi.fn().mockReturnThis(), end: vi.fn() }; await route!.handler({ params: { object: 'account', id: 'a1', shareId: 'shr_X' } } as any, res as any); @@ -1471,7 +1518,8 @@ describe('RestServer', () => { undefined, undefined, undefined, undefined, undefined, undefined, undefined, provider, ); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); return rest; } @@ -1690,7 +1738,8 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. const protocol = createMockProtocol(); protocol.saveMetaItem = vi.fn().mockResolvedValue({ success: true }); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getPutRoute(rest, '/api/v1/meta/:type/:name'); expect(route).toBeDefined(); @@ -1719,7 +1768,8 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. const protocol = createMockProtocol(); protocol.saveMetaItem = vi.fn().mockResolvedValue({ success: true }); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getPutRoute(rest, '/api/v1/meta/:type/:name'); await route.handler( @@ -1741,7 +1791,8 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. const protocol = createMockProtocol(); protocol.saveMetaItem = vi.fn().mockResolvedValue({ success: true }); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getPutRoute(rest, '/api/v1/meta/:type/:name'); await route.handler( @@ -1766,7 +1817,8 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. err.status = 409; protocol.saveMetaItem = vi.fn().mockRejectedValue(err); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const route = getPutRoute(rest, '/api/v1/meta/:type/:name'); const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; @@ -1788,7 +1840,8 @@ describe('RestServer project-scoped routing', () => { const server = createMockServer(); const protocol = createMockProtocol(); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const paths = rest.getRoutes().map(r => r.path); expect(paths).toContain('/api/v1/data/:object'); @@ -1805,7 +1858,8 @@ describe('RestServer project-scoped routing', () => { const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false, enableProjectScoping: true, projectResolution: 'auto' } as any, } as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const paths = rest.getRoutes().map(r => r.path); expect(paths).toContain('/api/v1/data/:object'); @@ -1820,7 +1874,8 @@ describe('RestServer project-scoped routing', () => { const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false, enableProjectScoping: true, projectResolution: 'required' } as any, } as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const paths = rest.getRoutes().map(r => r.path); expect(paths).toContain('/api/v1/environments/:environmentId/data/:object'); @@ -1833,7 +1888,8 @@ describe('RestServer project-scoped routing', () => { const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false, enableProjectScoping: true, projectResolution: 'required' } as any, } as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const listRoute = rest .getRoutes() @@ -1857,7 +1913,8 @@ describe('RestServer project-scoped routing', () => { const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false, enableProjectScoping: true, projectResolution: 'auto' } as any, } as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const unscoped = rest .getRoutes() @@ -2433,6 +2490,8 @@ describe('discovery — routes.mcp (ADR-0036, #152)', () => { const protocol = createMockProtocol(); // protocol discovery carries a `routes` object the server augments. (protocol.getDiscovery as any) = vi.fn().mockResolvedValue({ routes: { data: '', metadata: '' } }); + // Discovery is a control-plane route (auth-gate allowlisted), so no + // authenticated context is needed here. const rest = new RestServer( server as any, protocol as any, ANON_API as any, undefined, // kernelManager @@ -2549,7 +2608,8 @@ describe('discovery — capabilities.transactionalBatch (#3298)', () => { protocol as any, { api: opts.api ?? { requireAuth: false } } as any, ); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); const entry = rest.getRouteManager().get('GET', '/api/v1/discovery'); if (!entry) throw new Error('discovery route not registered'); return entry.handler as (req: any, res: any) => Promise; @@ -2915,7 +2975,8 @@ describe('RestServer — object API exposure (apiEnabled / apiMethods)', () => { : [{ name: 'widget', enable }], ); const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); - rest.registerRoutes(); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); return { rest, protocol }; } async function invoke(rest: any, method: string, path: string, params: any, body?: any) { diff --git a/packages/rest/src/security-routes.test.ts b/packages/rest/src/security-routes.test.ts index 05fcf1576c..2f0f9bc9ca 100644 --- a/packages/rest/src/security-routes.test.ts +++ b/packages/rest/src/security-routes.test.ts @@ -86,14 +86,17 @@ describe('GET/POST /security/explain (ADR-0090 D6)', () => { expect(explain).toHaveBeenCalledWith({ object: 'task', operation: 'read' }, CALLER); }); - it('stays authenticated-only even on requireAuth=false deployments', async () => { + it('is authenticated-only — an anonymous caller is 401ed before explain runs', async () => { const explain = vi.fn(); const { post } = buildServer(async () => ({ explain }), { callerCtx: undefined }); const res = mockRes(); await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'read' } } as any, res); + // The shared anonymous-deny gate (#3963) catches this first now, so the code + // is the uniform UNAUTHENTICATED rather than the route's own UNAUTHORIZED — + // both 401, and explain never runs either way. expect(res.statusCode).toBe(401); - expect(res.body.code).toBe('UNAUTHORIZED'); + expect(res.body.error).toBe('UNAUTHENTICATED'); expect(explain).not.toHaveBeenCalled(); }); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 498a522a3b..61c268cfe0 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -52,22 +52,6 @@ export interface DispatcherPluginConfig { */ enforceProjectMembership?: boolean; - /** - * Reject anonymous requests to `auth: true` service routes (AI, etc.), the - * `/meta` catch-all 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 or read - * - * 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; - /** * Security response headers. When provided, every response routed * through this plugin gets the headers merged in (route-specific @@ -136,7 +120,6 @@ function mountRouteOnServer( routePath: string, securityHeaders?: Record, resolveUser?: (headers: Record) => Promise, - requireAuth = false, ): boolean { const handler = async (req: any, res: any) => { try { @@ -156,10 +139,11 @@ function mountRouteOnServer( // Enforce the route's declared `auth` contract. This used to be // assumed to run "separately"/upstream, but nothing did: an // anonymous caller reached `auth: true` handlers (e.g. - // `GET /ai/status`) and got adapter/model config back. Gate here - // when the deployment requires auth. Off (or `auth: false`) → the - // handler runs as before. - if (requireAuth && route.auth !== false && !user) { + // `GET /ai/status`) and got adapter/model config back. [#3963] The + // gate is unconditional now — the deployment-wide `requireAuth` + // opt-out is retired, so only a route declaring `auth: false` opens + // itself, and it does so by declaration. + if (route.auth !== false && !user) { res.status(401); if (securityHeaders) { for (const [k, v] of Object.entries(securityHeaders)) res.header(k, v); @@ -480,31 +464,14 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // Tests / single-tenant deploys can opt out via the explicit flag. const enforceMembership = config.enforceProjectMembership ?? (config.scoping?.enableProjectScoping ?? false); - // 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. - // - // Defaults to `true` — matching `rest-server.ts`'s `?? true` - // (ADR-0056 D2). The dispatcher gates the same object data as REST - // through sibling surfaces (`/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 - // 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 ?? true; - if (!requireAuth) { - ctx.logger?.warn?.( - '[dispatcher] requireAuth is OFF — /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).', - ); - } + // [#3963] Anonymous callers are denied unconditionally on every + // surface that reaches object data — the deployment-wide opt-out is + // retired, so there is nothing to resolve or warn about here. The + // dispatcher gates the same data as REST through sibling surfaces + // (`/ai`, the `/meta` catch-all, service routes), and by-surface + // consistency is exactly what #2567 established. const dispatcher = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: enforceMembership, - requireAuth, }); const prefix = config.prefix || '/api/v1'; @@ -1213,11 +1180,11 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu let count = 0; if (enableProjectScoping && projectResolution === 'required') { - if (mountRouteOnServer(route, server, toScopedPath(routePath), securityHeaders, resolveRequestUser, requireAuth)) count++; + if (mountRouteOnServer(route, server, toScopedPath(routePath), securityHeaders, resolveRequestUser)) count++; } else { - if (mountRouteOnServer(route, server, routePath, securityHeaders, resolveRequestUser, requireAuth)) count++; + if (mountRouteOnServer(route, server, routePath, securityHeaders, resolveRequestUser)) count++; if (enableProjectScoping) { - if (mountRouteOnServer(route, server, toScopedPath(routePath), securityHeaders, resolveRequestUser, requireAuth)) count++; + if (mountRouteOnServer(route, server, toScopedPath(routePath), securityHeaders, resolveRequestUser)) count++; } } return count; diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index ada05c01f8..0c9fe32ee0 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -143,8 +143,6 @@ export interface DomainHandlerDeps { resolveProjectKernelObjectQL(context: HttpProtocolContext): Promise; /** True when a host KernelResolver is registered (multi-tenant deployment). */ isMultiTenantHost(): boolean; - /** The deployment's `requireAuth` posture (lazily read — construction-order safe). */ - isAuthRequired(): boolean; /** * The AI route table the AI plugin caches on the request kernel * (`__aiRoutes`); undefined until the plugin initializes it. diff --git a/packages/runtime/src/domains/ai.ts b/packages/runtime/src/domains/ai.ts index 2af65bf19a..674061b5fa 100644 --- a/packages/runtime/src/domains/ai.ts +++ b/packages/runtime/src/domains/ai.ts @@ -95,9 +95,9 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, // passes, matching the REST `enforceAuth` seam. Off → unchanged. if (route.auth !== false) { const gec: any = context.executionContext; - // `requireAuth && route.auth !== false` is the AI-route contract; - // the shared function owns the anonymous decision itself. - if (shouldDenyAnonymous({ requireAuth: deps.isAuthRequired(), userId: gec?.userId, isSystem: gec?.isSystem })) { + // `route.auth !== false` is the AI-route contract; #3963 dropped the + // deployment-wide opt-out, so the shared function owns the decision. + if (shouldDenyAnonymous({ userId: gec?.userId, isSystem: gec?.isSystem })) { return { handled: true, response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), diff --git a/packages/runtime/src/domains/error-passthrough.test.ts b/packages/runtime/src/domains/error-passthrough.test.ts index bd549126c0..07d67f706b 100644 --- a/packages/runtime/src/domains/error-passthrough.test.ts +++ b/packages/runtime/src/domains/error-passthrough.test.ts @@ -62,7 +62,11 @@ async function dispatchWith(method: string, path: string, err: unknown, body: an }; const objectql = { registry: {} }; const resolve = (name: string) => - name === 'protocol' ? protocol : name === 'objectql' ? objectql : undefined; + name === 'protocol' ? protocol : name === 'objectql' ? objectql + // Authenticated caller so the dispatch reaches the error path instead of + // 401ing at the anonymous-deny gate (#3963). + : name === 'auth' ? { api: { getSession: async () => ({ user: { id: 'u1' } }) } } + : undefined; const kernel: any = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), @@ -129,7 +133,10 @@ describe('#3918 follow-up — deliberate per-route fallbacks are preserved', () /** `/meta` PUT with a metadata service (no `saveMetaItem`) → the 501 branch. */ async function putMetaViaMetadataService(err: unknown) { const metadata = { saveItem: async () => { throw err; } }; - const resolve = (name: string) => (name === 'metadata' ? metadata : undefined); + const resolve = (name: string) => + name === 'metadata' ? metadata + : name === 'auth' ? { api: { getSession: async () => ({ user: { id: 'u1' } }) } } + : undefined; const kernel: any = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index c442d5f6c0..6944c31e15 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -5,7 +5,7 @@ * the terminal cut). The metadata read/write surface: type listing, item * CRUD (ADR-0033 draft-aware via the protocol service), the ADR-0046 doc * slimming, and org-scoped reads. The anonymous gate keys off the - * deployment's requireAuth posture through the deps seam. + * anonymous-deny gate (unconditional since #3963). */ import { @@ -51,13 +51,13 @@ function slimDocList(type: string, data: any, query?: Record): a */ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: string, _context: HttpProtocolContext, method?: string, body?: any, query?: any): Promise { // Defense-in-depth: the metadata catch-all must honour the same - // `requireAuth` gate as the REST `/meta` routes (which serve `/meta` on + // anonymous-deny (#2567) as the REST `/meta` routes (which serve `/meta` on // the cloud runtime). Object/field schemas — SYSTEM-object schemas on a // tenant-less host — must not be readable by anonymous callers when the - // deployment requires auth. No-op when `requireAuth` is off. + // an anonymous, non-system caller. Unconditional since #3963. { const ec: any = _context.executionContext; - if (shouldDenyAnonymous({ requireAuth: deps.isAuthRequired(), userId: ec?.userId, isSystem: ec?.isSystem })) { + if (shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem })) { return { handled: true, response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), diff --git a/packages/runtime/src/domains/security.ts b/packages/runtime/src/domains/security.ts index 81e282d652..7738ea5e58 100644 --- a/packages/runtime/src/domains/security.ts +++ b/packages/runtime/src/domains/security.ts @@ -50,12 +50,11 @@ export async function handleSecurityRequest( } const ec = context.executionContext; - // 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 })) { + // Admin surface — anonymous is denied UNCONDITIONALLY (#2567, #3963): + // even before the opt-out was retired this seam never honoured it, so an + // anonymous caller could never list or confirm audience bindings. Shares + // the decision + body with every other HTTP seam. + if (shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem })) { return { handled: true, response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), diff --git a/packages/runtime/src/http-dispatcher.error-leak.test.ts b/packages/runtime/src/http-dispatcher.error-leak.test.ts index 927bafcbe1..0b85d2ceeb 100644 --- a/packages/runtime/src/http-dispatcher.error-leak.test.ts +++ b/packages/runtime/src/http-dispatcher.error-leak.test.ts @@ -34,9 +34,15 @@ function makeDispatcher(saveError: unknown) { const protocol = { saveMetaItem: async () => { throw saveError; }, }; + // An auth service that resolves an authenticated caller — otherwise the + // dispatch would 401 at the anonymous-deny gate (#3963) before reaching the + // error path this test covers. + const auth = { api: { getSession: async () => ({ user: { id: 'u1' } }) } }; + const svc = (name: string) => + name === 'protocol' ? protocol : name === 'auth' ? auth : undefined; const kernel: any = { - getService: (name: string) => (name === 'protocol' ? protocol : undefined), - getServiceAsync: async (name: string) => (name === 'protocol' ? protocol : undefined), + getService: svc, + getServiceAsync: async (name: string) => svc(name), }; return new HttpDispatcher(kernel); } @@ -48,7 +54,7 @@ async function putMeta(saveError: unknown) { '/meta/object/widget', { name: 'widget' }, {}, - {} as any, + { executionContext: { userId: 'u1' } } as any, ); } diff --git a/packages/runtime/src/http-dispatcher.requireauth.test.ts b/packages/runtime/src/http-dispatcher.requireauth.test.ts index 0875d6882e..3494dbf033 100644 --- a/packages/runtime/src/http-dispatcher.requireauth.test.ts +++ b/packages/runtime/src/http-dispatcher.requireauth.test.ts @@ -31,36 +31,38 @@ const anon = { request: {}, executionContext: undefined } as any; const authed = { request: {}, executionContext: { userId: 'u1' } } as any; const system = { request: {}, executionContext: { isSystem: true } } as any; -describe('HttpDispatcher requireAuth gate — AI routes (handleAI)', () => { - it('401s an anonymous caller when requireAuth is on', async () => { - const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); +describe('HttpDispatcher anonymous-deny — AI routes (handleAI) (#3963)', () => { + it('401s an anonymous caller (unconditional)', async () => { + const d = new HttpDispatcher(makeKernel()); const r = await d.handleAI('/ai/status', 'GET', undefined, undefined, anon); expect(r.response?.status).toBe(401); expect(r.response?.body?.error?.details?.code ?? r.response?.body?.error?.code).toBeDefined(); }); it('lets an authenticated caller through', async () => { - const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + const d = new HttpDispatcher(makeKernel()); const r = await d.handleAI('/ai/status', 'GET', undefined, undefined, authed); expect(r.response?.status).toBe(200); expect(r.response?.body?.adapter).toBe('test'); }); it('lets an internal system context through', async () => { - const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + const d = new HttpDispatcher(makeKernel()); const r = await d.handleAI('/ai/status', 'GET', undefined, undefined, system); expect(r.response?.status).toBe(200); }); - it('serves anonymously when requireAuth is off (unchanged legacy behaviour)', async () => { - const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: false }); + it('an auth:false route is the only way an anonymous caller gets through (no global opt-out)', async () => { + // There is no `requireAuth: false` any more; only a route declaring + // auth:false opens itself — asserted by the opt-out test below. + const d = new HttpDispatcher(makeKernel()); const r = await d.handleAI('/ai/status', 'GET', undefined, undefined, anon); - expect(r.response?.status).toBe(200); + expect(r.response?.status).toBe(401); }); it('does not gate a route that opts out with auth:false', async () => { const openRoute = { ...aiRoute, path: '/api/v1/ai/public', auth: false }; - const d = new HttpDispatcher(makeKernel({ __aiRoutes: [openRoute] }), undefined, { requireAuth: true }); + const d = new HttpDispatcher(makeKernel({ __aiRoutes: [openRoute] })); const r = await d.handleAI('/ai/public', 'GET', undefined, undefined, anon); expect(r.response?.status).toBe(200); }); @@ -68,22 +70,22 @@ describe('HttpDispatcher requireAuth gate — AI routes (handleAI)', () => { -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 }); +describe('HttpDispatcher anonymous-deny — metadata catch-all (handleMetadata) (#3963)', () => { + it('401s an anonymous caller (unconditional)', async () => { + const d = new HttpDispatcher(makeKernel()); const r = await d.handleMetadata('/object', anon, 'GET'); expect(r.response?.status).toBe(401); }); it('does not 401 an authenticated caller (proceeds past the gate)', async () => { - const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); + const d = new HttpDispatcher(makeKernel()); const r = await d.handleMetadata('/object', authed, 'GET'); expect(r.response?.status).not.toBe(401); }); - it('does not 401 when requireAuth is off', async () => { - const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: false }); + it('401s an anonymous caller unconditionally — the opt-out is retired (#3963)', async () => { + const d = new HttpDispatcher(makeKernel()); const r = await d.handleMetadata('/object', anon, 'GET'); - expect(r.response?.status).not.toBe(401); + expect(r.response?.status).toBe(401); }); }); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index f42ecd6de9..7add8b98fa 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -47,7 +47,7 @@ describe('HttpDispatcher', () => { describe('handleMetadata', () => { it('should handle PUT /metadata/:type/:name by calling protocol.saveMetaItem', async () => { - const context = { request: {} }; + const context = { request: {}, executionContext: { userId: 'u1' } }; const body = { label: 'New Label' }; const path = '/objects/my_obj'; @@ -68,7 +68,7 @@ describe('HttpDispatcher', () => { }); it('should handle PUT with compound name (3+ path segments)', async () => { - const context = { request: {} }; + const context = { request: {}, executionContext: { userId: 'u1' } }; const body = { density: 'compact' }; // /metadata/lead/views/all_leads → type='lead', name='views/all_leads' const path = '/lead/views/all_leads'; @@ -96,7 +96,7 @@ describe('HttpDispatcher', () => { return null; }; - const context = { request: {} }; + const context = { request: {}, executionContext: { userId: 'u1' } }; const body = { label: 'Fallback' }; const path = '/objects/my_obj'; @@ -110,7 +110,7 @@ describe('HttpDispatcher', () => { it('should return error if save fails', async () => { mockProtocol.saveMetaItem.mockRejectedValue(new Error('Save failed')); - const context = { request: {} }; + const context = { request: {}, executionContext: { userId: 'u1' } }; const body = {}; const path = '/objects/bad_obj'; @@ -134,7 +134,7 @@ describe('HttpDispatcher', () => { ]; mockProtocol.saveMetaItem.mockRejectedValue(err); - const result = await dispatcher.handleMetadata('/objects/bad', { request: {} }, 'PUT', {}); + const result = await dispatcher.handleMetadata('/objects/bad', { request: {}, executionContext: { userId: 'u1' } } as any, 'PUT', {}); expect(result.handled).toBe(true); expect(result.response?.status).toBe(422); // NOT the old hardcoded 400 @@ -149,7 +149,7 @@ describe('HttpDispatcher', () => { it('should handle READ operations via ObjectQL registry', async () => { mockObjectQL.registry.getObject.mockReturnValue({ name: 'my_obj', fields: {} }); - const context = { request: {} }; + const context = { request: {}, executionContext: { userId: 'u1' } }; const result = await dispatcher.handleMetadata('/objects/my_obj', context, 'GET'); expect(result.handled).toBe(true); @@ -989,7 +989,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handleMetadata('/objects/my_obj', { request: {} }, 'PUT', { label: 'Test' }); + const result = await dispatcher.handleMetadata('/objects/my_obj', { request: {}, executionContext: { userId: 'u1' } } as any, 'PUT', { label: 'Test' }); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(asyncProtocol.saveMetaItem).toHaveBeenCalled(); @@ -1002,7 +1002,7 @@ describe('HttpDispatcher', () => { }); mockObjectQL.registry.getObject.mockReturnValue({ name: 'my_obj', fields: {} }); - const result = await dispatcher.handleMetadata('/objects/my_obj', { request: {} }, 'GET'); + const result = await dispatcher.handleMetadata('/objects/my_obj', { request: {}, executionContext: { userId: 'u1' } } as any, 'GET'); expect(result.handled).toBe(true); expect(mockObjectQL.registry.getObject).toHaveBeenCalledWith('my_obj'); }); @@ -1107,7 +1107,7 @@ describe('HttpDispatcher', () => { // Remove context.getService to ensure getServiceAsync is used (kernel as any).context = {}; - const result = await dispatcher.handleMetadata('/objects/my_obj', { request: {} }, 'PUT', { label: 'Test' }); + const result = await dispatcher.handleMetadata('/objects/my_obj', { request: {}, executionContext: { userId: 'u1' } } as any, 'PUT', { label: 'Test' }); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(asyncProtocol.saveMetaItem).toHaveBeenCalled(); @@ -1807,7 +1807,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handleMetadata('_drafts', { request: {} }, 'GET', undefined, { + const result = await dispatcher.handleMetadata('_drafts', { request: {}, executionContext: { userId: 'u1' } } as any, 'GET', undefined, { packageId: 'app.edu', type: 'object', }); @@ -1826,7 +1826,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handleMetadata('_drafts', { request: {} }, 'GET', undefined, {}); + const result = await dispatcher.handleMetadata('_drafts', { request: {}, executionContext: { userId: 'u1' } } as any, 'GET', undefined, {}); expect(result.handled).toBe(true); expect(result.response?.status).toBe(501); }); @@ -1839,7 +1839,7 @@ describe('HttpDispatcher', () => { return null; }); - await dispatcher.handleMetadata('_drafts', { request: {} }, 'GET', undefined, {}); + await dispatcher.handleMetadata('_drafts', { request: {}, executionContext: { userId: 'u1' } } as any, 'GET', undefined, {}); expect(listDrafts).toHaveBeenCalledTimes(1); expect(getMetaItems).not.toHaveBeenCalled(); }); @@ -1859,7 +1859,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handleMetadata('/object/account/published', { request: {} }, 'GET'); + const result = await dispatcher.handleMetadata('/object/account/published', { request: {}, executionContext: { userId: 'u1' } } as any, 'GET'); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(result.response?.body?.data).toEqual({ name: 'account', label: 'Account' }); @@ -1875,7 +1875,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handleMetadata('/object/nonexistent/published', { request: {} }, 'GET'); + const result = await dispatcher.handleMetadata('/object/nonexistent/published', { request: {}, executionContext: { userId: 'u1' } } as any, 'GET'); expect(result.handled).toBe(true); expect(result.response?.status).toBe(404); }); @@ -1897,7 +1897,7 @@ describe('HttpDispatcher', () => { } }; - const result = await dispatcher.handleMetadata('/object/account/published', { request: {} }, 'GET'); + const result = await dispatcher.handleMetadata('/object/account/published', { request: {}, executionContext: { userId: 'u1' } } as any, 'GET'); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(metaSvc.getPublished).toHaveBeenCalledWith('object', 'account'); @@ -2453,7 +2453,7 @@ describe('HttpDispatcher', () => { }); it('GET /meta should return default types with minimal kernel', async () => { - const context = { request: {} }; + const context = { request: {}, executionContext: { userId: 'u1' } }; const result = await minimalDispatcher.handleMetadata('', context, 'GET'); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); @@ -2461,7 +2461,7 @@ describe('HttpDispatcher', () => { }); it('GET /meta/types should return default types with minimal kernel', async () => { - const context = { request: {} }; + const context = { request: {}, executionContext: { userId: 'u1' } }; const result = await minimalDispatcher.handleMetadata('/types', context, 'GET'); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); @@ -2478,7 +2478,7 @@ describe('HttpDispatcher', () => { return null; }); - const context = { request: {} }; + const context = { request: {}, executionContext: { userId: 'u1' } }; const result = await minimalDispatcher.handleMetadata('/objects', context, 'GET'); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); @@ -2496,7 +2496,7 @@ describe('HttpDispatcher', () => { return null; }); - const context = { request: {} }; + const context = { request: {}, executionContext: { userId: 'u1' } }; const result = await minimalDispatcher.handleMetadata('/objects/account', context, 'GET'); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); @@ -2504,14 +2504,14 @@ describe('HttpDispatcher', () => { }); it('GET /meta/:type/:name/published should return 404 when metadata service is unavailable', async () => { - const context = { request: {} }; + const context = { request: {}, executionContext: { userId: 'u1' } }; const result = await minimalDispatcher.handleMetadata('/object/my_obj/published', context, 'GET'); expect(result.handled).toBe(true); expect(result.response?.status).toBe(404); }); it('PUT /meta/:type/:name should return 501 when protocol is unavailable', async () => { - const context = { request: {} }; + const context = { request: {}, executionContext: { userId: 'u1' } }; const body = { label: 'Test' }; const result = await minimalDispatcher.handleMetadata('/objects/my_obj', context, 'PUT', body); expect(result.handled).toBe(true); @@ -2527,7 +2527,7 @@ describe('HttpDispatcher', () => { return null; }); - const context = { request: {} }; + const context = { request: {}, executionContext: { userId: 'u1' } }; const result = await minimalDispatcher.handleMetadata('/types', context, 'GET'); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 5353e6f32c..f78c0fc41c 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -139,14 +139,6 @@ export interface HttpDispatcherOptions { * called on every scoped request so idle projects are evicted after TTL. */ scopeManager?: EnvironmentScopeManager; - /** - * Reject anonymous requests to `auth: true` service routes (AI) and to the - * metadata catch-all with HTTP 401, mirroring the REST API's `requireAuth` - * gate. Matches {@link DispatcherPluginConfig.requireAuth}; the dispatcher - * plugin threads the host's `api.requireAuth` here. Defaults to `false` - * (backward-compatible — nothing enforced `RouteDefinition.auth` before). - */ - requireAuth?: boolean; } /** @@ -189,12 +181,6 @@ export class HttpDispatcher { * `DispatcherConfig.enforceProjectMembership`). */ private enforceMembership: boolean; - /** - * When `true`, `auth: true` AI routes and the metadata catch-all reject - * anonymous callers with 401 (mirrors the REST `requireAuth` gate). Set - * from {@link HttpDispatcherOptions.requireAuth}. Defaults to `false`. - */ - private requireAuth: boolean; /** * In-memory cache of positive membership checks, keyed by * `${environmentId}:${userId}`. Entries expire 60 seconds after insertion @@ -221,7 +207,6 @@ export class HttpDispatcher { try { return (kernel as any).getService?.(name); } catch { return undefined; } }; this.enforceMembership = options?.enforceProjectMembership ?? true; - this.requireAuth = options?.requireAuth ?? false; // ADR-0006 kernel-resolution seam — the host's resolver owns env // resolution + kernel selection. Optional service so single-environment // hosts that register none are unchanged. @@ -278,7 +263,6 @@ export class HttpDispatcher { } catch { /* fall back to defaultKernel resolution downstream */ } return null; }, - isAuthRequired: () => this.requireAuth, getRegisteredAiRoutes: () => (this.kernel as any)?.__aiRoutes, }; diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 9f5d4883a6..0caa8cc0d5 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -1777,7 +1777,7 @@ "api/RestApiConfig:enableProjectScoping", "api/RestApiConfig:enableUi", "api/RestApiConfig:projectResolution", - "api/RestApiConfig:requireAuth", + "api/RestApiConfig:requireAuth [RETIRED]", "api/RestApiConfig:responseFormat", "api/RestApiConfig:version", "api/RestApiEndpoint:cacheTtl", diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 89b85594df..01cb04de95 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { HttpMethod } from '../shared/http.zod'; +import { retiredKey } from '../shared/retired-key'; /** * REST API Server Protocol @@ -113,32 +114,21 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ .describe('Project ID resolution strategy'), /** - * When `true`, anonymous requests are rejected with HTTP 401 before reaching - * ObjectQL. This is the transport-layer counterpart to the security plugin's - * RBAC enforcement and the **only** defense for deployments where the - * security plugin is not mounted (legacy bare-runtime hosts) or where it - * would otherwise fall through anonymous traffic. + * [REMOVED in #3963] The deployment-wide anonymous-access opt-out. * - * **Applies to every HTTP surface, not just REST `/data`** (#2567). The same - * `requireAuth` value is threaded to every entry point that reaches object - * data, so the anonymous-deny posture is UNIFORM by surface: REST `/data` - * CRUD + batch, the metadata endpoints (`/meta`), and the raw-hono standard `/data` routes. All share - * one decision (`shouldDenyAnonymous` in `@objectstack/core`). Before #2567 a - * caller denied on `/data` could read the same rows through a sibling door. - * - * **Default `true` — secure by default (ADR-0056 D2).** Anonymous access must - * be an explicit deployment decision (`requireAuth: false`), not a silent - * fallthrough. Legitimate anonymous surfaces survive the deny posture without - * any opt-out: the control plane (`/auth`, `/health`, `/discovery`) is exempt, - * share-links read under a system context after token validation, and - * public-form submission is self-authorizing via the declaration-derived - * `publicFormGrant` (create + read-back on the declared target object only). - * Demo/playground deployments that intentionally serve data publicly must set - * `requireAuth: false` explicitly — the REST plugin (and the dispatcher / hono - * plugins) log a boot warning when they do. - */ - requireAuth: z.boolean().default(true) - .describe('Reject anonymous requests on ALL HTTP surfaces that reach object data (REST /data, /meta, raw-hono /data) with HTTP 401 (secure-by-default; set false to serve them publicly)'), + * Tombstoned rather than deleted: `RestApiConfigSchema` is not `.strict()`, so + * a plain deletion would silently strip the key — an author who keeps writing + * `requireAuth: false` would get a clean parse and a deployment that quietly + * denies every anonymous request, with nothing to grep (ADR-0104, #3733). + * Which is the exact failure mode this key's removal is about. + */ + requireAuth: retiredKey( + '`api.requireAuth` was removed in @objectstack/spec 18 (#3963). Anonymous access to object data ' + + 'is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. ' + + 'To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a ' + + "share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of " + + 'opening the whole data plane.', + ), /** * API documentation configuration diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 43a75ba664..8dc2eb2e26 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -1313,6 +1313,48 @@ const fieldRequiredNotNullExplicit: MetadataConversion = { * All conversions, keyed by the protocol major that introduced the canonical * shape. Newest majors last; ordering within a major is application order. */ +/** + * Stack `api.requireAuth` → dropped (protocol 18, #3963). + * + * NOT a rename — there is no key to move the value to. The deployment-wide + * anonymous-access opt-out is retired: auth is a kernel concern, and anonymous + * access to object data is now always denied. A surface that legitimately + * serves a session-less caller derives its own narrow authorization from a + * declaration (a public form view, a share link, or `book.audience: 'public'`), + * so there is nothing for the old boolean to control. + * + * Dropping is safe at load time: the runtime no longer reads the key (its + * plumbing was removed in the same change), so a surviving `api.requireAuth` + * would otherwise be silently stripped by the non-strict schema — the exact + * quiet-failure this conversion + the `retiredKey` tombstone exist to prevent. + * The notice tells the author their intent was dropped and where to re-declare + * public access. + */ +const stackApiRequireAuthRemoved: MetadataConversion = { + id: 'stack-api-require-auth-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'stack.api.requireAuth', + summary: "stack key 'api.requireAuth' removed — anonymous access is always denied; publish public surfaces by declaration (#3963)", + apply(stack, emit) { + const api = stack.api; + if (!isDict(api) || !('requireAuth' in api)) return stack; + const nextApi: Dict = { ...api }; + delete nextApi.requireAuth; + emit({ from: 'requireAuth', to: '(removed)', path: 'api' }); + return { ...stack, api: nextApi }; + }, + fixture: { + before: { + api: { requireAuth: false, enableProjectScoping: false }, + }, + after: { + api: { enableProjectScoping: false }, + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -1331,6 +1373,9 @@ export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: step11, 12: step12, @@ -463,6 +480,7 @@ export const MIGRATIONS_BY_MAJOR: Readonly> = { 15: step15, 16: step16, 17: step17, + 18: step18, }; /** The majors that have a step, ascending. */ diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 50e8c0402e..22830d5414 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -37,6 +37,7 @@ import { CapabilityDeclarationSchema } from './security/capabilities'; import { SharingRuleSchema } from './security/sharing.zod'; import { ApiEndpointSchema } from './api/endpoint.zod'; +import { retiredKey } from './shared/retired-key'; // AI Protocol import { AgentSchema } from './ai/agent.zod'; @@ -272,12 +273,16 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ */ api: z.object({ /** - * Reject anonymous requests on `/data/*` with HTTP 401. Secure-by-default - * (ADR-0056 D2) at the REST layer; set `false` here to intentionally serve - * data publicly (the REST plugin logs a boot warning). + * [REMOVED in #3963] See `RestApiConfigSchema.requireAuth` — tombstoned for + * the same reason: this block is not `.strict()`, so deleting the key would + * silently strip it and the author's intent would vanish without a word. */ - requireAuth: z.boolean().optional() - .describe('[ADR-0056 D2] Reject anonymous /data/* requests (secure-by-default; set false to serve publicly)'), + requireAuth: retiredKey( + '`api.requireAuth` was removed in @objectstack/spec 18 (#3963). Anonymous access to object data ' + + 'is now always denied. Delete the key; publish public surfaces by declaration instead — a public ' + + "form view, a share link, or `book.audience: 'public'`. A stack that mounts no auth at all now " + + 'fails at boot rather than silently serving object data to anonymous callers.', + ), /** Enable environment-scoped routing for data/meta/AI APIs. */ enableProjectScoping: z.boolean().optional(), /** Environment id resolution strategy when scoping is on. */ diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 919556ac28..2d74dc1e19 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -262,10 +262,10 @@ export async function bootStack( await kernel.use(new SharingServicePlugin()); // REST + dispatcher route surfaces (mount onto the http-server service). - // No `requireAuth` override: the harness deliberately boots on the platform - // DEFAULT (secure-by-default deny, ADR-0056 D2) so every dogfood proof — - // anonymous-deny, public-form survival, share-links — exercises the posture - // a fresh production deployment actually gets. + // Anonymous access to object data is denied unconditionally (#3963 retired the + // `requireAuth` opt-out), so every dogfood proof — anonymous-deny, public-form + // survival, share-links, public-book reads — exercises the one posture a + // production deployment actually gets. await kernel.use(createRestApiPlugin({})); await kernel.use(createDispatcherPlugin({}));