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
34 changes: 34 additions & 0 deletions .changeset/22661-second-object-exposure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/service-analytics': minor
---

fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the TARGET object its declared exposure — the data door's `$expand` at every level, and the dataset door's dimension-label passes (#22661)

Clause-②: no

<!-- 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 two runtime read doors: each stops serving a lookup's target object whose existing `enable` declaration already refuses the read on every data route, and the remedy is the declaration the author already wrote. The other categories are closed on facts: both packages publish (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.

The data routes judge the ADDRESSED object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`): `enable.apiEnabled: false` answers `404 OBJECT_API_DISABLED`, and an `enable.apiMethods` whitelist that does not grant the verb answers `405 OBJECT_API_METHOD_NOT_ALLOWED`. A read that FOLLOWS a lookup reaches a second object nobody addressed, and two such reads never asked it.

**FROM.** For an administrator and a member alike:

- **`$expand`** — `GET /api/v1/data/:object?$expand=`, `GET /api/v1/data/:object/:id?expand=`, the relation map on `POST /api/v1/data/:object/query` (every level of the tree), and the export door (which expands every lookup to name it) served the row fields of a lookup's target whose declaration refuses it on every data route.
- **Dataset dimension labels** — `POST /api/v1/analytics/dataset/query` rendered a reference-class dimension's grouped id as the target record's display name, and sorted an `order` on that dimension by those names, for the same targets.

**TO.** Each read asks the spec's one decision of the TARGET object, for `get` — the read both perform is "turn an id the caller holds into the record it names", the read `GET /api/v1/data/:target/:id` performs:

- An `$expand` entry whose target the decision does not serve is withheld at the generic data door (`@objectstack/metadata-protocol`), at every level: the field answers as an UNEXPANDED lookup, its stored id — the answer the door already gives for a related record the caller may not read. Nothing below a withheld entry is read.
- A dimension label is not read for such a target (`@objectstack/service-analytics`): the stored id renders, and an `order` on the dimension sorts by it — the answer a target the reader's row scope hides already gets. A declaration that cannot be read withholds the labels too (fail-closed, at `warn`); the query itself still answers.

Concretely, a target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']` or `['create']`) is withheld from both; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before.

**Bindings.**

- **Every caller.** The decision takes no user, so an administrator, a member and a system context get the same tree and the same labels.
- **The engine's privileged callers are untouched.** A flow's `config.expand` and a hook's own reads call the engine directly and never pass the generic data door, the same split the write results and the `internal: true` evaluate positions keep.
- **No new error code.** Both reads withhold rather than refuse, so no response status changes.

**Measured producers.** Read on `origin/main` `eae3368a` over every `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false`. No example declares `enable.apiEnabled` or `enable.apiMethods`. So no shipped object's served answer changes. Deployed and cloud-held object definitions were NOT MEASURED.
326 changes: 326 additions & 0 deletions packages/core/src/security/second-object-read-exposure.pin.test.ts

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/core/vitest.repo-tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"src/security/authz-store-unavailable.test.ts",
"src/security/granted-permissions-not-enforced.pin.test.ts",
"src/security/operation-private-keys.pin.test.ts",
"src/security/row-serving-door-exposure.pin.test.ts"
"src/security/row-serving-door-exposure.pin.test.ts",
"src/security/second-object-read-exposure.pin.test.ts"
]
155 changes: 155 additions & 0 deletions packages/metadata-protocol/src/protocol.expand-target-exposure.test.ts
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.

/**
* [#22661] The data door's `$expand` asks every level's TARGET object the
* spec's one exposure decision (`canServeApiOperation(enable, 'get')`,
* ADR-0049), and withholds an entry it does not serve, so the field answers as
* an unexpanded lookup — its stored id.
*
* Asserted on what the door hands the engine: a withheld entry never reaches
* `engine.find` / `engine.findOne`, so nothing of the target is read. Each
* withheld shape is paired with a CONTROL the decision serves, which must reach
* the engine unchanged — a door that dropped every expansion would pass every
* "withheld" case and serve nothing. The decision takes no caller, so a system
* context is withheld from too. The rows themselves (the stored id where the
* record would have been, for an administrator and a member alike) are pinned
* on a real stack in `@objectstack/dogfood`.
*/

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

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

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

