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
19 changes: 19 additions & 0 deletions .changeset/22430-api-description-anonymous-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/rest': minor
---

fix(rest)!: the API-description endpoints refuse an anonymous caller

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) A runtime authorization narrowing at the two API-description routes that RestServer.registerOpenApiEndpoints mounts (the document and its viewer page), not a metadata change. No spec key, export, option, config field, response field or stored shape is removed, renamed or re-shaped, and the document a signed-in caller receives is unchanged, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which callers the two routes serve: a caller with no session is now refused with the shared anonymous-deny 401, a session held by an auth policy gets that policy's 403, and a signed-in caller is served as before. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers these routes and this diff adds none (not registered / already-registered); and no published interface or type changes (not runtime-interface-only / type-surface-only). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

`RestServer.registerOpenApiEndpoints` mounts the API-description endpoints: the document and its viewer page. Until now they served a caller with no session, while the data and metadata routes beside them refused one. ADR-0056 D2 denies anonymous callers by default.

- **Refused now:** a caller with no session, with `401` and code `UNAUTHENTICATED` — the same body the data routes answer. Both handlers check before any other work, so the refusal does not say whether a document is bundled.
- **Also refused:** a session held by an auth policy (an expired password, enforced MFA) gets that policy's `403`, as on every other protected route.
- **Unchanged:** a signed-in caller is served the same document and the same viewer page as before. The viewer page loads the document from the browser with the browser's session, so a signed-in browser sees the viewer work as before.

What changes for you. If you published the API description to readers without an account, have them sign in first, or publish a static copy of the document instead.
10 changes: 10 additions & 0 deletions packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,16 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
// which stays in the shrink-only baseline.
covers: ['analytics:domains/analytics.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/analytics'],
note: 'Ungated, an unauthenticated caller — carried to the domain as the guest envelope — was answered 200 on every analytics face on a stock boot, with aggregates over arbitrary objects, while the record doors and the dataset door on the same boot answered it 401. Gating the DOMAIN rather than each face keeps a newly added analytics face from arriving ungated. This row is the door half only: a non-system caller who resolves NO permission set being granted object admission and an unscoped read by that absence is a security-layer question for every door at once, deliberately not copied into service-analytics; it is tracked on its own card. The per-face unit pins (anonymous 401 with the service never consulted, malformed body and empty slot still 401, signed-in control unchanged) live in runtime/domains/analytics-anonymous-deny.test.ts.' },
// #22430 — the API-description endpoints, under the ruling recorded on #22146
// (the API-description endpoints refuse an anonymous caller with 401).
{ id: 'anonymous-deny-api-description', summary: 'anonymous-deny on the API-description endpoints (the document and its viewer)', state: 'enforced',
enforcement: 'rest/rest-server.ts registerOpenApiEndpoints — BOTH handlers (the document and its viewer page, on every base the server mounts) open with enforceAuth (→ shouldDenyAnonymous) as their first act, ahead of the bundled-artifact load and the protocol resolution, so a refused request does no work and does not reveal whether a document is bundled; an anonymous caller gets the REST seam\'s flat 401 UNAUTHENTICATED, a signed-in caller is served as before, and a session held by the ADR-0069 auth-policy gate gets that gate\'s 403',
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
// The REST family at ledger granularity. The family is exactly these two
// routes and both handlers open with the floor, which is the property a
// family key needs.
covers: ['rest-family:rest-route-ledger.ts:openapi'],
note: 'Measured on a booted showcase before the change: an unauthenticated caller was served the document and the viewer page with 200, just as a signed-in caller was, while the record doors on the same boot answered it 401 — ADR-0056 D2 is default-deny and no ADR-0138 D2 door class names these endpoints. The cited proof drives both endpoints anonymously (401, the REST flat envelope, nothing of either served) and with a signed-in member (200, the document / the viewer page) on one boot. The environment-scoped twin mounts only under project scoping, which the showcase does not enable; it is the same handler closure, pinned per base in rest/src/rest-api-description-anonymous-deny.test.ts beside the gated-session 403 and the permission-store-outage 503. The viewer page fetches the document from the browser, and that request carries the browser\'s session (measured with the real viewer in a browser), so a signed-in browser is served on both endpoints; a deployment that publishes its API description to readers without an account publishes a static copy of the document instead.' },

