Skip to content

Commit be7945a

Browse files
authored
feat(rest): audience:'public' publishes a book anonymously on a secure-by-default deployment (#3963) (#3995)
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. 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. Narrow in three ways: 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); only GET and only book/doc, either spelling (#3984), plus the book tree — other types, writes and GET /meta itself stay 401; and reachability is not authorization, since audienceAllows admits 'public' only while org and { permissionSet } books require caller.authenticated and fail closed. Prerequisite for retiring api.requireAuth (#3963 step 2). ADR-0046 §6.7 carries an amendment recording the new gate, and the authorization matrix now lists every narrow public surface rather than implying the global opt-out is the only one.
1 parent 0c90ece commit be7945a

5 files changed

Lines changed: 269 additions & 8 deletions

File tree

.changeset/public-book-grant.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
feat(rest): `audience: 'public'` publishes a book anonymously on a secure-by-default deployment (#3963)
6+
7+
`book.audience: 'public'` was a declared per-book capability that in practice
8+
required the deployment to open its **entire** data plane. The `/meta` umbrella
9+
gate refused every anonymous caller unless `api.requireAuth` was `false`, so a
10+
`public` book was only ever reachable inside a globally-public deployment — the
11+
audience model was *re-narrowing* what that flag had already opened, not granting
12+
anything of its own. ADR-0046 §6.7 recorded exactly that as ground truth ("the
13+
gate is the optional global `requireAuth` … not the handler").
14+
15+
The exemption is now derived from the declaration, the same shape ADR-0056
16+
Option A chose for public form submission (`publicFormGrant`): the umbrella gate
17+
admits an anonymous **GET** of the book/doc read surface, and the §6.7 audience
18+
gate inside the handler is what authorizes it.
19+
20+
Narrow in three independent ways:
21+
22+
1. **Only when no execution context resolved.** An authenticated caller still
23+
goes through `enforceAuth` unchanged, so the ADR-0069 auth-policy gate
24+
(expired password, enforced MFA) keeps governing a gated session's book reads.
25+
2. **Only GET, only book/doc.** `GET /meta/:type`, `GET /meta/:type/:name` (type
26+
`book` or `doc`, either spelling — #3984) and `GET /meta/book/:name/tree`.
27+
Every other type stays 401 for anonymous, writes stay 401, and `GET /meta`
28+
itself stays 401. The predicate keys on the REGISTERED route path plus the
29+
normalized `:type`, so a route added later cannot fall into it by accident.
30+
3. **Reachability, not authorization.** `audienceAllows` admits `'public'` only;
31+
`org` and `{ permissionSet }` books require `caller.authenticated` and
32+
unresolvable holdings fail closed, so an anonymous read of a gated book is
33+
still `401`.
34+
35+
A deployment can now publish a public manual with `requireAuth: true` — which is
36+
the prerequisite for retiring that flag entirely (#3963 step 2). ADR-0046 §6.7
37+
carries an amendment recording the new gate; its SEO and tenant-from-host
38+
reasoning is unchanged, having never depended on the flag.

content/docs/permissions/authorization.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ site — the file you read when behavior surprises you.
4848

4949
| # | Gate | What it decides | Enforcement site | Failure direction |
5050
|---|---|---|---|---|
51-
| 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 |
51+
| 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 |
5252
| 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 |
5353
| 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 |
5454
| 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 |

docs/adr/0046-package-docs-as-metadata.md

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -564,12 +564,34 @@ it does **not** reopen the §2 non-goal that excluded standalone product doc
564564
sites — a `public` book is still registry metadata (`doc`/`book`), sanitized
565565
and rendered by the platform, not a separately-hosted static website.
566566

567-
**Gating is enforced at the read layer, not just the UI.** Ground truth:
568-
`/meta/doc` is already anonymous-reachable (the gate is the optional global
569-
`requireAuth` or the SPA's `ProtectedRoute`, not the handler). So a public
570-
portal that mixes `public` and gated books **must** apply `audience` inside the
571-
`/meta/doc` and `/meta/book` read path — gating only the UI would let a gated
572-
doc leak straight from REST.
567+
**Gating is enforced at the read layer, not just the UI.** A public portal that
568+
mixes `public` and gated books **must** apply `audience` inside the `/meta/doc`
569+
and `/meta/book` read path — gating only the UI would let a gated doc leak
570+
straight from REST.
571+
572+
> **Amendment (framework#3963).** This section originally recorded the ground
573+
> truth that `/meta/doc` was "already anonymous-reachable (the gate is the
574+
> optional global `requireAuth` or the SPA's `ProtectedRoute`, not the handler)".
575+
> That is no longer how `public` is reached, and the original shape was the
576+
> problem: it made a *per-book* declaration depend on a deployment opening its
577+
> **entire** data plane, so `audience: 'public'` was really a re-narrowing inside
578+
> a globally-public deployment rather than a capability of its own.
579+
>
580+
> Anonymous reachability of the book/doc read surface is now derived from the
581+
> declaration itself — the same shape ADR-0056 Option A chose for public form
582+
> submission. The `/meta` umbrella gate admits an anonymous **GET** of
583+
> `/meta/:type` / `/meta/:type/:name` (type `book` or `doc`, either spelling) and
584+
> `/meta/book/:name/tree`, and nothing else; the §6.7 audience gate in the
585+
> handler remains the *authorization*, admitting `'public'` only. So a
586+
> secure-by-default deployment (`requireAuth: true`) can publish a public book
587+
> while every other metadata type — objects, fields, views, flows — stays 401 for
588+
> anonymous callers.
589+
>
590+
> An authenticated caller is unaffected: the exemption applies only when no
591+
> execution context resolved, so the ADR-0069 auth-policy gate still governs a
592+
> gated session's book reads. The two decisions this section calls decisive —
593+
> SEO/crawlability and tenant-from-host — are unchanged; they never depended on
594+
> the global flag.
573595
574596
**Two frontend surfaces, addressed differently.** The URL must distinguish the
575597
authenticated app from the docs portal — and not only for the auth gate:
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#3963 step 1] `book.audience: 'public'` is a declared capability, so it must
4+
// work on a secure-by-default deployment — not only on one that set
5+
// `requireAuth: false` and opened its entire data plane. The `/meta` umbrella
6+
// gate now lets an ANONYMOUS GET reach the book/doc surface, and the ADR-0046
7+
// §6.7 audience gate inside the handler is what authorizes it.
8+
//
9+
// Every test here runs with `requireAuth: true` (the platform default). The
10+
// interesting assertions are the negative ones: reachability must not leak into
11+
// authorization, and must not leak to any other metadata type.
12+
13+
import { describe, it, expect, vi } from 'vitest';
14+
import { RestServer } from './rest-server';
15+
16+
const PUBLIC_BOOK = { name: 'manual', label: 'Manual', audience: 'public', groups: [] };
17+
const ORG_BOOK = { name: 'internal', label: 'Internal', audience: 'org', groups: [] };
18+
const GATED_BOOK = { name: 'admin_guide', label: 'Admin Guide', audience: { permissionSet: 'crm_admin' }, groups: [] };
19+
const BOOKS = [PUBLIC_BOOK, ORG_BOOK, GATED_BOOK];
20+
21+
function createMockServer() {
22+
return {
23+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(),
24+
listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
25+
};
26+
}
27+
28+
function makeRes() {
29+
const res: any = { statusCode: 200, body: undefined };
30+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
31+
res.json = vi.fn((b: any) => { res.body = b; return res; });
32+
res.header = vi.fn(); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn();
33+
return res;
34+
}
35+
36+
/** Secure-by-default: `requireAuth` is ON for every case below. */
37+
function setup() {
38+
const protocol: any = {
39+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: { data: '', metadata: '', ui: '', auth: '/auth' } }),
40+
getMetaTypes: vi.fn().mockResolvedValue([]),
41+
getMetaItems: vi.fn(async ({ type }: any) => {
42+
const t = RestServerTypes.singular(String(type ?? ''));
43+
if (t === 'book') return BOOKS;
44+
if (t === 'object') return [{ name: 'crm_account' }];
45+
return [];
46+
}),
47+
getMetaItem: vi.fn(async ({ name }: any) => BOOKS.find((b) => b.name === name) ?? { name }),
48+
saveMetaItem: vi.fn().mockResolvedValue({}),
49+
findData: vi.fn().mockResolvedValue([]),
50+
};
51+
const rest = new RestServer(createMockServer() as any, protocol, { api: { requireAuth: true } } as any);
52+
rest.registerRoutes();
53+
return { rest, protocol };
54+
}
55+
56+
/** Tiny local mirror of the plural↔singular normalization for the stub. */
57+
const RestServerTypes = {
58+
singular: (t: string) => (t.endsWith('s') ? t.slice(0, -1) : t),
59+
};
60+
61+
function route(rest: any, method: string, path: string) {
62+
const r = rest.getRoutes().find((x: any) => x.method === method && x.path === path);
63+
if (!r) throw new Error(`route not registered: ${method} ${path}`);
64+
return r;
65+
}
66+
67+
async function call(rest: any, method: string, path: string, params: any, query: any = {}) {
68+
const res = makeRes();
69+
await route(rest, method, path).handler({ method, params, query, body: {} }, res);
70+
return res;
71+
}
72+
73+
const LIST = '/api/v1/meta/:type';
74+
const ITEM = '/api/v1/meta/:type/:name';
75+
const names = (body: any) => {
76+
const list = Array.isArray(body) ? body : (body?.items ?? []);
77+
return list.map((b: any) => b?.name).sort();
78+
};
79+
80+
describe('anonymous reachability of the book surface under requireAuth (#3963)', () => {
81+
it('an anonymous book list is served, filtered down to `public` books only', async () => {
82+
const { rest } = setup();
83+
const res = await call(rest, 'GET', LIST, { type: 'book' });
84+
85+
expect(res.statusCode).toBe(200);
86+
// `org` and `{ permissionSet }` books are NOT reachable anonymously — the
87+
// §6.7 gate, not the auth gate, is what removes them.
88+
expect(names(res.body)).toEqual(['manual']);
89+
});
90+
91+
it('works on the plural spelling too', async () => {
92+
const { rest } = setup();
93+
const res = await call(rest, 'GET', LIST, { type: 'books' });
94+
95+
expect(res.statusCode).toBe(200);
96+
expect(names(res.body)).toEqual(['manual']);
97+
});
98+
99+
it('an anonymous read of a `public` book by name is served', async () => {
100+
const { rest } = setup();
101+
expect((await call(rest, 'GET', ITEM, { type: 'book', name: 'manual' })).statusCode).toBe(200);
102+
});
103+
104+
it('reachability is NOT authorization — org and gated books are still refused', async () => {
105+
const { rest } = setup();
106+
expect((await call(rest, 'GET', ITEM, { type: 'book', name: 'internal' })).statusCode).toBe(401);
107+
expect((await call(rest, 'GET', ITEM, { type: 'book', name: 'admin_guide' })).statusCode).toBe(401);
108+
// …and on the plural spelling (#3984).
109+
expect((await call(rest, 'GET', ITEM, { type: 'books', name: 'admin_guide' })).statusCode).toBe(401);
110+
});
111+
});
112+
113+
describe('the exemption does not widen past book/doc reads (#3963)', () => {
114+
it('every other metadata type keeps the anonymous deny', async () => {
115+
const { rest, protocol } = setup();
116+
const res = await call(rest, 'GET', LIST, { type: 'object' });
117+
118+
expect(res.statusCode).toBe(401);
119+
// The handler never ran, so the protocol was never asked for object schemas.
120+
expect(protocol.getMetaItems).not.toHaveBeenCalled();
121+
});
122+
123+
it('the plural spelling of another type is denied too', async () => {
124+
const { rest } = setup();
125+
expect((await call(rest, 'GET', LIST, { type: 'objects' })).statusCode).toBe(401);
126+
expect((await call(rest, 'GET', ITEM, { type: 'objects', name: 'crm_account' })).statusCode).toBe(401);
127+
});
128+
129+
it('a WRITE to the book surface is still denied', async () => {
130+
const { rest, protocol } = setup();
131+
const put = rest.getRoutes().find((r: any) => r.method === 'PUT' && r.path === ITEM);
132+
if (put) {
133+
const res = makeRes();
134+
await put.handler({ method: 'PUT', params: { type: 'book', name: 'manual' }, query: {}, body: {} }, res);
135+
expect(res.statusCode).toBe(401);
136+
expect(protocol.saveMetaItem).not.toHaveBeenCalled();
137+
}
138+
});
139+
140+
it('the type list itself (/meta) stays denied', async () => {
141+
const { rest } = setup();
142+
const res = makeRes();
143+
await route(rest, 'GET', '/api/v1/meta').handler({ method: 'GET', params: {}, query: {}, body: {} }, res);
144+
expect(res.statusCode).toBe(401);
145+
});
146+
});

packages/rest/src/rest-server.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1432,6 +1432,38 @@ export class RestServer {
14321432
return PLURAL_TO_SINGULAR[t] ?? t;
14331433
}
14341434

1435+
/**
1436+
* [#3963] Is this request a READ of the audience-gated book/doc surface —
1437+
* the one metadata surface whose own declaration (`book.audience`) can
1438+
* authorize an anonymous caller?
1439+
*
1440+
* Used by the `/meta` umbrella gate to grant an anonymous caller
1441+
* REACHABILITY of these three routes, so `audience: 'public'` works on a
1442+
* secure-by-default deployment instead of only on one that opened its whole
1443+
* data plane. Authorization stays with the handler's §6.7 gate, which admits
1444+
* `'public'` only.
1445+
*
1446+
* The predicate is keyed on the REGISTERED route path plus the normalized
1447+
* `:type` param — not on `req.path` string-matching — so a route added later
1448+
* cannot accidentally fall inside it, and the plural spelling cannot fall
1449+
* outside it (#3984).
1450+
*/
1451+
private static isPublicAudienceRead(
1452+
entry: Readonly<Record<string, unknown>>,
1453+
req: { method?: unknown; params?: Record<string, unknown> },
1454+
): boolean {
1455+
const method = String(req?.method ?? entry?.method ?? '').toUpperCase();
1456+
if (method !== 'GET') return false; // reads only — never a write or a publish
1457+
const path = typeof entry?.path === 'string' ? entry.path : '';
1458+
// `GET /meta/book/:name/tree` — the type segment is literal here.
1459+
if (path.endsWith('/book/:name/tree')) return true;
1460+
// `GET /meta/:type` and `GET /meta/:type/:name` — book/doc only. Every
1461+
// other type (object, field, view, flow, …) keeps the anonymous deny.
1462+
if (!/\/:type(\/:name)?$/.test(path)) return false;
1463+
const type = RestServer.metaTypeSingular(req?.params?.type);
1464+
return type === 'book' || type === 'doc';
1465+
}
1466+
14351467
/** Whether any of these books carries a `{ permissionSet }` audience. */
14361468
private static anyPermissionSetAudience(books: readonly any[]): boolean {
14371469
return books.some(
@@ -2422,7 +2454,30 @@ export class RestServer {
24222454
// each `/data` handler derives.
24232455
const environmentId = req?.params?.environmentId;
24242456
const context = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
2425-
if (this.enforceAuth(req, res, context)) return;
2457+
// [#3963] `audience: 'public'` is a DECLARED capability, so it
2458+
// must not depend on a deployment flipping its whole data plane
2459+
// open (`requireAuth: false`). An anonymous read of the
2460+
// book/doc surface skips the anonymous-deny and is authorized
2461+
// instead by the ADR-0046 §6.7 audience gate inside the handler
2462+
// — the same declaration-derived shape ADR-0056 Option A chose
2463+
// for public form submission (`publicFormGrant`).
2464+
//
2465+
// Deliberately narrow, in three independent ways:
2466+
// 1. only when NO context resolved. An authenticated caller
2467+
// still goes through `enforceAuth` unchanged, so the
2468+
// ADR-0069 auth-policy gate (expired password, enforced
2469+
// MFA) keeps applying to a gated session's book reads;
2470+
// 2. only GET, and only the book/doc routes (see
2471+
// {@link isPublicAudienceRead}) — `/meta/object` stays 401
2472+
// for anonymous, which is the whole point of the umbrella
2473+
// gate;
2474+
// 3. the handler still decides. `audienceAllows` returns true
2475+
// for `'public'` ONLY; `org` and `{ permissionSet }` books
2476+
// require `caller.authenticated`, and unresolvable holdings
2477+
// fail closed. This grants REACHABILITY, not authorization.
2478+
const anonymousPublicRead = !context?.userId
2479+
&& RestServer.isPublicAudienceRead(entry, req);
2480+
if (!anonymousPublicRead && this.enforceAuth(req, res, context)) return;
24262481
return (inner as (rq: any, rs: any) => unknown)(req, res);
24272482
},
24282483
} as any);

0 commit comments

Comments
 (0)