/** Every exposure shape the decision distinguishes for `get`, one lookup each. */
const SOURCE: ServiceObject = {
name: 'xt_source',
label: 'Source',
fields: {
id: { name: 'id', label: 'ID', type: 'text' },
name: { name: 'name', label: 'Name', type: 'text' },
// `apiEnabled: false` is judged FIRST: a whitelist granting `get` beside it changes nothing.
hidden: lookup('hidden', 'xt_hidden'),
closed: lookup('closed', 'xt_closed'),
denyall: lookup('denyall', 'xt_denyall'),
listonly: lookup('listonly', 'xt_listonly'),
getonly: lookup('getonly', 'xt_getonly'),
open: lookup('open', 'xt_open'),
},
} as unknown as ServiceObject;

/** Exposed, with a lookup into the off-switched object one hop further on. */
const OPEN: ServiceObject = {
name: 'xt_open',
label: 'Open',
fields: {
id: { name: 'id', label: 'ID', type: 'text' },
name: { name: 'name', label: 'Name', type: 'text' },
inner: lookup('inner', 'xt_hidden'),
peer: lookup('peer', 'xt_getonly'),
},
} as unknown as ServiceObject;

const SCHEMAS: Record<string, unknown> = {
xt_source: SOURCE,
xt_open: OPEN,
xt_hidden: target('xt_hidden', { apiEnabled: false, apiMethods: ['get'] }),
xt_closed: target('xt_closed', { apiMethods: ['create'] }),
xt_denyall: target('xt_denyall', { apiMethods: [] }),
xt_listonly: target('xt_listonly', { apiMethods: ['list'] }),
xt_getonly: target('xt_getonly', { apiMethods: ['get'] }),
};

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

/** The `expand` the door handed the engine's list read. */
async function expandHandedToFind(query: Record<string, unknown>, context?: unknown): Promise<unknown> {
const { p, find } = makeProtocol();
await p.findData({ object: 'xt_source', query, ...(context ? { context } : {}) });
expect(find).toHaveBeenCalledTimes(1);
return (find.mock.calls[0]![1] as { expand?: unknown }).expand;
}

const WITHHELD = ['hidden', 'closed', 'denyall', 'listonly'] as const;
const SERVED = ['getonly', 'open'] as const;