// ── #2992 / ADR-0096 D4 — latent execution surfaces (pre-wiring identity
// admission). Neither surface is reachable by a client today; these rows
Expand Down
2 changes: 2 additions & 0 deletions packages/qa/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,8 @@ describe('the ledger-sourced population and its baseline bite', () => {
'dispatcher-domain:route-ledger.ts:/meta',
'dispatcher-domain:route-ledger.ts:/packages',
'rest-family:rest-route-ledger.ts:metadata',
// [#22430] classified by `anonymous-deny-api-description`.
'rest-family:rest-route-ledger.ts:openapi',
]);
// …and the two halves partition the population exactly: nothing minted by
// a ledger probe is both, and nothing is neither.
Expand Down
11 changes: 7 additions & 4 deletions packages/qa/dogfood/test/authz-ledger-population.baseline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@
export const LEDGER_POPULATION_BASELINE: readonly string[] = [
// ── REST families (`packages/rest/src/rest-route-ledger.ts`) ────────────
// `metadata` is absent because it IS classified — `anonymous-deny-meta`
// covers it through the guarded registrar. The other 18 families are here.
// covers it through the guarded registrar — and so is `openapi`, classified
// by `anonymous-deny-api-description` since #22430. The other 16 families
// are here.
'rest-family:rest-route-ledger.ts:analytics',
'rest-family:rest-route-ledger.ts:approvals',
'rest-family:rest-route-ledger.ts:batch',
Expand All @@ -80,7 +82,6 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [
'rest-family:rest-route-ledger.ts:email',
'rest-family:rest-route-ledger.ts:external-datasource',
'rest-family:rest-route-ledger.ts:forms',
'rest-family:rest-route-ledger.ts:openapi',
// ⚠️ NOT the same surface as `dispatcher-domain:route-ledger.ts:/packages`,
// which IS classified: that key names the dispatcher domain whose single
// handler body carries the domain-wide gate. This one names the four routes
Expand Down Expand Up @@ -126,6 +127,8 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [
* 2026-09-25 (#20102): `rest-family:rest-route-ledger.ts:reports` left with the
* retired saved-report family — deleted, not classified. 32 at 2026-10-01
* (#21061): `dispatcher-domain:route-ledger.ts:/analytics` left classified, by
* the `anonymous-deny-analytics` row and its domain gate pin.
* the `anonymous-deny-analytics` row and its domain gate pin. 31 at 2026-10-09
* (#22430): `rest-family:rest-route-ledger.ts:openapi` left classified, by the
* `anonymous-deny-api-description` row and its booted-showcase proof.
*/
export const LEDGER_POPULATION_BASELINE_MAX = 32;
export const LEDGER_POPULATION_BASELINE_MAX = 31;
20 changes: 16 additions & 4 deletions packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,10 @@ export interface ProbeTableReading {
// table as a GATE_PIN on `packages/runtime/src/domains/analytics.ts`, and the
// `anonymous-deny-analytics` row covers its key plus the `/analytics`
// dispatcher-domain key that left the shrink-only ledger baseline.
export const PROBE_TABLE: ProbeTableReading = { entries: 19, files: 14, keys: 17 };
// [#22430] 19 / 14 / 17 -> 19 / 14 / 18: no probe and no file joined; the
// `anonymous-deny-api-description` row covers one more key, the `openapi` REST
// family, which left the shrink-only ledger baseline.
export const PROBE_TABLE: ProbeTableReading = { entries: 19, files: 14, keys: 18 };

/**
* The probe count `authz-conformance.matrix.ts`'s header states.
Expand Down Expand Up @@ -342,13 +345,15 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
blindSpot: 0,
populationRule: 'ledger rows inside REST_ROUTE_LEDGER; reachable = rows carrying a `family` (each distinct value mints a key)',
controls: { "route: '": 82, "family: '": 82, RestRouteLedgerEntry: 2 },
// [#22430] The `openapi` family is the second classified one, by the
// `anonymous-deny-api-description` row; the note's 2 / 16 counts it.
note:
'The audited disposition of every route @objectstack/rest mounts, enumerated through ' +
'RestServer.getRoutes() on a booted server and guarded per route by rest-route-ledger.conformance.test.ts. ' +
'That guard is why this file can be a population source and a regex table cannot: a mounted route with no ' +
'row here is already RED in another package, so a new family cannot be silently absent from this file, ' +
'and therefore cannot be silently absent from the authz ratchet either. 18 families; 1 classified by a ' +
'matrix row (metadata), 17 enumerated in the shrink-only baseline. Re-measured 83 -> 82 when the anonymous ' +
'and therefore cannot be silently absent from the authz ratchet either. 18 families; 2 classified by ' +
'matrix rows (metadata and openapi), 16 enumerated in the shrink-only baseline. Re-measured 83 -> 82 when the anonymous ' +
'public-form lookup-picker row left with its route; it carried `family: forms`, which the surviving form rows ' +
'still carry, so `reachable` moved with `population` and the families stay 18. Earlier re-measured 91 -> 83 (19 -> 18 families) ' +
'when the whole saved-report `reports` family left with its eight routes, all eight carrying the family, so ' +
Expand Down Expand Up @@ -499,6 +504,13 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
// not move: the handler never called `enforceAuth` (the public-form routes
// bypass it), so that stays 56, and `registerFormEndpoints` survives, so
// `private register*Endpoints(` stays 16.
// [#22430] `enforceAuth` 56 -> 58, and nothing else on this row moved:
// 71 / 19 / 52 and every other control re-derived unchanged. The two
// API-description handlers in `registerOpenApiEndpoints` (the document and
// its viewer) now open with `if (this.enforceAuth(req, res, context))
// return;` — two call sites, no prose mention. Both routes were already in
// the population and stay outside `registerMetadataEndpoints`, so the blind
// spot is unchanged: guarding a route is not reaching it with a key.
controls: {
'private register*Endpoints(': 16,
'this.routeManager.register(': 64,
Expand All @@ -509,7 +521,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
'registerPerItemRoute(': 8,
'const registerPerItemRoute =': 1,
'forwarder slice: this.routeManager.register(': 1,
enforceAuth: 56,
enforceAuth: 58,
},
note:
'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' +
Expand Down
13 changes: 7 additions & 6 deletions packages/qa/dogfood/test/authz-probe-blind-spot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,20 @@ describe('authz probe blind-spot census (#13260)', () => {
expect(derived.table).toEqual(PROBE_TABLE);
});

it('every classified key is accounted for — 17 `covers` keys, no more', () => {
// The matrix's `covers` keys number 17 today: the 9 probe-minted keys this
it('every classified key is accounted for — 18 `covers` keys, no more', () => {
// The matrix's `covers` keys number 18 today: the 9 probe-minted keys this
// census was first measured against, plus the 6 ledger family/domain keys
// classified when the population moved (2026-08-31), plus the `/analytics`
// domain's gate-pin key and its dispatcher-domain key (#21061). If a probe
// domain's gate-pin key and its dispatcher-domain key (#21061), plus the
// `openapi` REST family key (#22430). If a probe
// starts minting a key no row covers, the ratchet itself goes red as
// UNCLASSIFIED — that is its job, and this pin does not duplicate it. What
// this asserts is only that the census's key count is current.
//
// ⚠️ This is NOT the size of the population. The ledgers mint 39 keys; 7
// are classified here and 32 are enumerated in the shrink-only baseline,
// ⚠️ This is NOT the size of the population. The ledgers mint 39 keys; 8
// are classified here and 31 are enumerated in the shrink-only baseline,
// which `authz-conformance.test.ts` holds to its own four rules.
expect(PROBE_TABLE.keys).toBe(17);
expect(PROBE_TABLE.keys).toBe(18);
});

it.each(PROBE_FILE_CENSUS.map((r) => [r.file, r] as const))(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@
// authz-row: anonymous-deny-automation
// authz-row: anonymous-deny-packages
// authz-row: anonymous-deny-analytics
// authz-row: anonymous-deny-api-description
//
// ── Why the API-description endpoints are here (#22430) ────────────────────
//
// `@objectstack/rest`'s `registerOpenApiEndpoints` mounts the API-description
// endpoints — the document and its viewer page — and until #22430 neither
// handler asked who was calling: on a stock boot both answered an
// unauthenticated caller 200, exactly as they answered a signed-in one, while
// the record doors beside them answered 401. Both are driven below anonymously
// (401 in the REST flat envelope, nothing of either served) and with a
// signed-in member as the 200 control, on this one boot.
//
// ── Why the analytics faces are here (#21061) ──────────────────────────────
//
Expand Down Expand Up @@ -137,6 +148,21 @@ const ANALYTICS_FACES: readonly AnalyticsFace[] = [
{ face: 'the analytics meta face', method: 'GET', path: '/analytics/meta' },
];

// ── #22430 — the API-description endpoints (@objectstack/rest) ──────────────
//
// Both register on every base the server mounts; this boot mounts only the
// unscoped one (the showcase does not enable project scoping), and the
// environment-scoped twin — the same handler closures — is pinned per base in
// `packages/rest/src/rest-api-description-anonymous-deny.test.ts`.
interface ApiDescriptionEndpoint {
readonly endpoint: string;
readonly path: string;
}
const API_DESCRIPTION_ENDPOINTS: readonly ApiDescriptionEndpoint[] = [
{ endpoint: 'the API-description document', path: '/openapi.json' },
{ endpoint: 'the API-description viewer', path: '/docs' },
];

// ── #11373 — the /meta WRITE doors, driven through the REAL mount ──────────
//
// The `/meta` cases above this line were, for this file's whole life, ONE
Expand Down Expand Up @@ -636,6 +662,40 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
},
);

// ── the API-description endpoints (@objectstack/rest) — #22430 ──────────
//
// The refusal is the handler's first act, so the anonymous body is the REST
// seam's shared one, whole, and carries nothing of the document or the page.
// The member control is the teeth: the same endpoint, with a session, is
// served 200 on this boot, so the 401 is the floor's answer and the endpoint
// is really mounted here.
it.each(API_DESCRIPTION_ENDPOINTS)('an anonymous caller on $endpoint is denied (401) and served nothing', async (ep) => {
const r = await anon('GET', ep.path);
expect(r.status, `${ep.endpoint}: an anonymous caller must be refused by the floor`).toBe(ANONYMOUS_DENY_STATUS);
expect(r.headers.get('content-type') ?? '', `${ep.endpoint}: the refusal is JSON, never the page`).toContain('application/json');
const body = await r.json();
expect(body, `${ep.endpoint}: the refusal is the REST seam's shared body, whole`).toEqual(ANONYMOUS_DENY_BODY);
expect(body, `${ep.endpoint}: nothing of the document rides on the refusal`).not.toHaveProperty('openapi');
expect(body, `${ep.endpoint}: nothing of the document rides on the refusal`).not.toHaveProperty('paths');
});

it('a signed-in member is served the API-description document (200) — the control', async () => {
const r = await stack.apiAs(memberToken, 'GET', '/openapi.json');
expect(r.status, 'an authenticated member must clear the floor and be served the document').toBe(200);
const body = await r.json();
expect(body.openapi, 'the served body is the OpenAPI 3.1 document').toBe('3.1.0');
expect(Object.keys(body.paths ?? {}).length, 'the document describes the mounted surface').toBeGreaterThan(0);
});

it('a signed-in member is served the API-description viewer page (200) — the control', async () => {
const r = await stack.apiAs(memberToken, 'GET', '/docs');
expect(r.status, 'an authenticated member must clear the floor and be served the viewer').toBe(200);
expect(r.headers.get('content-type') ?? '', 'the viewer is an HTML page').toContain('text/html');
// The page loads its sibling document on the same base — the request that,
// from a signed-in browser, carries that browser's session.
expect(await r.text(), 'the viewer points at its sibling document').toContain('data-url="/api/v1/openapi.json"');
});

// ── one code, one message — two wrappers ───────────────────────────────
it('every denied surface answers the SAME code and message (the wrappers differ)', async () => {
const rest = await Promise.all([
Expand Down Expand Up @@ -740,6 +800,13 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
// [#21060] The two remaining REST families the checklist item names.
{ seam: 'POST /batch', owner: '@objectstack/rest enforceAuth', family: 'rest-flat', call: () => anon('POST', BATCH, BATCH_PROBE_BODY) },
{ seam: 'GET /security/explain', owner: '@objectstack/rest enforceAuth', family: 'rest-flat', call: () => anon('GET', EXPLAIN) },
// [#22430] The API-description endpoints, labelled by endpoint.
...API_DESCRIPTION_ENDPOINTS.map((ep) => ({
seam: ep.endpoint,
owner: '@objectstack/rest registerOpenApiEndpoints (enforceAuth)',
family: 'rest-flat' as DenyFamily,
call: () => anon('GET', ep.path),
})),
{ seam: 'POST /actions/:object/:action/:id', owner: 'runtime domains/actions.ts', family: 'dispatcher-wrapper', call: () => anon('POST', ACTION, { params: {} }) },
{ seam: 'POST /automation/:name/trigger', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('POST', `/automation/${FLOW}/trigger`, {}) },
{ seam: 'GET /automation/_status', owner: 'runtime domains/automation.ts', family: 'dispatcher-wrapper', call: () => anon('GET', '/automation/_status') },
Expand Down
Loading
Loading