From 424a01e468361e275d83b4654a6eee4686a65445 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:42:27 +0000 Subject: [PATCH 1/5] fix(metadata-protocol,service-analytics): a second-object read asks its target's exposure (WIP, #22661) The data door's $expand withholds an entry whose target object the spec's exposure decision does not serve for `get`, at every level; the dataset door's two label passes read only a target it serves for `aggregate`. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 94 ++++++++++++++- .../second-object-exposure-fixture.ts | 111 ++++++++++++++++++ .../src/analytics-service.ts | 13 +- .../src/api-exposure-door.ts | 42 +++++++ .../service-analytics/src/dimension-labels.ts | 34 ++++++ 5 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 packages/qa/dogfood/test/fixtures/second-object-exposure-fixture.ts diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 55bbeaa1c7a..5669d4600d9 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4446,6 +4446,19 @@ 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. + */ +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 @@ -12513,7 +12526,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 @@ -12594,6 +12608,69 @@ 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, depth: number): Record { + const gate = this.resolveQueryFields(source); + let served: Record | 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 }; + served[rel] = { ...entry, expand: nested }; + } + } + } + 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 []; @@ -12891,6 +12968,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 @@ -13310,10 +13394,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; + const expand = {} as Record; 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); diff --git a/packages/qa/dogfood/test/fixtures/second-object-exposure-fixture.ts b/packages/qa/dogfood/test/fixtures/second-object-exposure-fixture.ts new file mode 100644 index 00000000000..0a11aa71100 --- /dev/null +++ b/packages/qa/dogfood/test/fixtures/second-object-exposure-fixture.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22661] Fixture for "a read that reaches a SECOND object asks that object's +// exposure declaration": one exposed source object whose lookups point at a +// target of every exposure shape the spec's decision (`apiExposureDenialReason`, +// ADR-0049) distinguishes, plus a target the member may not read at all — the +// precedent leg (how the data door already answers a related record it will +// not serve). +// +// • `sox_source` — exposed (no `enable` block); one lookup per target. +// • `sox_hidden` — `apiEnabled: false`: the API's off switch. +// • `sox_closed` — `apiMethods: ['create']`: exposed, grants neither read. +// • `sox_getonly` — `apiMethods: ['get']`: a record can be fetched by id. +// • `sox_listonly` — `apiMethods: ['list']`: records can be queried. +// • `sox_open` — exposed; carries a lookup into `sox_hidden` itself, so a +// second-level expansion reaches an unexposed object one hop further on. +// • `sox_private` — exposed, but the fixture member holds no read on it. +// +// Every object is `public_read_write` so owner sharing is not what decides any +// answer here; the member's grant is CRUD on every object except `sox_private`. + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; + +const target = (name: string, label: string, enable?: Record) => + ObjectSchema.create({ + name, + sharingModel: 'public_read_write', + label, + pluralLabel: `${label}s`, + ...(enable ? { enable } : {}), + fields: { + name: Field.text({ label: 'Name', required: true }), + note: Field.text({ label: 'Note' }), + }, + }); + +export const Hidden = target('sox_hidden', 'Hidden', { apiEnabled: false }); +export const Closed = target('sox_closed', 'Closed', { apiMethods: ['create'] }); +export const GetOnly = target('sox_getonly', 'Get only', { apiMethods: ['get'] }); +export const ListOnly = target('sox_listonly', 'List only', { apiMethods: ['list'] }); +export const Private = target('sox_private', 'Private'); + +export const Open = ObjectSchema.create({ + name: 'sox_open', + sharingModel: 'public_read_write', + label: 'Open', + pluralLabel: 'Opens', + fields: { + name: Field.text({ label: 'Name', required: true }), + note: Field.text({ label: 'Note' }), + inner: Field.lookup('sox_hidden', { label: 'Inner' }), + }, +}); + +export const Source = ObjectSchema.create({ + name: 'sox_source', + sharingModel: 'public_read_write', + label: 'Source', + pluralLabel: 'Sources', + fields: { + name: Field.text({ label: 'Name', required: true }), + amount: Field.number({ label: 'Amount' }), + hidden: Field.lookup('sox_hidden', { label: 'Hidden' }), + closed: Field.lookup('sox_closed', { label: 'Closed' }), + getonly: Field.lookup('sox_getonly', { label: 'Get only' }), + listonly: Field.lookup('sox_listonly', { label: 'List only' }), + open: Field.lookup('sox_open', { label: 'Open' }), + private: Field.lookup('sox_private', { label: 'Private' }), + }, +}); + +export const secondObjectExposureStack = defineStack({ + manifest: { + id: 'com.dogfood.second-object-exposure', + namespace: 'sox', + version: '0.0.0', + type: 'app', + name: 'Second Object Exposure Fixture', + description: 'An exposed object with lookups into objects of every exposure shape: a second-object read must ask the target its own exposure.', + }, + objects: [Hidden, Closed, GetOnly, ListOnly, Private, Open, Source], +}); + +const crud = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; + +/** + * CRUD on every fixture object except `sox_private`, which the member cannot + * read, plus export on the source (the export door auto-expands every lookup). + */ +export const soxMemberSet: PermissionSet = PermissionSetSchema.parse({ + name: 'sox_fixture_member', + label: 'Second-object exposure fixture member', + objects: { + sox_source: { ...crud, allowExport: true }, + sox_hidden: crud, + sox_closed: crud, + sox_getonly: crud, + sox_listonly: crud, + sox_open: crud, + }, +}); + +export function secondObjectExposureSecurity(): SecurityPlugin { + return new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, soxMemberSet], + fallbackPermissionSet: soxMemberSet.name, + }); +} diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 07cb9454e93..3702287fce4 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -67,6 +67,7 @@ import { assertDefinitionExposed, assertNoInternalFieldNamed, assertObjectsExposed, + servesLabelTarget, type ObjectDeclarationProvider, type RegisteredDefinitionKind, } from './api-exposure-door.js'; @@ -106,6 +107,7 @@ import { resolveDimensionLabels, createOrderLabelResolver, withLabelFetchCache, + withServedLabelTargets, type DimensionLabelDeps, } from './dimension-labels.js'; import { evaluateAnalyticsQueryOverRows } from './preview-evaluator.js'; @@ -1469,11 +1471,20 @@ export class AnalyticsService implements IAnalyticsService { this.configuredAllowedRelationships = config.getAllowedRelationships; this.relationshipResolver = config.relationshipResolver; this.sourceFieldMeta = config.sourceFieldMeta; - this.labelResolver = config.labelResolver; this.draftRowsResolver = config.draftRowsResolver; this.isRegisteredObject = config.isRegisteredObject; this.getObjectFieldNames = config.getObjectFieldNames; this.objectDeclarationProvider = config.getObjectDeclaration; + // [#22661] A label pass reads a SECOND object — the target a reference-class + // dimension points at — so the configured resolver is taken through the + // generic-exit gate's label face: a target the spec's exposure decision does + // not serve is never read, and the stored id renders. Wrapped once, here, + // so every pass that reads labels inherits it. No declaration probe wired: + // no gate, as on the query face (which reports that once). + const declarations = this.objectDeclarationProvider; + this.labelResolver = config.labelResolver && declarations + ? withServedLabelTargets(config.labelResolver, (target) => servesLabelTarget(target, declarations, this.logger)) + : config.labelResolver; this.getObjectDatasource = config.getObjectDatasource; this.isExternalObject = config.isExternalObject; // [#8286] Resolved ONCE, at construction, from the host's explicit choice diff --git a/packages/services/service-analytics/src/api-exposure-door.ts b/packages/services/service-analytics/src/api-exposure-door.ts index 28d0e053a7c..7c9124075ee 100644 --- a/packages/services/service-analytics/src/api-exposure-door.ts +++ b/packages/services/service-analytics/src/api-exposure-door.ts @@ -311,6 +311,48 @@ export function assertDefinitionExposed( } } +/** + * [#22661] The LABEL face: may the dimension-label passes read `target` — the + * object a reference-class dimension (`lookup` / `master_detail` / `user` / + * `tree`) points at — to turn a grouped id into the target record's display + * name? + * + * Both label passes (the display pass, `resolveDimensionLabels`, and the + * sort-key pass, `createOrderLabelResolver`) read a SECOND object the query + * faces above never ask about: the dataset's base object and its hops are + * judged, the label target was not, so a dataset over an exposed object + * rendered the display names of an object every data route refuses. Asked the + * same decision, for the same `aggregate` operation: a label is the read a + * relationship-hop dimension over the target's display field performs + * (`.`), which this door already judges as `aggregate` over + * `queryObjects`; judging the label as anything else would serve through one + * spelling what the other is refused. It takes no caller. + * + * Withheld, not refused, and that is the precedent measured on this door: a + * label the target's row scope hides, or whose scope cannot be resolved, is + * not fetched and the stored id renders. An unexposed target answers the same + * way, and a provider that THROWS answers it too — no label is read on a + * declaration nobody could read (fail-closed, reported at `warn`: a display + * degradation, not a refused query). + */ +export function servesLabelTarget( + target: string, + provider: ObjectDeclarationProvider, + logger?: ExposureLogger, +): boolean { + let enable: EnableLike | null | undefined; + try { + enable = (provider(target) ?? undefined)?.enable as EnableLike | null | undefined; + } catch (e) { + logger?.warn( + `[Analytics] the API exposure declaration of "${target}" could not be resolved — its display ` + + `labels are not read and the stored ids render (fail-closed): ${e instanceof Error ? e.message : String(e)}`, + ); + return false; + } + return apiExposureDenialReason(enable, ANALYTICS_OPERATION) === null; +} + /** * Refuse the query when any member it names reads a field its object declares * `internal: true` — in every position, every role. `named` is what the door's diff --git a/packages/services/service-analytics/src/dimension-labels.ts b/packages/services/service-analytics/src/dimension-labels.ts index 228491a908f..40b1d74b1c1 100644 --- a/packages/services/service-analytics/src/dimension-labels.ts +++ b/packages/services/service-analytics/src/dimension-labels.ts @@ -300,6 +300,40 @@ export function withLabelFetchCache(deps: DimensionLabelDeps): DimensionLabelDep }; } +/** + * [#22661] Wrap a {@link DimensionLabelDeps} so `fetchRecordLabels` reads only + * a TARGET object the dataset door serves — `serves` is the door's label face + * (`servesLabelTarget`, `api-exposure-door.ts`), the spec's one exposure + * decision. A target it does not serve answers an EMPTY map without being + * read, which both label passes already take as "no label": the display pass + * leaves the stored id in the row, and the sort-key pass sorts by it — the + * same answer a target the reader's row scope hides gets (#3602). + * + * Applied once, where the service takes the configured resolver, so every + * pass that reads labels — today the display pass and the sort-key pass — + * goes through it; nothing here caches, so the per-request + * {@link withLabelFetchCache} wraps this, not the other way round. + */ +export function withServedLabelTargets( + deps: DimensionLabelDeps, + serves: (targetObject: string) => boolean, +): DimensionLabelDeps { + return { + getObjectFields: (objectName) => deps.getObjectFields(objectName), + async fetchRecordLabels(targetObject, ids, scope, context) { + if (!serves(targetObject)) return new Map(); + return deps.fetchRecordLabels(targetObject, ids, scope, context); + }, + // A select option's label is the BASE object's own metadata, not a read of + // a second object, so it passes straight through (and, as in + // `withLabelFetchCache`, is never dropped by the wrapper). + translateSelectOptions: deps.translateSelectOptions + ? (objectName, fieldName, options, locale) => + deps.translateSelectOptions!(objectName, fieldName, options, locale) + : undefined, + }; +} + /** Date-dimension granularity (mirrors the dataset `dateGranularity` enum). */ export type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year'; From bbe04d99da11ab4befdf0161eec0e94ab243a329 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:49:55 +0000 Subject: [PATCH 2/5] test(metadata-protocol,service-analytics,dogfood): pin the second-object exposure decision on $expand and the label passes (#22661) The label face asks `get` (a by-id read), the operation the data door's expansion asks of the same target. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- .../protocol.expand-target-exposure.test.ts | 155 +++++++++++++ packages/metadata-protocol/src/protocol.ts | 8 +- .../second-object-exposure.dogfood.test.ts | 207 ++++++++++++++++++ .../dimension-label-exposure.test.ts | 144 ++++++++++++ .../src/api-exposure-door.ts | 26 ++- 5 files changed, 532 insertions(+), 8 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.expand-target-exposure.test.ts create mode 100644 packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/dimension-label-exposure.test.ts diff --git a/packages/metadata-protocol/src/protocol.expand-target-exposure.test.ts b/packages/metadata-protocol/src/protocol.expand-target-exposure.test.ts new file mode 100644 index 00000000000..52eb36e2696 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.expand-target-exposure.test.ts @@ -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): 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 = { + 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, context?: unknown): Promise { + 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(); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5669d4600d9..a0e4a6bbaa4 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4455,7 +4455,10 @@ const INTERNAL_FIELD_WALK_DEPTH = 8; * 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. + * 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'; @@ -12658,7 +12661,8 @@ export class ObjectStackProtocolImplementation implements const nested = this.servedExpand(target, entry.expand, depth + 1); if (nested !== entry.expand) { served ??= { ...expand }; - served[rel] = { ...entry, expand: nested }; + const { expand: _withheld, ...rest } = entry; + served[rel] = Object.keys(nested).length > 0 ? { ...rest, expand: nested } : rest; } } } diff --git a/packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts b/packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts new file mode 100644 index 00000000000..038c0d723a1 --- /dev/null +++ b/packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22661] A read that reaches a SECOND object — the record a lookup points at +// — asks that object's own declared exposure (`enable.apiEnabled` / +// `enable.apiMethods`, ADR-0049), through the spec's one decision, on a real +// boot: the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST, auth and +// analytics layers. +// +// ## The reads, and what each must answer +// +// - The data door's `$expand` — the list route, the single-record route, the +// query route's relation map, a second-level entry, and the export door +// (which expands every lookup to name it). An entry whose target the +// decision does not serve for `get` answers as an UNEXPANDED lookup: the +// stored id, the answer the door already gives for a related record the +// caller may not read (the precedent leg below). +// - The dataset door's two dimension-label passes. A target the decision does +// not serve for `get` is not read: the stored id renders, and an `order` on +// the dimension sorts by it. +// +// ## Armed before anything is believed +// +// The decision's own answer for each target is read off the data door first: +// `GET /data/{target}/{id}` answers 404 for the off switch, 405 for a +// whitelist that does not grant `get`, and 200 for the targets that serve it — +// and the expansion and label of each target must agree with that answer, for +// an administrator and a member alike (the decision takes no caller). The +// served targets are the controls: a door that withheld everything would fail +// them. +// +// Fixtures are synthetic. ⚠️ No test title states a value. +// `@objectstack/metadata-protocol` and `@objectstack/service-analytics` +// resolve through their BUILT output here, so a verdict on a change to either +// is a verdict on its last build. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { secondObjectExposureStack, secondObjectExposureSecurity } from './fixtures/second-object-exposure-fixture.js'; + +const MEMBER_EMAIL = 'sox-member@verify.test'; +const SYS = { context: { isSystem: true } } as const; + +/** Each source lookup, the object it points at, and the data door's `get` answer for that object. */ +const TARGETS = { + hidden: { object: 'sox_hidden', getStatus: 404 }, + closed: { object: 'sox_closed', getStatus: 405 }, + listonly: { object: 'sox_listonly', getStatus: 405 }, + getonly: { object: 'sox_getonly', getStatus: 200 }, + open: { object: 'sox_open', getStatus: 200 }, +} as const; +type Rel = keyof typeof TARGETS; +const WITHHELD: Rel[] = ['hidden', 'closed', 'listonly']; +const SERVED: Rel[] = ['getonly', 'open']; + +/** Synthetic display names; each is unique, so finding one in a body means it was served. */ +const MARK: Record = { + hidden: 'SOXHIDDEN71', + closed: 'SOXCLOSED72', + listonly: 'SOXLISTONLY73', + getonly: 'SOXGETONLY74', + open: 'SOXOPEN75', + private: 'SOXPRIVATE76', +}; + +type Row = Record; +const rowsOf = (body: any): Row[] => body?.records ?? body?.data ?? (Array.isArray(body) ? body : []); + +describe('[#22661] a second-object read serves only a target whose declared exposure serves it', () => { + let stack: VerifyStack; + const token: Record<'admin' | 'member', string> = { admin: '', member: '' }; + /** The stored id each source lookup holds (the same on both source rows, except `hidden`). */ + const ids: Record = {}; + let sourceId = ''; + + beforeAll(async () => { + stack = await bootStack(secondObjectExposureStack as never, { security: secondObjectExposureSecurity() }); + token.admin = await stack.signIn(); + token.member = await stack.signUp(MEMBER_EMAIL); + const ql = (await stack.kernel.getServiceAsync('objectql')) as any; + // Two hidden rows whose name order is the reverse of their id order, so a + // sort by the withheld name and a sort by the stored id disagree. + await ql.insert('sox_hidden', { id: 'soxh_2', name: `A-${MARK.hidden}` }, SYS); + await ql.insert('sox_hidden', { id: 'soxh_1', name: `Z-${MARK.hidden}` }, SYS); + ids.hidden = 'soxh_2'; + for (const rel of ['closed', 'listonly', 'getonly', 'private'] as const) { + const row = await ql.insert(`sox_${rel}`, { name: MARK[rel] }, SYS); + ids[rel] = String(row.id); + } + const open = await ql.insert('sox_open', { name: MARK.open, inner: 'soxh_1' }, SYS); + ids.open = String(open.id); + const a = await ql.insert('sox_source', { name: 'src-a', amount: 1, ...ids }, SYS); + sourceId = String(a.id); + await ql.insert('sox_source', { name: 'src-b', amount: 2, ...ids, hidden: 'soxh_1' }, SYS); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + for (const persona of ['admin', 'member'] as const) { + describe(persona, () => { + it('ARMED — the data door answers each target\'s own get as its declaration says', async () => { + for (const rel of [...WITHHELD, ...SERVED]) { + const res = await stack.apiAs(token[persona], 'GET', `/data/${TARGETS[rel].object}/${ids[rel]}`); + expect(res.status, `${rel}: ${await res.clone().text()}`).toBe(TARGETS[rel].getStatus); + } + }); + + const spellings: Array<[string, (rel: Rel) => Promise]> = [ + ['the list route', async (rel) => { + const res = await stack.apiAs(token[persona], 'GET', `/data/sox_source?$expand=${rel}&$filter=${encodeURIComponent(JSON.stringify({ name: 'src-a' }))}`); + expect(res.status, await res.clone().text()).toBe(200); + return rowsOf(await res.json())[0]!; + }], + ['the single-record route', async (rel) => { + const res = await stack.apiAs(token[persona], 'GET', `/data/sox_source/${sourceId}?expand=${rel}`); + expect(res.status, await res.clone().text()).toBe(200); + const body: any = await res.json(); + return body.record ?? body; + }], + ['the query route relation map', async (rel) => { + const res = await stack.apiAs(token[persona], 'POST', '/data/sox_source/query', { + where: { name: 'src-a' }, + expand: { [rel]: { object: rel, fields: ['name'] } }, + }); + expect(res.status, await res.clone().text()).toBe(200); + return rowsOf(await res.json())[0]!; + }], + ]; + + for (const [spelling, read] of spellings) { + it(`$expand on ${spelling}: a withheld target answers as an unexpanded lookup`, async () => { + for (const rel of WITHHELD) { + const row = await read(rel); + expect(row[rel], rel).toBe(ids[rel]); + } + }); + it(`CONTROL $expand on ${spelling}: a served target is expanded`, async () => { + for (const rel of SERVED) { + const row = await read(rel); + expect(row[rel]?.name, rel).toBe(MARK[rel]); + } + }); + } + + it('$expand second level: the inner entry into the unexposed object is withheld, the outer one served', async () => { + const res = await stack.apiAs(token[persona], 'POST', '/data/sox_source/query', { + where: { name: 'src-a' }, + expand: { open: { object: 'open', expand: { inner: { object: 'inner' } } } }, + }); + expect(res.status, await res.clone().text()).toBe(200); + const row = rowsOf(await res.json())[0]!; + expect(row.open?.name).toBe(MARK.open); + expect(row.open?.inner).toBe('soxh_1'); + }); + + it('the export door names a served target and not a withheld one', async () => { + const res = await stack.apiAs(token[persona], 'GET', '/data/sox_source/export?format=json'); + expect(res.status, await res.clone().text()).toBe(200); + const text = await res.text(); + for (const rel of SERVED) expect(text, rel).toContain(MARK[rel]); + for (const rel of WITHHELD) expect(text, rel).not.toContain(MARK[rel]); + }); + + const dataset = (rel: Rel) => ({ + name: `sox_by_${rel}`, + label: `By ${rel}`, + object: 'sox_source', + dimensions: [{ name: rel, label: rel, field: rel, type: 'lookup' }], + measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }], + }); + const datasetRows = async (rel: Rel, order?: Record) => { + const res = await stack.apiAs(token[persona], 'POST', '/analytics/dataset/query', { + dataset: dataset(rel), + selection: { dimensions: [rel], measures: ['cnt'], ...(order ? { order } : {}) }, + }); + expect(res.status, await res.clone().text()).toBe(200); + return ((await res.json()) as { rows?: Row[] }).rows ?? []; + }; + + it('dataset label pass: a withheld target renders its stored id', async () => { + for (const rel of ['closed', 'listonly'] as const) { + expect((await datasetRows(rel)).map((r) => r[rel]), rel).toEqual([ids[rel]]); + } + expect((await datasetRows('hidden')).map((r) => r.hidden).sort()).toEqual(['soxh_1', 'soxh_2']); + }); + + it('CONTROL dataset label pass: a served target renders its name', async () => { + for (const rel of SERVED) { + expect((await datasetRows(rel)).map((r) => r[rel]), rel).toEqual([MARK[rel]]); + } + }); + + it('dataset sort-key pass: an order on a withheld dimension sorts by the stored id', async () => { + // By the withheld names this would read soxh_2 (A-…) first. + expect((await datasetRows('hidden', { hidden: 'asc' })).map((r) => r.hidden)).toEqual(['soxh_1', 'soxh_2']); + }); + }); + } + + it('PRECEDENT — a related record the member may not read answers as an unexpanded lookup, unchanged', async () => { + const res = await stack.apiAs(token.member, 'GET', `/data/sox_source/${sourceId}?expand=private`); + expect(res.status, await res.clone().text()).toBe(200); + const body: any = await res.json(); + expect((body.record ?? body).private).toBe(ids.private); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/dimension-label-exposure.test.ts b/packages/services/service-analytics/src/__tests__/dimension-label-exposure.test.ts new file mode 100644 index 00000000000..f9bf5c30d1c --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/dimension-label-exposure.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22661] The dataset door's dimension-label passes read a SECOND object — the + * target a reference-class dimension points at — and ask that target the + * spec's one exposure decision (`apiExposureDenialReason(enable, 'get')`, + * ADR-0049) before reading it. A target the decision does not serve is never + * read: the display pass leaves the stored id in the row, and the sort-key pass + * orders by it — the answer a target the reader's row scope hides already gets. + * + * Both passes are driven through `AnalyticsService.queryDataset` with the + * generic-exit declaration probe wired, the way `AnalyticsServicePlugin` wires + * it. Each withheld shape is paired with a CONTROL the decision serves (the + * names render and sort), and a select dimension beside the lookup keeps its + * option labels — the gate is about the second object, not about labels. The + * real plugin bridge, for an administrator and a member, is pinned in + * `@objectstack/dogfood`. + */ + +import { describe, it, expect } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { AnalyticsService } from '../analytics-service.js'; +import type { DimensionLabelDeps, FieldMetaLite } from '../dimension-labels.js'; +import type { ObjectDeclaration } from '../api-exposure-door.js'; + +const CTX = { tenantId: 'org_A' } as ExecutionContext; +const TARGET = 'lx_account'; + +const byAccount = DatasetSchema.parse({ + name: 'deals_by_account', label: 'Deals', object: 'lx_deal', include: [], + dimensions: [ + { name: 'account', field: 'account', type: 'lookup', label: 'Account' }, + { name: 'stage', field: 'stage', type: 'string', label: 'Stage' }, + ], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], +}); + +const FIELDS: Record> = { + lx_deal: { + account: { type: 'lookup', reference: TARGET }, + stage: { type: 'select', options: [{ value: 'won', label: 'Won' }, { value: 'lost', label: 'Lost' }] }, + amount: { type: 'number' }, + }, + [TARGET]: { name: { type: 'text' } }, +}; + +/** + * Three orders that all disagree: by id (a1, b2, c3), by name (b2 Apple, c3 + * Mango, a1 Zebra), and as the strategy returns the rows (c3, a1, b2) — so a + * sort by the stored id, a sort by the withheld name, and no sort at all are + * three different answers. + */ +const NAMES: Record = { a1: 'Zebra', b2: 'Apple', c3: 'Mango' }; +const ROWS = [ + { account: 'c3', stage: 'won', revenue: 30 }, + { account: 'a1', stage: 'won', revenue: 10 }, + { account: 'b2', stage: 'lost', revenue: 20 }, +]; + +function service(targetEnable: Record | undefined, opts: { throwOnTarget?: boolean; probe?: boolean } = {}) { + const fetched: string[] = []; + const labelResolver: DimensionLabelDeps = { + getObjectFields: (obj) => FIELDS[obj], + fetchRecordLabels: async (target, ids) => { + fetched.push(target); + const m = new Map(); + for (const id of ids) if (NAMES[String(id)]) m.set(id, NAMES[String(id)]); + return m; + }, + }; + const getObjectDeclaration = (name: string): ObjectDeclaration | undefined => { + if (name === TARGET) { + if (opts.throwOnTarget) throw new Error('declaration store unavailable'); + return { enable: targetEnable, fields: FIELDS[TARGET] }; + } + return name === 'lx_deal' ? { fields: FIELDS.lx_deal } : undefined; + }; + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async () => ROWS.map((r) => ({ ...r })), + labelResolver, + ...(opts.probe === false ? {} : { getObjectDeclaration }), + }); + return { svc, fetched }; +} + +async function displayPass(svc: AnalyticsService) { + const result = await svc.queryDataset(byAccount, { dimensions: ['account', 'stage'], measures: ['revenue'] }, CTX); + return result.rows.map((r) => [r.account, r.stage]); +} + +async function sortKeyPass(svc: AnalyticsService) { + const result = await svc.queryDataset(byAccount, { dimensions: ['account'], measures: ['revenue'], order: { account: 'asc' } }, CTX); + return result.rows.map((r) => r.account); +} + +const WITHHELD: Array<[string, Record]> = [ + ['apiEnabled: false (a whitelist granting get beside it changes nothing)', { apiEnabled: false, apiMethods: ['get'] }], + ['a whitelist that grants no read', { apiMethods: ['create'] }], + ['the deny-all whitelist', { apiMethods: [] }], + ['a whitelist that grants list but not get', { apiMethods: ['list'] }], +]; + +const SERVED: Array<[string, Record | undefined]> = [ + ['no enable block', undefined], + ['a whitelist that grants get', { apiMethods: ['get'] }], +]; + +describe('[#22661] a dimension label reads only a target the API serves', () => { + for (const [label, enable] of WITHHELD) { + it(`${label}: the display pass renders the stored ids and never reads the target`, async () => { + const { svc, fetched } = service(enable); + expect(await displayPass(svc)).toEqual([['c3', 'Won'], ['a1', 'Won'], ['b2', 'Lost']]); + expect(fetched).toEqual([]); + }); + it(`${label}: the sort-key pass orders by the stored ids and never reads the target`, async () => { + const { svc, fetched } = service(enable); + expect(await sortKeyPass(svc)).toEqual(['a1', 'b2', 'c3']); + expect(fetched).toEqual([]); + }); + } + + for (const [label, enable] of SERVED) { + it(`CONTROL ${label}: both passes read the target, and the names render and sort`, async () => { + const { svc, fetched } = service(enable); + expect(await displayPass(svc)).toEqual([['Mango', 'Won'], ['Zebra', 'Won'], ['Apple', 'Lost']]); + expect(await sortKeyPass(svc)).toEqual(['Apple', 'Mango', 'Zebra']); + expect(fetched.length).toBeGreaterThan(0); + expect(new Set(fetched)).toEqual(new Set([TARGET])); + }); + } + + it('a declaration that cannot be read withholds the labels (fail-closed); the query still answers', async () => { + const { svc, fetched } = service(undefined, { throwOnTarget: true }); + expect(await displayPass(svc)).toEqual([['c3', 'Won'], ['a1', 'Won'], ['b2', 'Lost']]); + expect(fetched).toEqual([]); + }); + + it('CONTROL no declaration probe wired: no gate, the names render (the query face reports the stand-down)', async () => { + const { svc } = service({ apiEnabled: false }, { probe: false }); + expect(await displayPass(svc)).toEqual([['Mango', 'Won'], ['Zebra', 'Won'], ['Apple', 'Lost']]); + }); +}); diff --git a/packages/services/service-analytics/src/api-exposure-door.ts b/packages/services/service-analytics/src/api-exposure-door.ts index 7c9124075ee..fcac6f0e9d1 100644 --- a/packages/services/service-analytics/src/api-exposure-door.ts +++ b/packages/services/service-analytics/src/api-exposure-door.ts @@ -125,6 +125,23 @@ export type ObjectDeclarationProvider = (objectName: string) => ObjectDeclaratio /** The canonical operation an analytics read is judged as. */ const ANALYTICS_OPERATION = 'aggregate'; +/** + * [#22661] The operation a dimension LABEL's target is judged as: `get`, not + * {@link ANALYTICS_OPERATION}. + * + * A label turns an id the grid already holds into the display name of the + * record it names — the read `GET /data/{target}/{id}` performs, judged `get` + * on the data routes. It aggregates nothing over the target: the bridge's + * `group by (id, name)` is a per-record read dressed as an aggregate (one row + * per id, bounded to `id $in` the grouped values). A relationship-hop + * dimension (`.`) is the different read — it groups the base + * rows by a target column — and the query face judges it `aggregate` over + * `queryObjects`, unchanged. `get` is also the operation the data door's + * `$expand` asks of the same target, so a lookup renders its name, or its + * stored id, alike on both doors for every `apiMethods` shape. + */ +const LABEL_TARGET_OPERATION = 'get'; + /** * The data door's two exposure codes, the same pair the REST data routes and * the MCP data tools answer. Typed against the ledger so a misspelling fails @@ -322,11 +339,8 @@ export function assertDefinitionExposed( * faces above never ask about: the dataset's base object and its hops are * judged, the label target was not, so a dataset over an exposed object * rendered the display names of an object every data route refuses. Asked the - * same decision, for the same `aggregate` operation: a label is the read a - * relationship-hop dimension over the target's display field performs - * (`.`), which this door already judges as `aggregate` over - * `queryObjects`; judging the label as anything else would serve through one - * spelling what the other is refused. It takes no caller. + * spec's one decision, for {@link LABEL_TARGET_OPERATION} (`get`, which says + * why), and it takes no caller. * * Withheld, not refused, and that is the precedent measured on this door: a * label the target's row scope hides, or whose scope cannot be resolved, is @@ -350,7 +364,7 @@ export function servesLabelTarget( ); return false; } - return apiExposureDenialReason(enable, ANALYTICS_OPERATION) === null; + return apiExposureDenialReason(enable, LABEL_TARGET_OPERATION) === null; } /** From e5eb5a9e914b25b4dfc0f1cf9f5a801a5cdb10b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:56:19 +0000 Subject: [PATCH 3/5] test(core): enumerate the reads that reach a second object and hold each to the exposure decision (#22661) Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- .../second-object-read-exposure.pin.test.ts | 321 ++++++++++++++++++ packages/core/vitest.repo-tests.json | 3 +- 2 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/security/second-object-read-exposure.pin.test.ts diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts new file mode 100644 index 00000000000..1123631141c --- /dev/null +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -0,0 +1,321 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22661] Every read that reaches a SECOND object — the record a lookup + * points at, rather than the object a request addresses — asks that object's + * own declared exposure through the spec's one decision + * (`apiExposureDenialReason` or its boolean face `canServeApiOperation`, + * `@objectstack/spec/data`, ADR-0049), or is named here as a read that does + * not yet, with the reason. + * + * 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, and the dataset door's label + * passes rendered its display names. Both now ask the target (behaviour-pinned + * in their own packages and on a real stack in `@objectstack/dogfood`). 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. + * + * ## How a second-object read is found, mechanically + * + * Following a lookup means asking which object the reference field points at, + * and the spec declares ONE arbiter of that answer, `referenceTargetOf` + * (`@objectstack/spec/data`; a raw `field.reference` read gets `user` wrong, + * cloud#983). A CALL of it is the discriminator: every non-test `.ts` source + * under `packages/` that calls it must be classified in {@link CALLERS} below — + * as a read that asks the decision (where, for which operation, and the + * behaviour pin), as the executor of a read another file decides, as a census + * read that does not ask yet (with what was measured and who carries it), or + * as something that serves no field of the target (with the reason). An + * unclassified caller, or a classified file that no longer calls it, turns + * this red. + * + * ⛔ What the discriminator does NOT see, stated rather than discovered later: + * a read whose target is not resolved through `referenceTargetOf`. Read on + * `origin/main` at eae3368a, those are the analytics relationship hop + * (`service-analytics/src/hop-object.ts` — its target comes from the host's + * relationship resolver; the analytics door already judges every hop's object + * for `aggregate` over `queryObjects`), and the reads whose target is a FIXED + * platform object rather than an authored one (a file field's `sys_file` + * hydration, the `sys_user` display names the approvals and audit surfaces + * resolve). They are named here, not held. + * + * The scan surface and its prefilter follow `row-serving-door-exposure.pin.test.ts` + * in this directory: git's authored-file list, never a directory crawl, and `.ts` + * only, the radius this package declares in the cross-package test-inputs table. + * That is also why this file is listed in this package's `repo` vitest project. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +/** …/packages/core/src/security → repo root */ +const REPO_ROOT = resolve(HERE, '../../../..'); + +/** The reference-target arbiter, as data. */ +const ARBITER = 'referenceTargetOf'; + +/** A CALL of the arbiter. Its own declaration (`function referenceTargetOf(`) is not one. */ +const arbiterCall = (line: string): boolean => + new RegExp(`\\b${ARBITER}\\s*\\(`).test(line) && !new RegExp(`function\\s+${ARBITER}\\b`).test(line); + +/** A CALL of the spec's exposure decision, either face. */ +const DECISION_CALL = /\b(?:apiExposureDenialReason|canServeApiOperation)\s*\(/; + +type Classification = + | { + kind: 'decided'; + /** The read, in words. */ + read: string; + /** The operation the target is judged as. */ + operation: 'get'; + /** The function that asks the decision, and the file it lives in. */ + decision: { file: string; fn: string }; + /** Where the read takes its answer from that function: a call site that must exist. */ + wiring: { file: string; call: string }; + /** The behaviour pin that holds the read to it. */ + pin: string; + } + | { + kind: 'executes'; + read: string; + /** The classified file whose door decides this read. */ + decidedIn: string; + /** What else in this file calls the arbiter, and why it serves no field. */ + also: string; + } + | { + kind: 'open'; + read: string; + /** What the census measured, on a real stack. */ + measured: string; + /** Why it is not decided in the card that took the census, and who carries it. */ + carrier: string; + } + | { kind: 'not-a-served-read'; reason: string }; + +/** Every non-test caller of the arbiter, classified. */ +const CALLERS: Record = { + 'packages/metadata-protocol/src/protocol.ts': { + kind: 'decided', + read: "the data door's `$expand` (the list, single-record, query and export routes, every level of the tree)", + operation: 'get', + decision: { file: 'packages/metadata-protocol/src/protocol.ts', fn: 'servesExpansionTarget' }, + wiring: { file: 'packages/metadata-protocol/src/protocol.ts', call: 'this.servedExpand(' }, + pin: 'packages/metadata-protocol/src/protocol.expand-target-exposure.test.ts', + }, + 'packages/objectql/src/engine.ts': { + kind: 'executes', + read: "the expansion sub-read (`ObjectQL.expandRelatedRecords`), for the data door's `$expand`", + decidedIn: 'packages/metadata-protocol/src/protocol.ts', + also: + 'the write-path reference-existence probe (`referenceExists`: whether a stored id resolves, no field) and ' + + 'the validation-predicate reads (`resolvePredicateRelated`: a pass/fail verdict, the value never served). ' + + "The engine's privileged callers (a flow's `config.expand`, a hook's read) never pass the door, by design.", + }, + 'packages/services/service-analytics/src/dimension-labels.ts': { + kind: 'decided', + read: "the dataset door's dimension-label passes (display and sort-key)", + operation: 'get', + decision: { file: 'packages/services/service-analytics/src/api-exposure-door.ts', fn: 'servesLabelTarget' }, + wiring: { file: 'packages/services/service-analytics/src/analytics-service.ts', call: 'servesLabelTarget(' }, + pin: 'packages/services/service-analytics/src/__tests__/dimension-label-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', + }, + 'packages/plugins/plugin-approvals/src/approval-service.ts': { + kind: 'open', + read: "the approvals inbox's `payload_display` (`ApprovalService.enrichRows` reads referenced records' titles under a system context)", + measured: "an unexposed target's title is served on the inbox list, to an administrator and a member", + carrier: 'outside the card that took the census (another package and lane); carried by a follow-up of #22661', + }, + 'packages/plugins/plugin-audit/src/audit-writers.ts': { + kind: 'open', + read: "the activity timeline's tracked-change summary (`resolveLookupTitles` writes referenced records' titles at write time)", + measured: "an unexposed target's title is served in the summary on the activity read, to an administrator and a member", + carrier: 'outside the card that took the census (another package and lane); carried by a follow-up of #22661', + }, + 'packages/lint/src/object-graph.ts': { + kind: 'not-a-served-read', + reason: 'authoring-time static analysis of metadata; reads no record', + }, + 'packages/lint/src/validate-field-consumers.ts': { + kind: 'not-a-served-read', + reason: 'authoring-time static analysis of metadata; reads no record', + }, + 'packages/lint/src/validate-object-references.ts': { + kind: 'not-a-served-read', + reason: 'authoring-time static analysis of metadata; reads no record', + }, + 'packages/metadata-protocol/src/seed-loader.ts': { + kind: 'not-a-served-read', + reason: "resolves a seed's references at install, under the system identity; serves nothing to a caller", + }, + 'packages/objectql/src/integrity/dangling-reference-audit.ts': { + kind: 'not-a-served-read', + reason: 'an integrity audit: it probes whether a stored id resolves and reports ids, never a field of the target', + }, + 'packages/objectql/src/no-operator-object-door.ts': { + kind: 'not-a-served-read', + reason: "names the related object in a refusal's wording; reads no record", + }, + 'packages/objectql/src/record-title.ts': { + kind: 'not-a-served-read', + reason: "the hook-body title accessor: author code inside a hook reads it, privileged like any engine call, not an API door", + }, + 'packages/objectql/src/validation/rule-validator.ts': { + kind: 'not-a-served-read', + reason: 'validation-rule analysis and evaluation on the write path; the verdict is pass/fail and no target field is served', + }, +}; + +const SCAN_TIMEOUT_MS = 60_000; + +function git(args: string[]): string[] { + let stdout: string; + try { + stdout = execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 1 << 28 }); + } catch (error) { + const failure = error as { status?: number; stderr?: string }; + // `git grep` exits 1 for "found nothing", which is data. Anything else is a + // broken scan and must not read as "no callers". + if (failure.status === 1) return []; + throw new Error(`git ${args.join(' ')} failed with status ${String(failure.status)}: ${failure.stderr ?? ''}`); + } + return stdout.split('\0').filter((line) => line.length > 0); +} + +const isScannedSource = (path: string) => + path.endsWith('.ts') && !path.endsWith('.d.ts') && !/\.(test|spec)\.ts$/.test(path); + +/** Code lines only: a docblock or line comment that names a call is not one. */ +const codeLines = (file: string) => + readFileSync(join(REPO_ROOT, file), 'utf8') + .split('\n') + .filter((line) => !/^\s*(\*|\/\/|\/\*)/.test(line)); + +const callsArbiter = (file: string) => codeLines(file).some(arbiterCall); + +/** Fixed-string prefilter over tracked plus untracked sources; the matcher decides. */ +function filesMentioningTheArbiter(): string[] { + return git(['grep', '--files-with-matches', '-z', '--untracked', '--fixed-strings', '-e', ARBITER, '--', 'packages']) + .filter(isScannedSource); +} + +function arbiterCallers(): string[] { + return filesMentioningTheArbiter().filter(callsArbiter).sort(); +} + +/** + * The code lines of one named function or method, from its declaration to the + * line its braces close on. Enough for the small decision functions this pin + * reads; it refuses (throws) rather than guessing when the name is not declared. + */ +function functionBody(file: string, fn: string): string[] { + const lines = codeLines(file); + const start = lines.findIndex((line) => + new RegExp(`^\\s*(?:export\\s+)?(?:async\\s+)?(?:function\\s+|private\\s+|public\\s+|protected\\s+)?${fn}\\s*\\(`).test(line), + ); + if (start < 0) throw new Error(`${fn} is not declared in ${file}`); + let depth = 0; + let opened = false; + const body: string[] = []; + for (const line of lines.slice(start)) { + body.push(line); + for (const ch of line) { + if (ch === '{') { depth += 1; opened = true; } + if (ch === '}') depth -= 1; + } + if (opened && depth <= 0) break; + } + return body; +} + +describe('[#22661] every read that reaches a second object asks that object its exposure, or is named', () => { + it( + 'every caller of the reference-target arbiter is classified, and every classified file still calls it', + () => { + const found = arbiterCallers(); + const unclassified = found.filter((file) => !(file in CALLERS)); + const stale = Object.keys(CALLERS).filter((file) => !found.includes(file)); + + expect( + unclassified, + [ + 'These sources resolve which object a reference field points at, which is how a read that', + 'reaches a SECOND object starts, and this pin does not know them:', + ...unclassified.map((f) => ` - ${f}`), + '', + 'Classify each in CALLERS. A read that serves anything of the target (a field, a display name,', + 'an order or a match) must ask `canServeApiOperation` / `apiExposureDenialReason`', + "(`@objectstack/spec/data`) of the TARGET's `enable` block before reading it, and carry a", + 'behaviour pin in its own package.', + ].join('\n'), + ).toEqual([]); + expect(stale, 'These classified files no longer call the arbiter; re-classify or remove them.').toEqual([]); + }, + SCAN_TIMEOUT_MS, + ); + + it( + 'each decided read asks the decision in its decision function, takes its answer from it, and is pinned', + () => { + const decided = Object.values(CALLERS).filter( + (c): c is Extract => c.kind === 'decided', + ); + expect(decided.map((d) => d.decision.fn).sort()).toEqual(['servesExpansionTarget', 'servesLabelTarget']); + + for (const d of decided) { + const body = functionBody(d.decision.file, d.decision.fn); + expect(body.some((line) => DECISION_CALL.test(line)), `${d.decision.fn} (${d.decision.file}) does not ask the exposure decision`).toBe(true); + expect( + readFileSync(join(REPO_ROOT, d.decision.file), 'utf8'), + `${d.decision.file} does not import the decision from the spec`, + ).toMatch(/from '@objectstack\/spec\/data'/); + expect( + codeLines(d.wiring.file).some((line) => line.includes(d.wiring.call)), + `${d.read}: no call site \`${d.wiring.call}\` in ${d.wiring.file}`, + ).toBe(true); + expect(existsSync(join(REPO_ROOT, d.pin)), `the behaviour pin of ${d.read} is missing: ${d.pin}`).toBe(true); + } + }, + SCAN_TIMEOUT_MS, + ); + + it('each executed read names the classified door that decides it', () => { + for (const [file, c] of Object.entries(CALLERS)) { + if (c.kind !== 'executes') continue; + expect(CALLERS[c.decidedIn]?.kind, `${file}: ${c.decidedIn} is not a decided door`).toBe('decided'); + } + }); + + it( + 'the scan cannot pass vacuously: the prefilter reaches every classified file and the matcher fires on each', + () => { + const mentioning = filesMentioningTheArbiter(); + for (const file of Object.keys(CALLERS)) { + expect(mentioning, `the prefilter did not reach ${file}`).toContain(file); + expect(callsArbiter(file), `the call matcher does not fire on ${file}`).toBe(true); + } + // The arbiter's own declaration names it, and is not a call of it. + const declaration = 'packages/spec/src/data/field-value.zod.ts'; + expect(mentioning).toContain(declaration); + expect(callsArbiter(declaration)).toBe(false); + // The body reader reads the right function: the decision lives in it, not beside it. + expect(functionBody('packages/metadata-protocol/src/protocol.ts', 'servedExpand').some((line) => DECISION_CALL.test(line))).toBe(false); + }, + SCAN_TIMEOUT_MS, + ); +}); diff --git a/packages/core/vitest.repo-tests.json b/packages/core/vitest.repo-tests.json index 4df1749b215..d0db050bdd1 100644 --- a/packages/core/vitest.repo-tests.json +++ b/packages/core/vitest.repo-tests.json @@ -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" ] From fc202fb010ed37645df06c7495fa801141eab223 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:57:39 +0000 Subject: [PATCH 4/5] chore(changeset): the second-object exposure narrowing, FROM -> TO (#22661) Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- .changeset/22661-second-object-exposure.md | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .changeset/22661-second-object-exposure.md diff --git a/.changeset/22661-second-object-exposure.md b/.changeset/22661-second-object-exposure.md new file mode 100644 index 00000000000..63ed91b9acb --- /dev/null +++ b/.changeset/22661-second-object-exposure.md @@ -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 + + + +**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. From 25abc956526071d40c98a51c4495e6817c6b44fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:35:55 +0000 Subject: [PATCH 5/5] test(core,metadata-protocol): name the import door's reference resolution in the census; pin the new engine double (#22661) Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- .../second-object-read-exposure.pin.test.ts | 13 +++++++++---- scripts/engine-double-contract.pinned.json | 5 +++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts index 1123631141c..f6ffaeb5e13 100644 --- a/packages/core/src/security/second-object-read-exposure.pin.test.ts +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -36,10 +36,15 @@ * `origin/main` at eae3368a, those are the analytics relationship hop * (`service-analytics/src/hop-object.ts` — its target comes from the host's * relationship resolver; the analytics door already judges every hop's object - * for `aggregate` over `queryObjects`), and the reads whose target is a FIXED - * platform object rather than an authored one (a file field's `sys_file` - * hydration, the `sys_user` display names the approvals and audit surfaces - * resolve). They are named here, not held. + * for `aggregate` over `queryObjects`); the import door's reference resolution + * (`core/src/utils/import-runner.ts` `resolveRef`, whose target + * `import-field-meta.ts` reads off the raw `reference` carrier — measured: a + * cell's display text is matched against an unexposed target and the matched + * id is stored, a miss is reported per row; open, carried by a follow-up of + * #22661); and the reads whose target is a FIXED platform object rather than + * an authored one (a file field's `sys_file` hydration, the `sys_user` display + * names the approvals and audit surfaces resolve). They are named here, not + * held. * * The scan surface and its prefilter follow `row-serving-door-exposure.pin.test.ts` * in this directory: git's authored-file list, never a directory crawl, and `.ts` diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 3141d49a218..daa9b00bc62 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -681,6 +681,11 @@ "verb": "update", "pinned": 3 }, + { + "file": "packages/metadata-protocol/src/protocol.expand-target-exposure.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts", "verb": "findOne",