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
2 changes: 1 addition & 1 deletion .changeset/22739-import-reference-target-exposure.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/core': minor
---
Expand All @@ -24,7 +24,7 @@

**Bindings.**

- **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it (plugin-auth's identity import, whose reads run under the system identity) is not judged.
- **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it is not judged (plugin-auth's identity import carries the reader since #22800).
- **No new error code, no status change.** The refusal is the per-row `reference_not_found` the resolver already answers.
- **Which fields count as references does not widen.** A `user` field written without `reference` still has no target on these doors (its cell reaches the write as written), although the arbiter gives it `sys_user`; adopting that target would widen what the import accepts and is not this change.

Expand Down
38 changes: 38 additions & 0 deletions .changeset/22823-calldata-fallback-relation-exposure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@objectstack/runtime': minor
---

fix(runtime)!: `callData` asks each nested-relation condition's related object its declared exposure before the engine reads it, on the query fallback and the aggregate arm (#22823)

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 at two arms of one door: the runtime dispatcher's `callData` stops handing the engine a filter condition on a lookup's target object whose existing `enable` declaration already refuses a query of it on every data door, 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): `callData` is not on this package's export surface and its signature is unchanged. -->

**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.** The runtime dispatcher's data bridge, `callData`, judged only the object a call addresses against its `enable` block, and then handed the caller's `where` to the ObjectQL engine at two positions:

- the `query` / `find` fallback, which serves when no protocol service resolves (an assembly that composes the engine plugin with `registerProtocol: false` and no `MetadataProtocolPlugin`);
- the `aggregate` arm, which never takes the protocol service, so on every assembly (the MCP `aggregate_records` tool over HTTP reaches it).

At both, such a condition was evaluated whatever the related object's `enable` block declared, including a related object every data door refuses when it is addressed.

**TO.** `callData` asks each condition's related object the exposure gate it applies to an addressed object, for `list` (the operation a query of that object is judged as), before the engine is called. A related object the gate does not serve refuses the call with the data door's own exposure answer, the one the protocol path gives the same condition:

- `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 (`object`) and the condition's position (`param`, `where.<lookup>`), with the remedy. It applies at the top level and under `$and` / `$or` / `$not`. A related object with no `enable` block, or a whitelist that grants `list`, is served exactly as before. With the protocol service present the `query` arm is unchanged: the data door judges the condition, as it has since #22737.

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

- **Refused, not withheld.** Dropping a condition would widen the result.
- **One gate, one walk.** The gate is the one `callData` already applies to an addressed object, and the conditions are found by `relationConditionSites` from `@objectstack/core`, the walk the data door and the MCP stdio bridge use.
- **A system context is not asked**, as it is not asked for the addressed object: these flags govern API exposure, not the engine's own reads. A flow's or a hook's own query calls the engine directly and never passes `callData`.
- **No new error code.** `@objectstack/runtime` now stamps the two existing exposure codes, and the error-code ledger lists them under that package too.

**Measured producers.** Read on `origin/main` `75b6d6a048`: no shipped composition reaches the `query` fallback. Every non-test `ObjectQLPlugin` construction under `packages/` and `examples/` keeps the built-in protocol (`os serve`, the standalone stack, the dev plugin, the verify harness, the kernel factory), and `registerProtocol: false` appears only in tests. The `aggregate` arm is reached on every assembly that serves the MCP route over HTTP. Deployed compositions were NOT MEASURED.
7 changes: 7 additions & 0 deletions .changeset/22823-spec-ledger-runtime-exposure-codes.md
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/runtime` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22823)

