Skip to content
Merged
31 changes: 31 additions & 0 deletions .changeset/22737-relation-condition-target-exposure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/metadata-protocol': minor
---

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)

Clause-②: no (narrowing)

<!-- 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). -->

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

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.

**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.

**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:

- `enable.apiEnabled: false` → `404 OBJECT_API_DISABLED`.
- 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`.

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.

**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`.

**Bindings.**

- **Every caller.** The decision takes no user, so an administrator, a member and a system context get the same answer.
- **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`).
- **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.
- **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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/spec': patch
---

fix(spec): the error-code ledger records `@objectstack/metadata-protocol` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22737)

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.
11 changes: 11 additions & 0 deletions .changeset/22737-types-exposure-arm-names-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/types': patch
---

fix(types): a thrown exposure refusal about a second object names that object on the REST body (#22737)

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`.

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.

An error naming the route's own object, or a door given no route object, answers exactly as before. No code is added.
20 changes: 11 additions & 9 deletions packages/core/src/security/second-object-read-exposure.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
* The data routes judge the ADDRESSED object. A read that follows a lookup
* reaches an object nobody addressed: the data door's `$expand` served the row
* fields of a target every data route refuses, the dataset door's label
* passes rendered its display names, and the import door matched cells against
* it (#22739). Each read classified `decided` below now asks the target
* (behaviour-pinned in its own package). This
* passes rendered its display names, the import door matched cells against
* it (#22739), and the data door's nested-relation filter condition evaluated
* a predicate on it (#22737). Each read classified `decided` below now asks the
* target (behaviour-pinned in its own package). This
* file is the ENUMERATION: it names the census of such reads, holds each
* decided one to the decision, and goes red when a new one appears without
* being classified here.
Expand Down Expand Up @@ -137,12 +138,12 @@ const CALLERS: Record<string, Classification> = {
pin: 'packages/core/src/utils/import-runner-reference-exposure.test.ts',
},
'packages/objectql/src/relation-filter-lowering.ts': {
kind: 'open',
read: "the data door's nested-relation filter condition (`ObjectQL.lowerRelationConditions` reads the related object to lower it)",
measured: 'evaluated for an unexposed target, for an administrator and a member: the source row comes back only when the related value matches',
carrier:
'a filter cannot be withheld, so its answer is a refusal in the data door\'s exposure codes, which needs a ' +
'provenance row for the door and touches a filter position; carried by a follow-up of #22661',
kind: 'decided',
read: "the data door's nested-relation filter condition (`ObjectQL.lowerRelationConditions` reads the related object to lower it), refused rather than withheld",
operation: 'list',
decision: { file: 'packages/metadata-protocol/src/protocol.ts', fn: 'refuseUnservedRelationTarget' },
wiring: { file: 'packages/metadata-protocol/src/protocol.ts', call: 'this.refuseUnservedRelationTarget(' },
pin: 'packages/metadata-protocol/src/protocol.relation-condition-target-exposure.test.ts',
},
'packages/plugins/plugin-approvals/src/approval-service.ts': {
kind: 'decided',
Expand Down Expand Up @@ -289,6 +290,7 @@ describe('[#22661] every read that reaches a second object asks that object its
(c): c is Extract<Classification, { kind: 'decided' }> => c.kind === 'decided',
);
expect(decided.map((d) => d.decision.fn).sort()).toEqual([
'refuseUnservedRelationTarget',
'servesExpansionTarget',
'servesLabelTarget',
'servesPayloadDisplayTarget',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#22737] A NESTED-RELATION filter condition is evaluated on the related
* object, so the data door asks that TARGET the spec's one exposure decision
* (`apiExposureDenialReason(enable, 'list')`, ADR-0049) and refuses a target
* it does not serve, in the door's own exposure answer: `404
* OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` with the
* effective operation set.
*
* Asserted on the envelope (`code` and `status`) and on the engine: a refused
* condition never reaches `engine.find` / `engine.aggregate`, so nothing of the
* target is read. Each refused shape is paired with a CONTROL the decision
* serves, which must reach the engine unchanged. The administrator and member
* answers on a real stack are pinned in `@objectstack/dogfood`.
*/

import { describe, expect, it, vi } from 'vitest';
import type { ServiceObject } from '@objectstack/spec/data';
import { ObjectStackProtocolImplementation } from './protocol.js';

const lookup = (name: string, reference: string) => ({ name, label: name, type: 'lookup', reference });

const object = (name: string, enable?: Record<string, unknown>, extra: Record<string, unknown> = {}): ServiceObject => ({
name,
label: name,
...(enable ? { enable } : {}),
fields: {
id: { name: 'id', label: 'ID', type: 'text' },
name: { name: 'name', label: 'Name', type: 'text' },
...extra,
},
} as unknown as ServiceObject);

const SCHEMAS: Record<string, unknown> = {
rc_source: object('rc_source', undefined, {
hidden: lookup('hidden', 'rc_hidden'),
closed: lookup('closed', 'rc_closed'),
denyall: lookup('denyall', 'rc_denyall'),
getonly: lookup('getonly', 'rc_getonly'),
listonly: lookup('listonly', 'rc_listonly'),
open: lookup('open', 'rc_open'),
}),
// `apiEnabled: false` is judged FIRST: a whitelist granting `list` beside it changes nothing.
rc_hidden: object('rc_hidden', { apiEnabled: false, apiMethods: ['list'] }),
rc_closed: object('rc_closed', { apiMethods: ['create'] }),
rc_denyall: object('rc_denyall', { apiMethods: [] }),
rc_getonly: object('rc_getonly', { apiMethods: ['get'] }),
rc_listonly: object('rc_listonly', { apiMethods: ['list'] }),
// Exposed, with a lookup into the off-switched object: an `expand` entry's own filter reaches it.
rc_open: object('rc_open', undefined, { inner: lookup('inner', 'rc_hidden') }),
};

function makeProtocol() {
const find = vi.fn(async () => [{ id: 's1', name: 'source' }]);
const aggregate = vi.fn(async () => [{ name: 'source', n: 1 }]);
const count = vi.fn(async () => 1);
const engine: any = { registry: { getObject: (n: string) => SCHEMAS[n] }, find, aggregate, count };
return { p: new ObjectStackProtocolImplementation(engine), find, aggregate };
}

/** Each refused target, the door's answer, and the effective set a 405 names. */
const REFUSED = {
hidden: { status: 404, code: 'OBJECT_API_DISABLED', allowed: undefined },
closed: { status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', allowed: ['create', 'import'] },
denyall: { status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', allowed: [] },
getonly: { status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', allowed: ['get'] },
} as const;
const SERVED = ['listonly', 'open'] as const;

/** A member and the most privileged caller: the decision takes no caller. */
const CONTEXTS = [{ userId: 'rc_member' }, { isSystem: true }];

describe('[#22737] the data door refuses a nested-relation condition whose target the API does not serve', () => {
for (const [rel, answer] of Object.entries(REFUSED)) {
for (const context of CONTEXTS) {
it(`${rel}: refused ${answer.status} ${answer.code} on the POST where and the list filter (${JSON.stringify(context)})`, async () => {
const { p, find } = makeProtocol();
for (const query of [
{ where: { [rel]: { name: 'x' } } },
{ $filter: JSON.stringify({ name: 'source', $or: [{ [rel]: { name: 'x' } }] }) },
]) {
const err: any = await p.findData({ object: 'rc_source', query, context }).catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect({ code: err.code, status: err.status }).toEqual({ code: answer.code, status: answer.status });
expect(err.object).toBe(`rc_${rel}`);
expect(err.allowed).toEqual(answer.allowed);
}
expect(find).not.toHaveBeenCalled();
});
}
}

for (const rel of SERVED) {
it(`CONTROL ${rel}: served, the condition reaches the engine unchanged`, async () => {
const { p, find } = makeProtocol();
await p.findData({ object: 'rc_source', query: { where: { [rel]: { name: 'x' } } } });
expect(find).toHaveBeenCalledTimes(1);
expect((find.mock.calls[0] as any[])[1].where).toEqual({ [rel]: { name: 'x' } });
});
}

it('the grouped read is refused before the engine aggregates', async () => {
const { p, aggregate } = makeProtocol();
const err: any = await p
.findData({ object: 'rc_source', query: { where: { hidden: { name: 'x' } }, groupBy: ['name'], aggregations: [{ function: 'count', alias: 'n' }] } })
.catch((e) => e);
expect({ code: err.code, status: err.status }).toEqual({ code: 'OBJECT_API_DISABLED', status: 404 });
expect(aggregate).not.toHaveBeenCalled();
});

it('an expand entry\'s own filter is judged against its level: a condition into an unexposed object is refused', async () => {
const { p, find } = makeProtocol();
const err: any = await p
.findData({ object: 'rc_source', query: { expand: { open: { object: 'open', where: { inner: { name: 'x' } } } } } })
.catch((e) => e);
expect({ code: err.code, status: err.status, object: err.object }).toEqual({ code: 'OBJECT_API_DISABLED', status: 404, object: 'rc_hidden' });
expect(find).not.toHaveBeenCalled();
});

it('CONTROL: a plain id comparand on the same lookup is the source\'s own column and is served', async () => {
const { p, find } = makeProtocol();
await p.findData({ object: 'rc_source', query: { where: { hidden: 'h1', getonly: { $in: ['g1'] } } } });
expect(find).toHaveBeenCalledTimes(1);
});
});
61 changes: 57 additions & 4 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ import {
SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS,
isVirtualSearchField,
type SearchFieldMeta,
canServeApiOperation,
canServeApiOperation, apiExposureDenialReason, effectiveOperationsArray, resolveEffectiveApiMethods,
classifyDottedFilterHead,
foldQueryAliasSlots,
QUERY_TRANSPORT_ALIAS_SLOTS, QUERY_TRANSPORT_DOLLAR_ALIASES, QUERY_TRANSPORT_DOLLAR_PARAMS,
Expand Down Expand Up @@ -4463,6 +4463,17 @@ const INTERNAL_FIELD_WALK_DEPTH = 8;
*/
const EXPANSION_TARGET_OPERATION = 'get';

/**
* [#22737] The operation a NESTED-RELATION condition's TARGET object is judged
* as, by the same decision: `list`. Not {@link EXPANSION_TARGET_OPERATION}'s
* `get`, because the read is not the same one: a condition is a PREDICATE over
* the target. The engine runs `find(target, { where: condition })` across the
* whole object, bounded by a match cap rather than by ids the source rows hold
* (`ObjectQL.lowerRelationConditions`), which is the read
* `GET /data/{target}?filter=…` performs, and that route is judged `list`.
*/
const RELATION_CONDITION_TARGET_OPERATION = 'list';

/**
* [#22646] The NESTED-RELATION conditions a filter holds — a key naming a
* reference field of the object (`REFERENCE_VALUE_TYPES`) whose value is a
Expand Down Expand Up @@ -12523,7 +12534,9 @@ export class ObjectStackProtocolImplementation implements
* record matched, so a related `internal` field there is the same
* confirmation oracle one hop away. Judged here, at the door, rather than
* in the engine's relation-site walk, so the engine's privileged callers
* keep the #7823 split (none of them reaches this door).
* keep the #7823 split (none of them reaches this door). The RELATED
* object's exposure is asked first, at the same hop
* ({@link refuseUnservedRelationTarget}, #22737).
* - every `expand` entry's own `where` / `orderBy`, at EVERY depth of the
* expand tree, judged against that level's TARGET object: the nested
* sub-read's row strip already withholds the column, but a nested filter
Expand Down Expand Up @@ -12566,7 +12579,8 @@ export class ObjectStackProtocolImplementation implements
/**
* One filter's internal-field judgement: every column it reads on `object`
* ({@link collectFilterReads}), then each nested-relation condition it holds,
* recursively, on the related object. `param` names the position, extended
* recursively, on the related object, once that object's exposure serves the
* condition ({@link refuseUnservedRelationTarget}). `param` names the position, extended
* by the relation field at each hop (`filter.owner_id`).
*/
private refuseInternalInFilter(object: string, where: unknown, param: string, depth: number): void {
Expand All @@ -12581,8 +12595,47 @@ export class ObjectStackProtocolImplementation implements
this.refuseInternalNames(object, [...reads], internal, param);
}
for (const site of relationConditionSites(gate.fields, condition)) {
this.refuseInternalInFilter(site.target, site.condition, `${param}.${site.field}`, depth + 1);
const position = `${param}.${site.field}`;
// [#22737] The target's exposure before anything of it is judged.
this.refuseUnservedRelationTarget(site.target, position);
this.refuseInternalInFilter(site.target, site.condition, position, depth + 1);
}
}

/**
* [#22737] A NESTED-RELATION condition is evaluated on its TARGET object
* (`ObjectQL.lowerRelationConditions` reads it as the caller), so the
* target is asked the spec's one exposure decision for
* {@link RELATION_CONDITION_TARGET_OPERATION}. A target it does not serve
* is REFUSED in the data door's own exposure answer: `404
* OBJECT_API_DISABLED` for the off switch, `405
* OBJECT_API_METHOD_NOT_ALLOWED` with the effective operation set for a
* whitelist. Refused, not withheld as {@link servedExpand} withholds:
* dropping a condition would widen the result, and a related object the
* caller may not read already answers this position with a refusal. The
* decision takes no caller. The target is named, as the `internal: true`
* refusal at this hop names it: it is the lookup's declared target.
*/
private refuseUnservedRelationTarget(target: string, param: string): void {
const enable = this.engine?.registry?.getObject?.(target)?.enable;
const reason = apiExposureDenialReason(enable, RELATION_CONDITION_TARGET_OPERATION);
if (reason === null) return;
const consequence = `, so the condition on it at '${param}' was not evaluated and the query was not run. `
+ 'Filter on the lookup\'s stored id instead, or remove the condition.';
const err: any = new Error(reason === 'api-disabled'
? `Object '${target}' is not exposed via the API${consequence}`
: `API operation '${RELATION_CONDITION_TARGET_OPERATION}' is not allowed on object '${target}'${consequence}`);
if (reason === 'api-disabled') {
err.code = 'OBJECT_API_DISABLED';
err.status = 404;
} else {
err.code = 'OBJECT_API_METHOD_NOT_ALLOWED';
err.status = 405;
err.allowed = effectiveOperationsArray(resolveEffectiveApiMethods(enable));
}
err.object = target;
err.param = param;
throw err;
}

/**
Expand Down
Loading
Loading