Skip to content

Commit ec7c7e0

Browse files
fix(metadata-protocol)!: a nested-relation filter condition asks the related object its declared exposure (#22737) (#22768)
Fixes #22737 Clause-②: no (narrowing) Census row 4 of the second-object exposure census (the #22661 card). Classes, positions and functions only; the fixtures are synthetic. ## What changes A nested-relation filter condition, a condition on a lookup's related object, is evaluated on that related object: `ObjectQL.lowerRelationConditions` reads the TARGET as the caller and keeps the source rows that point at a match. The data door never asked the target's declared exposure, so the list and query routes evaluated such a condition even for a target every data route refuses. The door's nested-relation walk (`refuseInternalInFilter` over `relationConditionSites`, in `findData`'s `assertNoInternalFieldEvaluated`) now asks each condition's TARGET the spec's one exposure decision before anything of it is judged. The new method is `refuseUnservedRelationTarget` (`packages/metadata-protocol/src/protocol.ts`), and it calls `apiExposureDenialReason`. A target the decision does not serve is REFUSED with the data door's existing exposure answer: - `apiEnabled: false` → `404 OBJECT_API_DISABLED`; - an `apiMethods` whitelist without the operation → `405 OBJECT_API_METHOD_NOT_ALLOWED`, with the effective operation set. No new code, no second rule or list. The engine and the filter compilation are unchanged, so the engine's privileged callers keep their path. - **Operation: `list`, not `get`.** `$expand` (#22661) asks `get` because it turns ids the caller already holds into records. A condition is a PREDICATE over the target: the engine runs a `find` on the target with the condition, across the whole object, bounded by a match cap rather than by the source rows' ids. That is the read the target's own filtered list performs, and `query` → `list` in `DATA_ACTION_TO_API_OPERATION`. So a `get`-only target is refused here while it stays served to `$expand`. A `list`-only target is served. - **Refused, not withheld.** Dropping a condition would widen the result set. The precedent at this position is also a refusal: a related object the member may not read answers `403 PERMISSION_DENIED` there, measured below and pinned unchanged. - **Every position the walk covers:** `where`, the `filter` alias, each `aggregations[i].filter`, and each `expand` entry's own `where` at every level. The grouped branch is refused before `engine.aggregate`. An `$expand` entry into an unexposed target is still withheld first (`servedExpand`), so its filter is never judged. - **The refusal names the target object** and the condition's position in its message. The lookup's declared target is already in the source object's served schema (measured: the source's `/meta` object read mentions the target for both personas), and the `internal: true` refusal at this same hop names it too. - **Provenance.** `@objectstack/metadata-protocol` now stamps both exposure codes, so its section of the error-code ledger lists them. This is one pure append in that section: no schema shape change, no existing entry touched. ## Measurement (real stack: `bootStack`, the real security, engine, SQL driver and REST layers; administrator and member) Fixture source object with lookups into targets of each exposure shape. Each answer was read on the list route and on the query route, once for a matching related value and once for a non-matching one. | target shape | before (base `bf515e724d`) | after (this branch) | |---|---|---| | `apiEnabled: false` | 200, 1 row on match / 0 on miss | 404 `OBJECT_API_DISABLED` | | `apiMethods: ['create']` | 200, 1 / 0 | 405 `OBJECT_API_METHOD_NOT_ALLOWED` | | `apiMethods: []` | 200, 1 / 0 | 405 `OBJECT_API_METHOD_NOT_ALLOWED` | | `apiMethods: ['get']` | 200, 1 / 0 | 405 `OBJECT_API_METHOD_NOT_ALLOWED` | | `apiMethods: ['list']` (control) | 200, 1 / 0 | 200, 1 / 0, unchanged | | no `enable` block (control) | 200, 1 / 0 | 200, 1 / 0, unchanged | | exposed, member holds no read (precedent) | member 403 `PERMISSION_DENIED` | unchanged | Administrator and member answered alike on every row except the precedent row, where only the member lacks the grant. The #22661 dev's reading (1–2 rows on a match, 0 on a miss) is confirmed. ## Pins - `packages/metadata-protocol/src/protocol.relation-condition-target-exposure.test.ts` (new, 13 cases). Each refused shape is asserted on `code` + `status` (and `allowed` for 405), for a member context and a system context, on the POST `where` and on a `$filter` string under `$or`. Each case also asserts that `engine.find` / `engine.aggregate` is never reached. It also covers the grouped branch and an `expand` entry's own filter. Controls: a `list`-only target, an open target, and a plain id comparand on the same lookups. - `packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts` (#22661's real-stack pin, +5 cases). For both personas: refused (`status` + `code`) on the list and query routes, matched or not; control targets still evaluated, 2 rows / 0 rows. Precedent: a member refused 403 `PERMISSION_DENIED`, unchanged. - `packages/core/src/security/second-object-read-exposure.pin.test.ts`: census row 4 moves from `open` to `decided` (operation `list`; decision `refuseUnservedRelationTarget`; wiring `this.refuseUnservedRelationTarget(`; pin as above). The type admits `'get' | 'list'`. ## Verification - Ablation A, on committed `fe8d208a87` via `ablation-replace.mjs`: the hop call deleted (anchor 1 → 0, blob changed) → the unit pin goes 10 red / 3 green (the three controls). Restored: blob == HEAD, `git diff HEAD` empty. - Ablation B: the call replaced by a compiling marker, metadata-protocol rebuilt (exit 0), `ablation-dist-preflight` marker present in `dist/`. The dogfood pin goes 2 red (the refused legs, admin and member) / 28 green. Restore leg: blob == HEAD, rebuilt, preflight `--absent` exit 0. A first B attempt with a non-compiling mutation (an unused private method) had a build exit 1 while the JS still carried the marker. It was re-run with the compiling mutation, and these numbers are from that run. - Ablation C (the pin's D2 leg): the decision call replaced by a hand-spelled rule → the enumeration pin goes 1 red, naming `refuseUnservedRelationTarget`. - Ablation D: the two ledger rows deleted → `check:error-code-provenance` FAIL, naming both stamp sites. The first D attempt was refused by `ablation-replace` (replacement text was a substring of the anchor), so nothing ran. It was re-run in delete mode. - Packages, at `fe8d208a87`: metadata-protocol vitest 225 files / 28129 passed (19 skipped); metadata-protocol `tsc --noEmit` exit 0 with the new test in `--listFiles`; core `typecheck` (including `check:test-typecheck`) exit 0. - On the merge head `151278b21c` (origin/main `e86530088a` merged, closure rebuilt): unit pins 28/28, core `repo` project 5 files / 55, dogfood pin 30/30, dogfood `tsc --noEmit` exit 0 with the file listed, and spec `check:generated` (14 artifacts) up to date. - Gates: at `151278b21c` (round 1's merge head), the union of the dispatch list (82) and `dispatch-gates --commands` on this diff (91) is 95 commands, and all 95 exit 0. Each exit code was captured before any pipe. `--ran` reconciliation: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN; 4 more were run from the dispatch list outside this diff's derivation. `check:dual-build-cjs-loads` was first `PREREQUISITE NOT MET` (exit 3) on a tree missing 8 dists; it exited 0 after building them, and again on this head. Named: `check:error-code-provenance` OK (red without the ledger rows, ablation D), `check:error-status-conformance`, `check:error-code-casing`, `check:dispatcher-error-vocabulary`, the ADR-0087 marker (`not-required (no-migration-prescription)`), `check-changeset-no-major`, `check-empty-changeset`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage` / `-debt`, `check:published-files`, `check:authorable-surface` and `check:api-surface`. - Round 2 at `964aceffd6`: types 27 files / 754 passed plus repo 11; rest local 272 files / 5239 passed (run in three chunks) plus repo 5 files / 209; dogfood pin 34/34; ablation E (arm deleted, types rebuilt, preflight `--absent`) reds the types pin 2 / 4, the rest guards 8 / 210 and the dogfood target leg 2 / 34, with controls green; restored blob == HEAD; 95 gates exit 0. - Rounds 3 and 4 merged `main` only, with no rebase and no force-push: `04cf472669` (after #22766, census rows 5–6) and `606e50f7ef` (after #22770, census row 7), the final commit. Each conflicted only in the census pin (`second-object-read-exposure.pin.test.ts`), resolved as the union: six `decided` reads, and no CALLERS row left `open`. Every other file's net change is byte-identical to round 2's. At `606e50f7ef`, with the dogfood closure rebuilt: core `repo` 5 files / 55; metadata-protocol's two exposure pins 28/28; the dogfood pin 34/34; the types exposure-arm pin 4/4; rest `repo` 5 files / 209; spec `check:generated` (14 artifacts) up to date; the same 95 gates exit 0 (`--ran`: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN). - Lint, narrowed to the 5 changed `.ts` files: `eslint --no-inline-config --format json` reports 5 files, 0 errors, 0 warnings. `--print-config` shows 5 active rules per file, none ignored. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so no untouched file's verdict can move. Repo-wide `pnpm lint` is CI's. ## Acceptance notes - **Fixed in this PR:** the flat REST body named the route object beside the exposure code. One arm in `@objectstack/types` `structuredCodeAnswer` (asked first by `classifyDataError`, so both REST doors agree) now answers with the error's own `object`, and relays `allowed` on 405, when the error names an object other than a defined route object. An error naming the route object, or a door given none (the analytics dataset route), answers byte for byte as before. - The MCP stdio data bridge (`stdio-data-bridge.ts`) hands a caller's filter to the engine directly, after its own addressed-object gate. Measured in round 2 at the MCP tool surface: the same class as this card. It is carried by #22777 (a sub-issue of #22661), which waits for this card; `packages/mcp` is untouched here. - Deviations: (a) three changesets: a `minor` BREAKING one for `@objectstack/metadata-protocol` and a `patch` one for `@objectstack/spec`'s ledger value, the shape the analytics rows' precedent took. (b) The real-stack legs sit in #22661's dogfood pin, outside the named file surface, beside their sibling legs. (c) The diff is 473 changed lines (+457 / -16) across 12 files at the final head: source +57/-4 (`protocol.ts`) and +23 (`data-error-classification.ts`); the rest is tests, three changesets, the pin row and the ledger rows. (d) Round 2 added test-only rows to two `packages/rest` drift guards that require coverage of every new arm (`error-response-structured-arm-door-parity.test.ts`, `error-response-sandbox-arm-message.test.ts`), and a third changeset (`@objectstack/types` `patch`). --- _Generated by [Claude Code](https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b0a9c9 commit ec7c7e0

12 files changed

Lines changed: 457 additions & 16 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol)!: a nested-relation filter condition asks the related object its declared exposure, and is refused when that object does not serve `list` (#22737)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is one runtime read position: the generic data door stops evaluating a filter condition on a lookup's target object whose existing `enable` declaration already refuses a query of it on every data route, and the remedy is a filter the caller can already write. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
A nested-relation filter condition, `{ <lookup>: { <field of the related object>: … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one.
14+
15+
**FROM.** On `GET /api/v1/data/:object?filter=…` and `POST /api/v1/data/:object/query`, for an administrator and a member alike, such a condition was evaluated whatever the related object's `enable` block declared. A source row came back only when the related value matched, even for a related object every data route refuses.
16+
17+
**TO.** The generic data door asks the spec's one exposure decision (`apiExposureDenialReason`) of the related object, for `list`, the operation `GET /api/v1/data/:target?filter=…` is judged as. A condition is a query of the related object, not a read of a record whose id the caller holds, so a `get`-only whitelist does not serve it. A related object the decision does not serve is refused, with the data door's own exposure answer:
18+
19+
- `enable.apiEnabled: false` → `404 OBJECT_API_DISABLED`.
20+
- an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `405 OBJECT_API_METHOD_NOT_ALLOWED`, with the effective operation set in `allowed`.
21+
22+
The refusal names the related object and the condition's position. It applies wherever the door walks a filter: `where`, each aggregation's `filter`, and an `expand` entry's own `where` at every level.
23+
24+
**Migration.** Filter on the lookup's stored id (`{ <lookup>: '<id>' }` or `{ <lookup>: { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`.
25+
26+
**Bindings.**
27+
28+
- **Every caller.** The decision takes no user, so an administrator, a member and a system context get the same answer.
29+
- **Refused, not withheld.** Dropping a condition would widen the result. A related object the caller may not read already answers this position with a refusal (`403`).
30+
- **The engine's privileged callers are untouched.** A flow's or a hook's own query calls the engine directly and never passes the generic data door.
31+
- **No new error code.** `@objectstack/metadata-protocol` now stamps the two existing exposure codes, and the error-code ledger lists them under that package too.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the error-code ledger records `@objectstack/metadata-protocol` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22737)
6+
7+
The generic data door now answers the two exposure codes for a nested-relation filter condition whose related object's `enable` block refuses a query of it, so `ERROR_CODE_LEDGER['@objectstack/metadata-protocol']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered under `@objectstack/rest`. No export, schema or generated artifact moves.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/types': patch
3+
---
4+
5+
fix(types): a thrown exposure refusal about a second object names that object on the REST body (#22737)
6+
7+
The shared REST error classification gains one arm for `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED`. Both REST error doors ask it: `mapDataError`, and `resolveErrorResponse` behind `sendThrownError`.
8+
9+
When the thrown error names an object other than the one the route addressed, the body's `object` is that object, and a `405` carries the effective operation set as `allowed`. The generic data door throws this when a nested-relation condition's related object is not served by the API. The new body is the same one the REST door already sends for these codes about the addressed object. Before, the body named the route's object beside a code saying that object was switched off.
10+
11+
An error naming the route's own object, or a door given no route object, answers exactly as before. No code is added.

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
* The data routes judge the ADDRESSED object. A read that follows a lookup
1212
* reaches an object nobody addressed: the data door's `$expand` served the row
1313
* fields of a target every data route refuses, the dataset door's label
14-
* passes rendered its display names, and the import door matched cells against
15-
* it (#22739). Each read classified `decided` below now asks the target
16-
* (behaviour-pinned in its own package). This
14+
* passes rendered its display names, the import door matched cells against
15+
* it (#22739), and the data door's nested-relation filter condition evaluated
16+
* a predicate on it (#22737). Each read classified `decided` below now asks the
17+
* target (behaviour-pinned in its own package). This
1718
* file is the ENUMERATION: it names the census of such reads, holds each
1819
* decided one to the decision, and goes red when a new one appears without
1920
* being classified here.
@@ -137,12 +138,12 @@ const CALLERS: Record<string, Classification> = {
137138
pin: 'packages/core/src/utils/import-runner-reference-exposure.test.ts',
138139
},
139140
'packages/objectql/src/relation-filter-lowering.ts': {
140-
kind: 'open',
141-
read: "the data door's nested-relation filter condition (`ObjectQL.lowerRelationConditions` reads the related object to lower it)",
142-
measured: 'evaluated for an unexposed target, for an administrator and a member: the source row comes back only when the related value matches',
143-
carrier:
144-
'a filter cannot be withheld, so its answer is a refusal in the data door\'s exposure codes, which needs a ' +
145-
'provenance row for the door and touches a filter position; carried by a follow-up of #22661',
141+
kind: 'decided',
142+
read: "the data door's nested-relation filter condition (`ObjectQL.lowerRelationConditions` reads the related object to lower it), refused rather than withheld",
143+
operation: 'list',
144+
decision: { file: 'packages/metadata-protocol/src/protocol.ts', fn: 'refuseUnservedRelationTarget' },
145+
wiring: { file: 'packages/metadata-protocol/src/protocol.ts', call: 'this.refuseUnservedRelationTarget(' },
146+
pin: 'packages/metadata-protocol/src/protocol.relation-condition-target-exposure.test.ts',
146147
},
147148
'packages/plugins/plugin-approvals/src/approval-service.ts': {
148149
kind: 'decided',
@@ -289,6 +290,7 @@ describe('[#22661] every read that reaches a second object asks that object its
289290
(c): c is Extract<Classification, { kind: 'decided' }> => c.kind === 'decided',
290291
);
291292
expect(decided.map((d) => d.decision.fn).sort()).toEqual([
293+
'refuseUnservedRelationTarget',
292294
'servesExpansionTarget',
293295
'servesLabelTarget',
294296
'servesPayloadDisplayTarget',
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22737] A NESTED-RELATION filter condition is evaluated on the related
5+
* object, so the data door asks that TARGET the spec's one exposure decision
6+
* (`apiExposureDenialReason(enable, 'list')`, ADR-0049) and refuses a target
7+
* it does not serve, in the door's own exposure answer: `404
8+
* OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` with the
9+
* effective operation set.
10+
*
11+
* Asserted on the envelope (`code` and `status`) and on the engine: a refused
12+
* condition never reaches `engine.find` / `engine.aggregate`, so nothing of the
13+
* target is read. Each refused shape is paired with a CONTROL the decision
14+
* serves, which must reach the engine unchanged. The administrator and member
15+
* answers on a real stack are pinned in `@objectstack/dogfood`.
16+
*/
17+
18+
import { describe, expect, it, vi } from 'vitest';
19+
import type { ServiceObject } from '@objectstack/spec/data';
20+
import { ObjectStackProtocolImplementation } from './protocol.js';
21+
22+
const lookup = (name: string, reference: string) => ({ name, label: name, type: 'lookup', reference });
23+
24+
const object = (name: string, enable?: Record<string, unknown>, extra: Record<string, unknown> = {}): ServiceObject => ({
25+
name,
26+
label: name,
27+
...(enable ? { enable } : {}),
28+
fields: {
29+
id: { name: 'id', label: 'ID', type: 'text' },
30+
name: { name: 'name', label: 'Name', type: 'text' },
31+
...extra,
32+
},
33+
} as unknown as ServiceObject);
34+
35+
const SCHEMAS: Record<string, unknown> = {
36+
rc_source: object('rc_source', undefined, {
37+
hidden: lookup('hidden', 'rc_hidden'),
38+
closed: lookup('closed', 'rc_closed'),
39+
denyall: lookup('denyall', 'rc_denyall'),
40+
getonly: lookup('getonly', 'rc_getonly'),
41+
listonly: lookup('listonly', 'rc_listonly'),
42+
open: lookup('open', 'rc_open'),
43+
}),
44+
// `apiEnabled: false` is judged FIRST: a whitelist granting `list` beside it changes nothing.
45+
rc_hidden: object('rc_hidden', { apiEnabled: false, apiMethods: ['list'] }),
46+
rc_closed: object('rc_closed', { apiMethods: ['create'] }),
47+
rc_denyall: object('rc_denyall', { apiMethods: [] }),
48+
rc_getonly: object('rc_getonly', { apiMethods: ['get'] }),
49+
rc_listonly: object('rc_listonly', { apiMethods: ['list'] }),
50+
// Exposed, with a lookup into the off-switched object: an `expand` entry's own filter reaches it.
51+
rc_open: object('rc_open', undefined, { inner: lookup('inner', 'rc_hidden') }),
52+
};
53+
54+
function makeProtocol() {
55+
const find = vi.fn(async () => [{ id: 's1', name: 'source' }]);
56+
const aggregate = vi.fn(async () => [{ name: 'source', n: 1 }]);
57+
const count = vi.fn(async () => 1);
58+
const engine: any = { registry: { getObject: (n: string) => SCHEMAS[n] }, find, aggregate, count };
59+
return { p: new ObjectStackProtocolImplementation(engine), find, aggregate };
60+
}
61+
62+
/** Each refused target, the door's answer, and the effective set a 405 names. */
63+
const REFUSED = {
64+
hidden: { status: 404, code: 'OBJECT_API_DISABLED', allowed: undefined },
65+
closed: { status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', allowed: ['create', 'import'] },
66+
denyall: { status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', allowed: [] },
67+
getonly: { status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', allowed: ['get'] },
68+
} as const;
69+
const SERVED = ['listonly', 'open'] as const;
70+
71+
/** A member and the most privileged caller: the decision takes no caller. */
72+
const CONTEXTS = [{ userId: 'rc_member' }, { isSystem: true }];
73+
74+
describe('[#22737] the data door refuses a nested-relation condition whose target the API does not serve', () => {
75+
for (const [rel, answer] of Object.entries(REFUSED)) {
76+
for (const context of CONTEXTS) {
77+
it(`${rel}: refused ${answer.status} ${answer.code} on the POST where and the list filter (${JSON.stringify(context)})`, async () => {
78+
const { p, find } = makeProtocol();
79+
for (const query of [
80+
{ where: { [rel]: { name: 'x' } } },
81+
{ $filter: JSON.stringify({ name: 'source', $or: [{ [rel]: { name: 'x' } }] }) },
82+
]) {
83+
const err: any = await p.findData({ object: 'rc_source', query, context }).catch((e) => e);
84+
expect(err).toBeInstanceOf(Error);
85+
expect({ code: err.code, status: err.status }).toEqual({ code: answer.code, status: answer.status });
86+
expect(err.object).toBe(`rc_${rel}`);
87+
expect(err.allowed).toEqual(answer.allowed);
88+
}
89+
expect(find).not.toHaveBeenCalled();
90+
});
91+
}
92+
}
93+
94+
for (const rel of SERVED) {
95+
it(`CONTROL ${rel}: served, the condition reaches the engine unchanged`, async () => {
96+
const { p, find } = makeProtocol();
97+
await p.findData({ object: 'rc_source', query: { where: { [rel]: { name: 'x' } } } });
98+
expect(find).toHaveBeenCalledTimes(1);
99+
expect((find.mock.calls[0] as any[])[1].where).toEqual({ [rel]: { name: 'x' } });
100+
});
101+
}
102+
103+
it('the grouped read is refused before the engine aggregates', async () => {
104+
const { p, aggregate } = makeProtocol();
105+
const err: any = await p
106+
.findData({ object: 'rc_source', query: { where: { hidden: { name: 'x' } }, groupBy: ['name'], aggregations: [{ function: 'count', alias: 'n' }] } })
107+
.catch((e) => e);
108+
expect({ code: err.code, status: err.status }).toEqual({ code: 'OBJECT_API_DISABLED', status: 404 });
109+
expect(aggregate).not.toHaveBeenCalled();
110+
});
111+
112+
it('an expand entry\'s own filter is judged against its level: a condition into an unexposed object is refused', async () => {
113+
const { p, find } = makeProtocol();
114+
const err: any = await p
115+
.findData({ object: 'rc_source', query: { expand: { open: { object: 'open', where: { inner: { name: 'x' } } } } } })
116+
.catch((e) => e);
117+
expect({ code: err.code, status: err.status, object: err.object }).toEqual({ code: 'OBJECT_API_DISABLED', status: 404, object: 'rc_hidden' });
118+
expect(find).not.toHaveBeenCalled();
119+
});
120+
121+
it('CONTROL: a plain id comparand on the same lookup is the source\'s own column and is served', async () => {
122+
const { p, find } = makeProtocol();
123+
await p.findData({ object: 'rc_source', query: { where: { hidden: 'h1', getonly: { $in: ['g1'] } } } });
124+
expect(find).toHaveBeenCalledTimes(1);
125+
});
126+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ import {
134134
SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS,
135135
isVirtualSearchField,
136136
type SearchFieldMeta,
137-
canServeApiOperation,
137+
canServeApiOperation, apiExposureDenialReason, effectiveOperationsArray, resolveEffectiveApiMethods,
138138
classifyDottedFilterHead,
139139
foldQueryAliasSlots,
140140
QUERY_TRANSPORT_ALIAS_SLOTS, QUERY_TRANSPORT_DOLLAR_ALIASES, QUERY_TRANSPORT_DOLLAR_PARAMS,
@@ -4463,6 +4463,17 @@ const INTERNAL_FIELD_WALK_DEPTH = 8;
44634463
*/
44644464
const EXPANSION_TARGET_OPERATION = 'get';
44654465

4466+
/**
4467+
* [#22737] The operation a NESTED-RELATION condition's TARGET object is judged
4468+
* as, by the same decision: `list`. Not {@link EXPANSION_TARGET_OPERATION}'s
4469+
* `get`, because the read is not the same one: a condition is a PREDICATE over
4470+
* the target. The engine runs `find(target, { where: condition })` across the
4471+
* whole object, bounded by a match cap rather than by ids the source rows hold
4472+
* (`ObjectQL.lowerRelationConditions`), which is the read
4473+
* `GET /data/{target}?filter=…` performs, and that route is judged `list`.
4474+
*/
4475+
const RELATION_CONDITION_TARGET_OPERATION = 'list';
4476+
44664477
/**
44674478
* [#22646] The NESTED-RELATION conditions a filter holds — a key naming a
44684479
* reference field of the object (`REFERENCE_VALUE_TYPES`) whose value is a
@@ -12523,7 +12534,9 @@ export class ObjectStackProtocolImplementation implements
1252312534
* record matched, so a related `internal` field there is the same
1252412535
* confirmation oracle one hop away. Judged here, at the door, rather than
1252512536
* in the engine's relation-site walk, so the engine's privileged callers
12526-
* keep the #7823 split (none of them reaches this door).
12537+
* keep the #7823 split (none of them reaches this door). The RELATED
12538+
* object's exposure is asked first, at the same hop
12539+
* ({@link refuseUnservedRelationTarget}, #22737).
1252712540
* - every `expand` entry's own `where` / `orderBy`, at EVERY depth of the
1252812541
* expand tree, judged against that level's TARGET object: the nested
1252912542
* sub-read's row strip already withholds the column, but a nested filter
@@ -12566,7 +12579,8 @@ export class ObjectStackProtocolImplementation implements
1256612579
/**
1256712580
* One filter's internal-field judgement: every column it reads on `object`
1256812581
* ({@link collectFilterReads}), then each nested-relation condition it holds,
12569-
* recursively, on the related object. `param` names the position, extended
12582+
* recursively, on the related object, once that object's exposure serves the
12583+
* condition ({@link refuseUnservedRelationTarget}). `param` names the position, extended
1257012584
* by the relation field at each hop (`filter.owner_id`).
1257112585
*/
1257212586
private refuseInternalInFilter(object: string, where: unknown, param: string, depth: number): void {
@@ -12581,8 +12595,47 @@ export class ObjectStackProtocolImplementation implements
1258112595
this.refuseInternalNames(object, [...reads], internal, param);
1258212596
}
1258312597
for (const site of relationConditionSites(gate.fields, condition)) {
12584-
this.refuseInternalInFilter(site.target, site.condition, `${param}.${site.field}`, depth + 1);
12598+
const position = `${param}.${site.field}`;
12599+
// [#22737] The target's exposure before anything of it is judged.
12600+
this.refuseUnservedRelationTarget(site.target, position);
12601+
this.refuseInternalInFilter(site.target, site.condition, position, depth + 1);
12602+
}
12603+
}
12604+
12605+
/**
12606+
* [#22737] A NESTED-RELATION condition is evaluated on its TARGET object
12607+
* (`ObjectQL.lowerRelationConditions` reads it as the caller), so the
12608+
* target is asked the spec's one exposure decision for
12609+
* {@link RELATION_CONDITION_TARGET_OPERATION}. A target it does not serve
12610+
* is REFUSED in the data door's own exposure answer: `404
12611+
* OBJECT_API_DISABLED` for the off switch, `405
12612+
* OBJECT_API_METHOD_NOT_ALLOWED` with the effective operation set for a
12613+
* whitelist. Refused, not withheld as {@link servedExpand} withholds:
12614+
* dropping a condition would widen the result, and a related object the
12615+
* caller may not read already answers this position with a refusal. The
12616+
* decision takes no caller. The target is named, as the `internal: true`
12617+
* refusal at this hop names it: it is the lookup's declared target.
12618+
*/
12619+
private refuseUnservedRelationTarget(target: string, param: string): void {
12620+
const enable = this.engine?.registry?.getObject?.(target)?.enable;
12621+
const reason = apiExposureDenialReason(enable, RELATION_CONDITION_TARGET_OPERATION);
12622+
if (reason === null) return;
12623+
const consequence = `, so the condition on it at '${param}' was not evaluated and the query was not run. `
12624+
+ 'Filter on the lookup\'s stored id instead, or remove the condition.';
12625+
const err: any = new Error(reason === 'api-disabled'
12626+
? `Object '${target}' is not exposed via the API${consequence}`
12627+
: `API operation '${RELATION_CONDITION_TARGET_OPERATION}' is not allowed on object '${target}'${consequence}`);
12628+
if (reason === 'api-disabled') {
12629+
err.code = 'OBJECT_API_DISABLED';
12630+
err.status = 404;
12631+
} else {
12632+
err.code = 'OBJECT_API_METHOD_NOT_ALLOWED';
12633+
err.status = 405;
12634+
err.allowed = effectiveOperationsArray(resolveEffectiveApiMethods(enable));
1258512635
}
12636+
err.object = target;
12637+
err.param = param;
12638+
throw err;
1258612639
}
1258712640

1258812641
/**

0 commit comments

Comments
 (0)