The runtime dispatcher's `callData` now answers the two exposure codes for a nested-relation filter condition whose related object's `enable` block refuses a query of it, on its `query` fallback and its `aggregate` arm, so `ERROR_CODE_LEDGER['@objectstack/runtime']` 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. No export, schema or generated artifact moves.
15 changes: 11 additions & 4 deletions packages/core/src/security/second-object-read-exposure.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,14 @@
* targets are resolved inside `relationConditionSites` (`@objectstack/core`),
* which is the caller classified below. The bridge asks each target the
* decision for `list` (`enforceRelationTargetExposure`, #22777), held by its
* own package's pin rather than by a row here. Nor, for the same reason, a
* own package's pin rather than by a row here. The runtime's `callData` is a
* third such door: its `query` fallback (no protocol service resolved) and its
* `aggregate` arm hand a caller's `where` to the same engine lowering, and
* `refuseUnservedRelationTargets` (`runtime/src/action-execution.ts`) asks each
* target the gate the addressed object is asked, for `list`, through the same
* walk (#22823, census row 10), held by its own package's pin
* (`runtime/src/action-execution-calldata-relation-target-exposure.test.ts`)
* rather than by a row here. Nor, for the same reason, a
* door that drives `runImport` with a protocol of its own: plugin-auth's
* identity import takes its cells' targets from the `import-field-meta.ts` map
* classified below, and its run protocol carries the declaration reader the
Expand Down Expand Up @@ -178,9 +185,9 @@ const CALLERS: Record<string, Classification> = {
'packages/core/src/utils/relation-condition-sites.ts': {
kind: 'not-a-served-read',
reason:
"finds a filter's nested-relation conditions and each one's target; reads no record. Both doors that call it ask " +
"each target the decision before the engine reads it: the data door's `refuseUnservedRelationTarget` and the MCP " +
"stdio data bridge's `enforceRelationTargetExposure`",
"finds a filter's nested-relation conditions and each one's target; reads no record. Every door that calls it asks " +
"each target the decision before the engine reads it: the data door's `refuseUnservedRelationTarget`, the MCP " +
"stdio data bridge's `enforceRelationTargetExposure` and the runtime `callData`'s `refuseUnservedRelationTargets`",
},
'packages/lint/src/object-graph.ts': {
kind: 'not-a-served-read',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#22823] Census row 10 of #22661 — `callData` hands a caller's `where` to the
* ObjectQL ENGINE on two arms: the `query` fallback (no protocol service
* resolved) and the `aggregate` arm (which never takes the protocol). The engine
* lowers a NESTED-RELATION condition by reading the related object as the
* caller, so each condition's TARGET is asked the exposure gate the addressed
* object is asked, for `list`, before the engine is called, and a target the
* gate does not serve refuses the call with the data door's own 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 `find` / `aggregate`. Each refused shape is paired
* with a CONTROL the gate serves, which reaches the engine unchanged, and the
* protocol path is pinned untouched — with the protocol service present the
* query is the data door's to judge, and this door hands it over verbatim.
*/

import { describe, expect, it, vi } from 'vitest';
import { callData, type ActionExecutionDeps } from './action-execution.js';
import type { HttpProtocolContext } from './http-dispatcher.js';

const REQ = { request: {} } as HttpProtocolContext;
const MEMBER = { userId: 'rc_member', isSystem: false, positions: [], permissions: [] } as any;

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

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

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'] }),
rc_open: object('rc_open'),
};

/** 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;

/**
* The engine `callData` falls back to, with its registry (the schema the
* engine's relation lowering reads) and spies on the two verbs a caller's
* `where` reaches. `withProtocol` adds the data door, which then serves the
* query arm.
*/
function makeHarness(opts: { withProtocol?: boolean } = {}) {
const find = vi.fn(async () => [{ id: 's1', name: 'source' }]);
const aggregate = vi.fn(async () => [{ n: 1 }]);
const findData = vi.fn(async (req: any) => ({ object: req.object, records: [], total: 0, hasMore: false }));
const engine: any = { registry: { getObject: (n: string) => SCHEMAS[n] }, find, aggregate };
const services: Record<string, any> = {
metadata: { getObject: async (n: string) => SCHEMAS[n] },
objectql: engine,
...(opts.withProtocol ? { protocol: { findData } } : {}),
};
const deps: ActionExecutionDeps = {
resolveService: (async (_ctx: HttpProtocolContext, name: string) => services[name]) as any,
getObjectQL: async () => engine,
};
return { deps, find, aggregate, findData };
}

const refusal = (e: any) => ({ code: e?.code, status: e?.status, object: e?.object, param: e?.param, allowed: e?.allowed });

describe('[#22823] callData asks each nested-relation condition\'s target its exposure before the engine reads it', () => {
for (const [rel, answer] of Object.entries(REFUSED)) {
it(`query fallback: ${rel} is refused ${answer.status} ${answer.code}, top level and under a combinator`, async () => {
const h = makeHarness();
for (const where of [{ [rel]: { name: 'x' } }, { name: 'source', $or: [{ [rel]: { name: 'x' } }] }]) {
const err: any = await callData(h.deps, REQ, 'query', { object: 'rc_source', query: { where } }, undefined, undefined, MEMBER)
.catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect(refusal(err)).toEqual({ code: answer.code, status: answer.status, object: `rc_${rel}`, param: `where.${rel}`, allowed: answer.allowed });
}
expect(h.find).not.toHaveBeenCalled();
});

it(`aggregate arm: ${rel} is refused ${answer.status} ${answer.code} before the engine aggregates`, async () => {
const h = makeHarness();
const err: any = await callData(
h.deps, REQ, 'aggregate',
{ object: 'rc_source', where: { [rel]: { name: 'x' } }, aggregations: [{ function: 'count', alias: 'n' }] },
undefined, undefined, MEMBER,
).catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect(refusal(err)).toEqual({ code: answer.code, status: answer.status, object: `rc_${rel}`, param: `where.${rel}`, allowed: answer.allowed });
expect(h.aggregate).not.toHaveBeenCalled();
});
}

for (const rel of SERVED) {
it(`CONTROL ${rel}: served, the condition reaches the engine unchanged on both arms`, async () => {
const h = makeHarness();
await callData(h.deps, REQ, 'query', { object: 'rc_source', query: { where: { [rel]: { name: 'x' } } } }, undefined, undefined, MEMBER);
expect(h.find).toHaveBeenCalledTimes(1);
expect((h.find.mock.calls[0] as any[])[1].where).toEqual({ [rel]: { name: 'x' } });
await callData(
h.deps, REQ, 'aggregate',
{ object: 'rc_source', where: { [rel]: { name: 'x' } }, aggregations: [{ function: 'count', alias: 'n' }] },
undefined, undefined, MEMBER,
);
expect(h.aggregate).toHaveBeenCalledTimes(1);
expect((h.aggregate.mock.calls[0] as any[])[1].where).toEqual({ [rel]: { name: 'x' } });
});
}

it('CONTROL: a plain id comparand on the same lookups is the source\'s own column and is served', async () => {
const h = makeHarness();
await callData(h.deps, REQ, 'query', { object: 'rc_source', query: { where: { hidden: 'h1', getonly: { $in: ['g1'] } } } }, undefined, undefined, MEMBER);
expect(h.find).toHaveBeenCalledTimes(1);
});

it('CONTROL: a system context is not asked, as the addressed object\'s gate is not', async () => {
const h = makeHarness();
await callData(h.deps, REQ, 'query', { object: 'rc_source', query: { where: { hidden: { name: 'x' } } } }, undefined, undefined, { isSystem: true } as any);
expect(h.find).toHaveBeenCalledTimes(1);
});

it('the protocol path is untouched: the data door gets the query verbatim and the engine is never called here', async () => {
const h = makeHarness({ withProtocol: true });
const query = { where: { hidden: { name: 'x' } } };
await callData(h.deps, REQ, 'query', { object: 'rc_source', query }, undefined, undefined, MEMBER);
expect(h.findData).toHaveBeenCalledTimes(1);
expect((h.findData.mock.calls[0] as any[])[0].query).toEqual(query);
expect(h.find).not.toHaveBeenCalled();
});
});
Loading
Loading