From 885046c7402785e8f0ef103fd5290d8191ead875 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:02:45 +0000 Subject: [PATCH 1/2] feat(rest): audience:'public' publishes a book anonymously on a secure-by-default deployment (#3963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit book.audience: 'public' was a declared per-book capability that in practice required the deployment to open its ENTIRE data plane: the /meta umbrella gate refused every anonymous caller unless api.requireAuth was false, so a `public` book was only reachable inside a globally-public deployment — the audience model re-narrowed what that flag had already opened rather than granting anything of its own. ADR-0046 §6.7 recorded exactly that as ground truth. The exemption is now derived from the declaration, the same shape ADR-0056 Option A chose for public form submission: the umbrella gate admits an anonymous GET of the book/doc read surface, and the §6.7 audience gate in the handler is what authorizes it. Narrow in three independent ways: 1. only when no execution context resolved — an authenticated caller still goes through enforceAuth, so the ADR-0069 auth-policy gate keeps governing a gated session's book reads; 2. only GET, only book/doc (either spelling, #3984) plus the book tree. Other types, writes, and GET /meta itself stay 401 for anonymous. The predicate keys on the registered route path plus the normalized :type, so a route added later cannot fall into it by accident; 3. reachability, not authorization — audienceAllows admits 'public' only; org and { permissionSet } books require caller.authenticated and unresolvable holdings fail closed. Prerequisite for retiring api.requireAuth (#3963 step 2). ADR-0046 §6.7 carries an amendment recording the new gate; its SEO and tenant-from-host reasoning is unchanged, having never depended on the flag. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TzLE9cw4gZKNyPN2ZP4iTt --- .changeset/public-book-grant.md | 38 +++++ docs/adr/0046-package-docs-as-metadata.md | 34 +++- .../rest/src/meta-public-book-grant.test.ts | 146 ++++++++++++++++++ packages/rest/src/rest-server.ts | 57 ++++++- 4 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 .changeset/public-book-grant.md create mode 100644 packages/rest/src/meta-public-book-grant.test.ts diff --git a/.changeset/public-book-grant.md b/.changeset/public-book-grant.md new file mode 100644 index 0000000000..c86628ceda --- /dev/null +++ b/.changeset/public-book-grant.md @@ -0,0 +1,38 @@ +--- +"@objectstack/rest": minor +--- + +feat(rest): `audience: 'public'` publishes a book anonymously on a secure-by-default deployment (#3963) + +`book.audience: 'public'` was a declared per-book capability that in practice +required the deployment to open its **entire** data plane. The `/meta` umbrella +gate refused every anonymous caller unless `api.requireAuth` was `false`, so a +`public` book was only ever reachable inside a globally-public deployment — the +audience model was *re-narrowing* what that flag had already opened, not granting +anything of its own. ADR-0046 §6.7 recorded exactly that as ground truth ("the +gate is the optional global `requireAuth` … not the handler"). + +The exemption is now derived from the declaration, the same shape ADR-0056 +Option A chose for public form submission (`publicFormGrant`): the umbrella gate +admits an anonymous **GET** of the book/doc read surface, and the §6.7 audience +gate inside the handler is what authorizes it. + +Narrow in three independent ways: + +1. **Only when no execution context resolved.** An authenticated caller still + goes through `enforceAuth` unchanged, so the ADR-0069 auth-policy gate + (expired password, enforced MFA) keeps governing a gated session's book reads. +2. **Only GET, only book/doc.** `GET /meta/:type`, `GET /meta/:type/:name` (type + `book` or `doc`, either spelling — #3984) and `GET /meta/book/:name/tree`. + Every other type stays 401 for anonymous, writes stay 401, and `GET /meta` + itself stays 401. The predicate keys on the REGISTERED route path plus the + normalized `:type`, so a route added later cannot fall into it by accident. +3. **Reachability, not authorization.** `audienceAllows` admits `'public'` only; + `org` and `{ permissionSet }` books require `caller.authenticated` and + unresolvable holdings fail closed, so an anonymous read of a gated book is + still `401`. + +A deployment can now publish a public manual with `requireAuth: true` — which is +the prerequisite for retiring that flag entirely (#3963 step 2). ADR-0046 §6.7 +carries an amendment recording the new gate; its SEO and tenant-from-host +reasoning is unchanged, having never depended on the flag. diff --git a/docs/adr/0046-package-docs-as-metadata.md b/docs/adr/0046-package-docs-as-metadata.md index 958567d29e..bd2ac2b8e6 100644 --- a/docs/adr/0046-package-docs-as-metadata.md +++ b/docs/adr/0046-package-docs-as-metadata.md @@ -564,12 +564,34 @@ it does **not** reopen the §2 non-goal that excluded standalone product doc sites — a `public` book is still registry metadata (`doc`/`book`), sanitized and rendered by the platform, not a separately-hosted static website. -**Gating is enforced at the read layer, not just the UI.** Ground truth: -`/meta/doc` is already anonymous-reachable (the gate is the optional global -`requireAuth` or the SPA's `ProtectedRoute`, not the handler). So a public -portal that mixes `public` and gated books **must** apply `audience` inside the -`/meta/doc` and `/meta/book` read path — gating only the UI would let a gated -doc leak straight from REST. +**Gating is enforced at the read layer, not just the UI.** A public portal that +mixes `public` and gated books **must** apply `audience` inside the `/meta/doc` +and `/meta/book` read path — gating only the UI would let a gated doc leak +straight from REST. + +> **Amendment (framework#3963).** This section originally recorded the ground +> truth that `/meta/doc` was "already anonymous-reachable (the gate is the +> optional global `requireAuth` or the SPA's `ProtectedRoute`, not the handler)". +> That is no longer how `public` is reached, and the original shape was the +> problem: it made a *per-book* declaration depend on a deployment opening its +> **entire** data plane, so `audience: 'public'` was really a re-narrowing inside +> a globally-public deployment rather than a capability of its own. +> +> Anonymous reachability of the book/doc read surface is now derived from the +> declaration itself — the same shape ADR-0056 Option A chose for public form +> submission. The `/meta` umbrella gate admits an anonymous **GET** of +> `/meta/:type` / `/meta/:type/:name` (type `book` or `doc`, either spelling) and +> `/meta/book/:name/tree`, and nothing else; the §6.7 audience gate in the +> handler remains the *authorization*, admitting `'public'` only. So a +> secure-by-default deployment (`requireAuth: true`) can publish a public book +> while every other metadata type — objects, fields, views, flows — stays 401 for +> anonymous callers. +> +> An authenticated caller is unaffected: the exemption applies only when no +> execution context resolved, so the ADR-0069 auth-policy gate still governs a +> gated session's book reads. The two decisions this section calls decisive — +> SEO/crawlability and tenant-from-host — are unchanged; they never depended on +> the global flag. **Two frontend surfaces, addressed differently.** The URL must distinguish the authenticated app from the docs portal — and not only for the auth gate: diff --git a/packages/rest/src/meta-public-book-grant.test.ts b/packages/rest/src/meta-public-book-grant.test.ts new file mode 100644 index 0000000000..7674d64f4e --- /dev/null +++ b/packages/rest/src/meta-public-book-grant.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#3963 step 1] `book.audience: 'public'` is a declared capability, so it must +// work on a secure-by-default deployment — not only on one that set +// `requireAuth: false` and opened its entire data plane. The `/meta` umbrella +// gate now lets an ANONYMOUS GET reach the book/doc surface, and the ADR-0046 +// §6.7 audience gate inside the handler is what authorizes it. +// +// Every test here runs with `requireAuth: true` (the platform default). The +// interesting assertions are the negative ones: reachability must not leak into +// authorization, and must not leak to any other metadata type. + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +const PUBLIC_BOOK = { name: 'manual', label: 'Manual', audience: 'public', groups: [] }; +const ORG_BOOK = { name: 'internal', label: 'Internal', audience: 'org', groups: [] }; +const GATED_BOOK = { name: 'admin_guide', label: 'Admin Guide', audience: { permissionSet: 'crm_admin' }, groups: [] }; +const BOOKS = [PUBLIC_BOOK, ORG_BOOK, GATED_BOOK]; + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); + return res; +} + +/** Secure-by-default: `requireAuth` is ON for every case below. */ +function setup() { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: any) => { + const t = RestServerTypes.singular(String(type ?? '')); + if (t === 'book') return BOOKS; + if (t === 'object') return [{ name: 'crm_account' }]; + return []; + }), + getMetaItem: vi.fn(async ({ name }: any) => BOOKS.find((b) => b.name === name) ?? { name }), + saveMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + }; + const rest = new RestServer(createMockServer() as any, protocol, { api: { requireAuth: true } } as any); + rest.registerRoutes(); + return { rest, protocol }; +} + +/** Tiny local mirror of the plural↔singular normalization for the stub. */ +const RestServerTypes = { + singular: (t: string) => (t.endsWith('s') ? t.slice(0, -1) : t), +}; + +function route(rest: any, method: string, path: string) { + const r = rest.getRoutes().find((x: any) => x.method === method && x.path === path); + if (!r) throw new Error(`route not registered: ${method} ${path}`); + return r; +} + +async function call(rest: any, method: string, path: string, params: any, query: any = {}) { + const res = makeRes(); + await route(rest, method, path).handler({ method, params, query, body: {} }, res); + return res; +} + +const LIST = '/api/v1/meta/:type'; +const ITEM = '/api/v1/meta/:type/:name'; +const names = (body: any) => { + const list = Array.isArray(body) ? body : (body?.items ?? []); + return list.map((b: any) => b?.name).sort(); +}; + +describe('anonymous reachability of the book surface under requireAuth (#3963)', () => { + it('an anonymous book list is served, filtered down to `public` books only', async () => { + const { rest } = setup(); + const res = await call(rest, 'GET', LIST, { type: 'book' }); + + expect(res.statusCode).toBe(200); + // `org` and `{ permissionSet }` books are NOT reachable anonymously — the + // §6.7 gate, not the auth gate, is what removes them. + expect(names(res.body)).toEqual(['manual']); + }); + + it('works on the plural spelling too', async () => { + const { rest } = setup(); + const res = await call(rest, 'GET', LIST, { type: 'books' }); + + expect(res.statusCode).toBe(200); + expect(names(res.body)).toEqual(['manual']); + }); + + it('an anonymous read of a `public` book by name is served', async () => { + const { rest } = setup(); + expect((await call(rest, 'GET', ITEM, { type: 'book', name: 'manual' })).statusCode).toBe(200); + }); + + it('reachability is NOT authorization — org and gated books are still refused', async () => { + const { rest } = setup(); + expect((await call(rest, 'GET', ITEM, { type: 'book', name: 'internal' })).statusCode).toBe(401); + expect((await call(rest, 'GET', ITEM, { type: 'book', name: 'admin_guide' })).statusCode).toBe(401); + // …and on the plural spelling (#3984). + expect((await call(rest, 'GET', ITEM, { type: 'books', name: 'admin_guide' })).statusCode).toBe(401); + }); +}); + +describe('the exemption does not widen past book/doc reads (#3963)', () => { + it('every other metadata type keeps the anonymous deny', async () => { + const { rest, protocol } = setup(); + const res = await call(rest, 'GET', LIST, { type: 'object' }); + + expect(res.statusCode).toBe(401); + // The handler never ran, so the protocol was never asked for object schemas. + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + }); + + it('the plural spelling of another type is denied too', async () => { + const { rest } = setup(); + expect((await call(rest, 'GET', LIST, { type: 'objects' })).statusCode).toBe(401); + expect((await call(rest, 'GET', ITEM, { type: 'objects', name: 'crm_account' })).statusCode).toBe(401); + }); + + it('a WRITE to the book surface is still denied', async () => { + const { rest, protocol } = setup(); + const put = rest.getRoutes().find((r: any) => r.method === 'PUT' && r.path === ITEM); + if (put) { + const res = makeRes(); + await put.handler({ method: 'PUT', params: { type: 'book', name: 'manual' }, query: {}, body: {} }, res); + expect(res.statusCode).toBe(401); + expect(protocol.saveMetaItem).not.toHaveBeenCalled(); + } + }); + + it('the type list itself (/meta) stays denied', async () => { + const { rest } = setup(); + const res = makeRes(); + await route(rest, 'GET', '/api/v1/meta').handler({ method: 'GET', params: {}, query: {}, body: {} }, res); + expect(res.statusCode).toBe(401); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 7b36a48c70..2ecb7da795 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1432,6 +1432,38 @@ export class RestServer { return PLURAL_TO_SINGULAR[t] ?? t; } + /** + * [#3963] Is this request a READ of the audience-gated book/doc surface — + * the one metadata surface whose own declaration (`book.audience`) can + * authorize an anonymous caller? + * + * Used by the `/meta` umbrella gate to grant an anonymous caller + * REACHABILITY of these three routes, so `audience: 'public'` works on a + * secure-by-default deployment instead of only on one that opened its whole + * data plane. Authorization stays with the handler's §6.7 gate, which admits + * `'public'` only. + * + * The predicate is keyed on the REGISTERED route path plus the normalized + * `:type` param — not on `req.path` string-matching — so a route added later + * cannot accidentally fall inside it, and the plural spelling cannot fall + * outside it (#3984). + */ + private static isPublicAudienceRead( + entry: Readonly>, + req: { method?: unknown; params?: Record }, + ): boolean { + const method = String(req?.method ?? entry?.method ?? '').toUpperCase(); + if (method !== 'GET') return false; // reads only — never a write or a publish + const path = typeof entry?.path === 'string' ? entry.path : ''; + // `GET /meta/book/:name/tree` — the type segment is literal here. + if (path.endsWith('/book/:name/tree')) return true; + // `GET /meta/:type` and `GET /meta/:type/:name` — book/doc only. Every + // other type (object, field, view, flow, …) keeps the anonymous deny. + if (!/\/:type(\/:name)?$/.test(path)) return false; + const type = RestServer.metaTypeSingular(req?.params?.type); + return type === 'book' || type === 'doc'; + } + /** Whether any of these books carries a `{ permissionSet }` audience. */ private static anyPermissionSetAudience(books: readonly any[]): boolean { return books.some( @@ -2422,7 +2454,30 @@ export class RestServer { // each `/data` handler derives. const environmentId = req?.params?.environmentId; const context = await this.resolveExecCtx(environmentId, req).catch(() => undefined); - if (this.enforceAuth(req, res, context)) return; + // [#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 + // 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 + // for public form submission (`publicFormGrant`). + // + // Deliberately narrow, in three independent ways: + // 1. only when NO context resolved. An authenticated caller + // still goes through `enforceAuth` unchanged, so the + // ADR-0069 auth-policy gate (expired password, enforced + // MFA) keeps applying to a gated session's book reads; + // 2. only GET, and only the book/doc routes (see + // {@link isPublicAudienceRead}) — `/meta/object` stays 401 + // for anonymous, which is the whole point of the umbrella + // gate; + // 3. the handler still decides. `audienceAllows` returns true + // for `'public'` ONLY; `org` and `{ permissionSet }` books + // require `caller.authenticated`, and unresolvable holdings + // fail closed. This grants REACHABILITY, not authorization. + const anonymousPublicRead = !context?.userId + && RestServer.isPublicAudienceRead(entry, req); + if (!anonymousPublicRead && this.enforceAuth(req, res, context)) return; return (inner as (rq: any, rs: any) => unknown)(req, res); }, } as any); From fed891ef1910ea6886232be44bdb6612ee074ef6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:04:35 +0000 Subject: [PATCH 2/2] docs(permissions): the anonymous-deny row lists every narrow public surface, not just the global opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authorization matrix said public serving 'requires an explicit api.requireAuth: false opt-out'. That was already only half true — public forms and share-links never needed it — and #3963 adds the book/doc read surface. Each of those derives authorization from a declaration rather than from the deployment posture, which is the distinction the row was flattening. Surfaced by the docs-drift check on #3995. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TzLE9cw4gZKNyPN2ZP4iTt --- content/docs/permissions/authorization.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 5f0c89061e..153d71e9af 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -48,7 +48,7 @@ site — the file you read when behavior surprises you. | # | Gate | What it decides | Enforcement site | Failure direction | |---|---|---|---|---| -| 1 | **Anonymous deny** | No identity → HTTP 401. **Uniform across every HTTP surface that reaches object data** (#2567): REST `/data`, the metadata endpoints (`/meta`), and the raw-hono standard `/data` routes (the dispatcher GraphQL endpoint left the matrix when the GraphQL surface was removed in v17 — `/graphql` now 404s) — one shared decision, so a caller denied on `/data` can't read the same rows through a sibling door. **Default-on** (ADR-0056 D2): public serving requires an explicit `api.requireAuth: false` opt-out, which logs a boot warning. Control plane (`/auth`, `/health`, `/discovery`) is exempt; share-links validate their token then read as SYSTEM. | `packages/core/src/security/anonymous-deny.ts` `shouldDenyAnonymous` — called by `rest-server.ts` `enforceAuth`, the dispatcher `handleMetadata`/`handleAI`, and `plugin-hono-server` `denyAnonymous` (default in `packages/spec/src/api/rest-server.zod.ts`); a source-enumerating ratchet in `authz-conformance.test.ts` fails CI if a new surface ships ungated | fail-closed | +| 1 | **Anonymous deny** | No identity → HTTP 401. **Uniform across every HTTP surface that reaches object data** (#2567): REST `/data`, the metadata endpoints (`/meta`), and the raw-hono standard `/data` routes (the dispatcher GraphQL endpoint left the matrix when the GraphQL surface was removed in v17 — `/graphql` now 404s) — one shared decision, so a caller denied on `/data` can't read the same rows through a sibling door. **Default-on** (ADR-0056 D2): serving the whole data plane publicly requires an explicit `api.requireAuth: false` opt-out, which logs a boot warning. Narrower public surfaces do **not** need it — each derives its own authorization from a declaration rather than from the deployment posture: control plane (`/auth`, `/health`, `/discovery`) is allow-listed; public form submission carries a `publicFormGrant` (ADR-0056 Option A); share-links validate their token then read as SYSTEM; and an anonymous **GET** of the book/doc read surface is admitted so `book.audience: 'public'` works under the secure default, with the ADR-0046 §6.7 audience gate — `'public'` only, fail-closed — doing the authorizing (#3963). | `packages/core/src/security/anonymous-deny.ts` `shouldDenyAnonymous` — called by `rest-server.ts` `enforceAuth`, the dispatcher `handleMetadata`/`handleAI`, and `plugin-hono-server` `denyAnonymous` (default in `packages/spec/src/api/rest-server.zod.ts`); a source-enumerating ratchet in `authz-conformance.test.ts` fails CI if a new surface ships ungated | fail-closed | | 2 | **Public-form grant** | An anonymous form submission carries a declaration-derived `publicFormGrant` authorizing ONLY create + read-back on the form's declared target object — never anything else (ADR-0056 Option A). No guest-portal configuration needed (anonymous principals hold the `guest` position). | `packages/plugins/plugin-security/src/security-plugin.ts` (ObjectQL middleware) | scope-limited allow | | 3 | **Object CRUD** | `allowRead/Create/Edit/Delete` (+ the destructive lifecycle class `allowTransfer/Restore/Purge`, gated ahead of the M2 operations — #1883) resolved across the caller's permission sets. | `packages/plugins/plugin-security/src/permission-evaluator.ts` `checkObjectPermission` | fail-closed 403 | | 4 | **OWD / sharing** | Org-wide default (`private` / `public_read` / `public_read_write` / `controlled_by_parent`; **unset or unknown ⇒ `private`, fail-closed** — ADR-0090 D1) plus the external dial (`externalSharingModel`, ADR-0090 D11), manual record shares, criteria sharing rules (owner-type rules were removed from the authoring surface in v17 rather than left declared-but-skipped — [Sharing Rules](/docs/permissions/sharing-rules#recipient-types)), business-unit hierarchy widening (ADR-0057 D5: scope-depth hierarchy lives on `sys_business_unit`, not positions). | `packages/plugins/plugin-sharing/src/sharing-service.ts` + `sharing-rule-service.ts` | fail-closed to owner-only |