describe('[#22661] the data door withholds an $expand entry whose target the API does not serve', () => {
for (const rel of WITHHELD) {
it(`${rel}: withheld on the comma list, the engine reads nothing of the target`, async () => {
expect(await expandHandedToFind({ $expand: rel })).toBeUndefined();
});
it(`${rel}: withheld from the POST relation map, beside a served entry that is kept`, async () => {
const expand = await expandHandedToFind({ expand: { [rel]: { object: rel, fields: ['name'] }, open: { object: 'open' } } });
expect(expand).toEqual({ open: { object: 'open' } });
});
}

for (const rel of SERVED) {
it(`CONTROL ${rel}: served, handed to the engine unchanged`, async () => {
const map = { [rel]: { object: rel, fields: ['name'] } };
expect(await expandHandedToFind({ expand: map })).toEqual(map);
expect(await expandHandedToFind({ $expand: rel })).toEqual({ [rel]: { object: rel } });
});
}

it('every level is judged against its own target: a second-level entry into an unexposed object is withheld', async () => {
const expand = await expandHandedToFind({
expand: {
open: {
object: 'open',
fields: ['name'],
expand: { inner: { object: 'inner' }, peer: { object: 'peer' } },
},
},
});
expect(expand).toEqual({ open: { object: 'open', fields: ['name'], expand: { peer: { object: 'peer' } } } });
});

it('a level left with nothing to expand carries no expand of its own', async () => {
const expand = await expandHandedToFind({ expand: { open: { object: 'open', expand: { inner: { object: 'inner' } } } } });
expect(expand).toEqual({ open: { object: 'open' } });
});

it('the caller\'s relation map is never mutated', async () => {
const map = { hidden: { object: 'hidden' }, open: { object: 'open', expand: { inner: { object: 'inner' } } } };
const before = JSON.stringify(map);
await expandHandedToFind({ expand: map });
expect(JSON.stringify(map)).toBe(before);
});

it('the decision takes no caller: a system context is withheld from too', async () => {
expect(await expandHandedToFind({ $expand: 'hidden,open' }, { isSystem: true })).toEqual({ open: { object: 'open' } });
});

it('the single-record read withholds the same entries, and keeps the served ones', async () => {
const { p, findOne } = makeProtocol();
await p.getData({ object: 'xt_source', id: 's1', expand: 'hidden,listonly,getonly,open' });
expect((findOne.mock.calls[0]![1] as { expand?: unknown }).expand).toEqual({
getonly: { object: 'getonly' },
open: { object: 'open' },
});
await p.getData({ object: 'xt_source', id: 's1', expand: ['closed', 'denyall'] });
expect((findOne.mock.calls[1]![1] as { expand?: unknown }).expand).toBeUndefined();
});
});
98 changes: 95 additions & 3 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4446,6 +4446,22 @@ function headSegment(name: string): string {
*/
const INTERNAL_FIELD_WALK_DEPTH = 8;

/**
* [#22661] The operation an `$expand` entry's TARGET object is judged as, by
* the spec's one exposure decision (`canServeApiOperation`, ADR-0049): `get`.
*
* An expansion replaces an id the caller already holds with the record it
* names, which is the read `GET /data/{target}/{id}` performs, and that route
* is judged `get`. It never enumerates the target: the engine's sub-read is
* bounded to `id $in` the source rows' own values, so a nested `where` can only
* narrow records the caller could fetch by id. `list` would refuse an object
* that serves exactly those records by id; it is not the read this is. The
* dataset door's dimension-label pass asks the same operation of the same
* target (`@objectstack/service-analytics`), so a lookup renders its name, or
* its stored id, alike on both doors.
*/
const EXPANSION_TARGET_OPERATION = 'get';

/**
* [#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 @@ -12513,7 +12529,8 @@ export class ObjectStackProtocolImplementation implements
* is a presence oracle on the related record (the expanded object appears
* only when the guess matches), and a deeper level's sub-read is one more
* `engine.find` that answers the same way. Target OBJECT exposure is a
* different axis, filed separately (#22661).
* different axis, judged before this walk ({@link servedExpand}, #22661):
* an entry whose target the API does not serve never reaches it.
*
* Not judged here because the ingress already refuses them, measured: a
* DOTTED sort or group-by path crossing a relation (the sort gate's
Expand Down Expand Up @@ -12594,6 +12611,70 @@ export class ObjectStackProtocolImplementation implements
}
}

/**
* [#22661] The `$expand` tree this door hands the engine, without the
* entries whose TARGET object's declared exposure does not serve the read.
*
* An expansion reads a SECOND object: the record a lookup points at, served
* under the source row. The data routes judge the ADDRESSED object's
* `enable` block (`apiEnabled: false` answers 404, an `apiMethods` whitelist
* that does not grant the verb answers 405); the expansion's target was
* never asked, so `GET /data/{source}?$expand={lookup}` served the row
* fields of an object `GET /data/{target}` refuses. Every level is asked the
* spec's one decision, `canServeApiOperation(enable, 'get')`
* ({@link EXPANSION_TARGET_OPERATION} says why `get`), against that level's
* own target, and the decision takes no caller, so an administrator and a
* member get the same tree.
*
* Withheld, not refused, and that is the precedent measured on this door: a
* related record the caller may not READ is answered by the engine's
* expansion as the bare foreign key (its sub-read is refused and the id is
* retained, `ObjectQL.expandRelatedRecords`), so the field answers exactly
* as an unexpanded lookup does. An unexposed target answers the same way:
* its entry is dropped here, nothing below it is read, and the field keeps
* its stored id. Nothing about the target is named in the response.
*
* Here, at the door, and not in the engine: exposure is a property of the
* API, and the engine's privileged callers (a flow's `config.expand`, a
* hook's own read) never pass this door, the same split #7823 drew for the
* write results and #22646 for the evaluate positions.
*
* Copy-on-write: the caller's map is never mutated, and an unchanged tree
* comes back by reference. A key that names no reference field of its level
* is left alone — the top level is the expand gate's to refuse, and a
* deeper one is a key the engine reads nothing for. Below the walk's depth
* backstop an entry is dropped rather than passed unjudged.
*/
private servedExpand(source: string, expand: Record<string, any>, depth: number): Record<string, any> {
const gate = this.resolveQueryFields(source);
let served: Record<string, any> | undefined;
for (const [rel, entry] of Object.entries(expand)) {
const def = gate?.fields?.[rel];
const target = def != null && REFERENCE_VALUE_TYPES.has(def.type) ? referenceTargetOf(def) : undefined;
if (!target) continue;
if (depth >= INTERNAL_FIELD_WALK_DEPTH || !this.servesExpansionTarget(target)) {
served ??= { ...expand };
delete served[rel];
continue;
}
if (isPlainRecord(entry) && isPlainRecord(entry.expand)) {
const nested = this.servedExpand(target, entry.expand, depth + 1);
if (nested !== entry.expand) {
served ??= { ...expand };
const { expand: _withheld, ...rest } = entry;
served[rel] = Object.keys(nested).length > 0 ? { ...rest, expand: nested } : rest;
}
}
}
return served ?? expand;
}

/** [#22661] Does the target's declared exposure serve an expansion read of it? */
private servesExpansionTarget(target: string): boolean {
const schema: any = this.engine?.registry?.getObject?.(target);
return canServeApiOperation(schema?.enable, EXPANSION_TARGET_OPERATION);
}

/** Field heads an `orderBy` names (string or `{ field }`), dotted head only. */
private orderByFieldNames(orderBy: unknown): string[] {
if (!Array.isArray(orderBy)) return [];
Expand Down Expand Up @@ -12891,6 +12972,13 @@ export class ObjectStackProtocolImplementation implements
// rather than `expandNames` is what covers the second one.
if (options.expand && typeof options.expand === 'object') {
this.assertExpandTargetsExist(request.object, Object.keys(options.expand));
// [#22661] …then every level's TARGET is asked its declared exposure,
// and an entry the decision does not serve is withheld (the field
// answers as an unexpanded lookup) — see {@link servedExpand}. Ahead
// of the `internal: true` walk below, so nothing about a withheld
// target is judged, named or read.
options.expand = this.servedExpand(request.object, options.expand, 0);
if (Object.keys(options.expand).length === 0) delete options.expand;
}

// [#4254] The `searchFields` override is validated on the value the
Expand Down Expand Up @@ -13310,10 +13398,14 @@ export class ObjectStackProtocolImplementation implements
? request.expand.split(',').map((s: string) => s.trim()).filter(Boolean)
: request.expand;
this.assertExpandTargetsExist(request.object, expandNames);
queryOptions.expand = {} as Record<string, any>;
const expand = {} as Record<string, any>;
for (const rel of expandNames) {
queryOptions.expand[rel] = { object: rel };
expand[rel] = { object: rel };
}
// [#22661] The same exposure judgement as the list path: an entry
// whose target does not serve `get` is withheld ({@link servedExpand}).
const served = this.servedExpand(request.object, expand, 0);
if (Object.keys(served).length > 0) queryOptions.expand = served;
}

const result = await this.engine.findOne(request.object, queryOptions);
Expand Down
Loading
Loading