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
33 changes: 33 additions & 0 deletions .changeset/22739-import-reference-target-exposure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@objectstack/core': minor
---

fix(core)!: an import's reference resolution asks the lookup TARGET its declared exposure before matching a cell (#22739)

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, in two places: the import's reference resolution stops matching cells against a lookup's target object whose existing `enable` declaration already refuses that read on every data route (the remedy is the declaration the author already wrote), and a field of the legacy, schema-refused type spelling `'reference'` no longer takes a target from its raw `reference` key, since the spec's arbiter gives it none (no shipped producer declares one). 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 — `ImportProtocolLike` gains one optional member (not runtime-interface-only or type-surface-only). -->

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

`POST /api/v1/data/:object/import` and the async `POST /api/v1/data/:object/import/jobs` judge the ADDRESSED object's `enable` block. A lookup cell is resolved by matching its text against the lookup's TARGET object — an object nobody addressed.

**FROM.**

- For an administrator and a member alike, a cell was matched against a target whose declaration refuses the read on every data route (`enable.apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `list`): a cell naming an existing record stored its id, and a cell naming none answered `reference_not_found` — so the row report told the two apart.
- A field of the legacy type spelling `'reference'` (not a `FieldType`, so `ObjectSchema` refuses it, but still listed in the import, export and import-template doors' own type tables) took its target from the raw `reference` key: import matched its cells against that object and export expanded it.

**TO.**

- The runner asks the spec's one decision (`canServeApiOperation`) of the TARGET's `enable` block, for `list` — matching a cell is a predicate read over the target, the runtime `find` the spec maps to `list`. A target the decision does not serve answers every cell, naming a record or not (a pasted id included), `reference_not_found` — the answer a target the caller cannot read (no read permission, or a record its row scope hides) already gets. Nothing of the target is read. A target with no `enable` block, or a whitelist that grants `list`, resolves exactly as before. A declaration that cannot be read withholds the target too (fail-closed).
- A field is a reference on those doors exactly when it declares a `reference` string, as before, and its target is read through the spec's arbiter `referenceTargetOf`, which names none for a type outside `lookup` / `master_detail` / `user` / `tree`: such a `'reference'`-typed field's cell is stored as written, and export leaves its stored id. Every other non-reference type was never resolved by these doors and is unchanged. Measured over every git-tracked non-test source under `packages/` and `examples/` (`.ts`, `.js`, `.json`; fixtures excluded) at `0d326bfb12`: no shipped field declares `reference` on a non-reference type (0 object literals, 0 `Field.*` helper calls; the one literal pairing `type` with `reference` is a key-classification table, not a field), against a control of 30 reference-typed literals and 80 `Field.lookup` / `masterDetail` / `user` / `tree` calls; a planted literal and a planted helper call were both found.

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

**Fix, if an import relied on the old answer:** the target's own declaration decides — grant `list` in its `enable.apiMethods`, or drop `enable.apiEnabled: false`, if the object is meant to be matched by import.

**Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` (spec and qa excluded) and `examples/`: twelve objects refuse `list` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (its own import door already answers 404); the control count of lookups into `sys_user` is 75. So no shipped object's import answer changes. Deployed and cloud-held object definitions were NOT MEASURED.
31 changes: 18 additions & 13 deletions packages/core/src/security/second-object-read-exposure.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
*
* The data routes judge the ADDRESSED object. A read that follows a lookup
* reaches an object nobody addressed: the data door's `$expand` served the row
* fields of a target every data route refuses, 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
* fields of a target every data route refuses, the dataset door's label
* passes rendered its display names, and the import door matched cells against
* it (#22739). Each read classified `decided` below now asks the target
* (behaviour-pinned in its own package). This
* file is the ENUMERATION: it names the census of such reads, holds each
* decided one to the decision, and goes red when a new one appears without
* being classified here.
Expand All @@ -36,15 +37,10 @@
* `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`); 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.
* 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`
Expand Down Expand Up @@ -79,7 +75,7 @@ type Classification =
/** The read, in words. */
read: string;
/** The operation the target is judged as. */
operation: 'get';
operation: 'get' | 'list';
/** 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. */
Expand Down Expand Up @@ -132,6 +128,14 @@ const CALLERS: Record<string, Classification> = {
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/core/src/utils/import-field-meta.ts': {
kind: 'decided',
read: "the import door's reference resolution (`resolveRef` matches a cell against the target; the export and template doors read the same map, the export's expansion decided in protocol.ts)",
operation: 'list',
decision: { file: 'packages/core/src/utils/import-runner.ts', fn: 'servesReferenceTarget' },
wiring: { file: 'packages/core/src/utils/import-runner.ts', call: 'servesReferenceTarget(p, referenceObject' },
pin: 'packages/core/src/utils/import-runner-reference-exposure.test.ts',
},
'packages/objectql/src/relation-filter-lowering.ts': {
kind: 'open',
read: "the data door's nested-relation filter condition (`ObjectQL.lowerRelationConditions` reads the related object to lower it)",
Expand Down Expand Up @@ -288,6 +292,7 @@ describe('[#22661] every read that reaches a second object asks that object its
'servesExpansionTarget',
'servesLabelTarget',
'servesPayloadDisplayTarget',
'servesReferenceTarget',
'servesSummaryTitleTarget',
]);

Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/utils/import-field-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@
* needs. The export renderers stay in `rest`.
*/

import { referenceTargetOf } from '@objectstack/spec/data';

export interface ExportFieldMeta {
name: string;
type?: string;
label?: string;
options?: Array<{ label?: string; value?: unknown; color?: string }>;
/** Target object for lookup / master_detail / user fields. */
/**
* Target object of a field that declares a `reference` string, read through
* the spec's one arbiter `referenceTargetOf` (so a non-reference type names
* none). The import's reference resolution matches cells against it and asks
* it its exposure first (#22739). Only a declared `reference` makes a field
* one: a `user` field written without it keeps no target here, as before.
*/
reference?: string;
/** Field on the referenced record to show as its label. */
displayField?: string;
Expand Down Expand Up @@ -79,7 +87,7 @@ export function buildFieldMetaMap(schema: unknown): Map<string, ExportFieldMeta>
type: typeof f.type === 'string' ? f.type : undefined,
label: typeof f.label === 'string' ? f.label : undefined,
options: Array.isArray(f.options) ? f.options : undefined,
reference: typeof f.reference === 'string' ? f.reference : undefined,
reference: typeof f.reference === 'string' ? referenceTargetOf(f) : undefined,
displayField: typeof f.displayField === 'string' ? f.displayField : undefined,
multiple: f.multiple === true,
});
Expand Down
122 changes: 122 additions & 0 deletions packages/core/src/utils/import-runner-reference-exposure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#22739] An import cell resolved against a lookup TARGET whose declared
* exposure does not serve `list` answers as a target the caller cannot read:
* `reference_not_found`, for a cell that matches and a cell that does not
* alike, and the target is never read.
*
* The decision is the spec's one (`canServeApiOperation`), asked of the
* target's `enable` block as the protocol's `getMetaItem` serves it. It takes
* no caller, so a member and an administrator are answered the same. The real
* doors (`POST /data/:object/import` and `/import/jobs`, both personas) are
* pinned in `packages/qa/dogfood/test/import-reference-exposure.dogfood.test.ts`.
*/

import { describe, it, expect, vi } from 'vitest';
import { runImport, type ImportProtocolLike } from './import-runner';
import { buildFieldMetaMap, type ExportFieldMeta } from './import-field-meta.js';

type FindArgs = Parameters<ImportProtocolLike['findData']>[0];

const TARGET = 'rx_target';
const RECORD = { id: 'rx_1', name: 'Existing' };

const metaMap = new Map<string, ExportFieldMeta>([
['name', { name: 'name', type: 'text' }],
['ref', { name: 'ref', type: 'lookup', reference: TARGET }],
]);

/** A protocol over one target record, whose declaration is `enable` (or a throw). */
function protocol(enable: unknown, opts: { metaThrows?: boolean } = {}) {
const findData = vi.fn(async (args: FindArgs) => {
if (args.object !== TARGET) return [];
const [[field, value]] = Object.entries(args.query!.where!);
return (RECORD as Record<string, unknown>)[field] === value ? [RECORD] : [];
});
const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) => {
if (opts.metaThrows) throw new Error('metadata store unavailable');
return { type: 'object', name, item: { name, ...(enable === undefined ? {} : { enable }) } };
});
const p: ImportProtocolLike = { findData, getMetaItem, createData: vi.fn(async (a) => ({ id: 'new', ...a.data })), updateData: vi.fn() };
return { p, findData, getMetaItem };
}

const run = (p: ImportProtocolLike, context: Record<string, unknown>) =>
runImport({
p, context, objectName: 'rx_source', metaMap, writeMode: 'insert', matchFields: [], dryRun: true,
runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false,
rows: [{ name: 'a', ref: 'Existing' }, { name: 'b', ref: 'Missing' }, { name: 'c', ref: 'rx_1' }],
});

const verdict = (r: { ok: boolean; code?: string; field?: string }) => ({ ok: r.ok, code: r.code, field: r.field });
const NOT_FOUND = { ok: false, code: 'reference_not_found', field: 'ref' };
const PERSONAS = { member: { userId: 'u_member', roles: ['member'] }, admin: { userId: 'u_admin', roles: ['admin'] } };

describe('[#22739] import reference resolution asks the target its declared exposure, for list', () => {
for (const [persona, context] of Object.entries(PERSONAS)) {
for (const [label, enable] of [
['apiEnabled: false', { apiEnabled: false }],
['the deny-all whitelist', { apiMethods: [] }],
['a whitelist granting get but not list', { apiMethods: ['get'] }],
['a whitelist granting no read', { apiMethods: ['create'] }],
] as const) {
it(`${persona}: ${label} answers a match, a miss and an id alike, and reads nothing`, async () => {
const { p, findData } = protocol(enable);
const summary = await run(p, context);
expect(summary.results.map(verdict)).toEqual([NOT_FOUND, NOT_FOUND, NOT_FOUND]);
expect(findData).not.toHaveBeenCalled();
});
}

for (const [label, enable] of [
['no enable block', undefined],
['a whitelist granting list', { apiMethods: ['list'] }],
] as const) {
it(`CONTROL ${persona}: ${label} resolves a match and an id, and misses a miss`, async () => {
const { p } = protocol(enable);
const summary = await run(p, context);
expect(summary.results.map(verdict)).toEqual([{ ok: true, code: undefined, field: undefined }, NOT_FOUND, { ok: true, code: undefined, field: undefined }]);
});
}
}

it('a declaration that cannot be read withholds the target (fail-closed), asked once per import', async () => {
const { p, findData, getMetaItem } = protocol(undefined, { metaThrows: true });
const summary = await run(p, PERSONAS.member);
expect(summary.results.map(verdict)).toEqual([NOT_FOUND, NOT_FOUND, NOT_FOUND]);
expect(findData).not.toHaveBeenCalled();
expect(getMetaItem).toHaveBeenCalledTimes(1);
});
});

/**
* CONTROL — which fields the import treats as references is unchanged: a field
* declaring a `reference` string. A `user` field written without one is NOT
* resolved (its raw cell reaches the write, as on `main`), although the spec's
* arbiter gives it `sys_user`; adopting that target widens the accept set and
* is not this change. A non-reference type declaring `reference` names no target.
*/
describe('[#22739] the reference field set stays the declared-reference set', () => {
it('a user field without reference passes its cell through unresolved; one declaring it is resolved', async () => {
const meta = buildFieldMetaMap({ fields: {
owner: { type: 'user', label: 'Owner' },
assignee: { type: 'user', label: 'Assignee', reference: 'sys_user' },
amount: { type: 'number', label: 'Amount', reference: 'rx_target' },
} });
expect([meta.get('owner')!.reference, meta.get('assignee')!.reference, meta.get('amount')!.reference]).toEqual([undefined, 'sys_user', undefined]);

const findData = vi.fn(async (args: FindArgs) => (args.object === 'sys_user' && Object.values(args.query!.where!)[0] === 'Ann' ? [{ id: 'u_ann' }] : []));
const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) => ({ type: 'object', name, item: { name } }));
const createData = vi.fn(async (a: { data: Record<string, unknown> }) => ({ id: 'new', ...a.data }));
const p: ImportProtocolLike = { findData, getMetaItem, createData, updateData: vi.fn() };
const summary = await runImport({
p, objectName: 'rx_source', metaMap: meta, writeMode: 'insert', matchFields: [], dryRun: false,
runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false,
rows: [{ owner: 'Ann', assignee: 'Ann' }],
});
expect(summary.results.map(verdict)).toEqual([{ ok: true, code: undefined, field: undefined }]);
expect(createData.mock.calls[0]![0].data).toMatchObject({ owner: 'Ann', assignee: 'u_ann' });
expect(getMetaItem.mock.calls.map(([r]) => r.name)).toEqual(['sys_user']);
});
});
Loading
Loading