Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/public-book-grant.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/permissions/authorization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
Expand Down
34 changes: 28 additions & 6 deletions docs/adr/0046-package-docs-as-metadata.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand Down
146 changes: 146 additions & 0 deletions packages/rest/src/meta-public-book-grant.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
57 changes: 56 additions & 1 deletion packages/rest/src/rest-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<Record<string, unknown>>,
req: { method?: unknown; params?: Record<string, unknown> },
): 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(
Expand DownExpand Up@@ -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);
Expand Down
Loading