Skip to content

Commit afc3b64

Browse files
fix(objectql): scope the lookup existence probe to the caller's organization (#19836)
Fixes #19808 Clause-②: no ## What this changes `ObjectQL.referenceExists`, the probe behind `assertReferencesResolve` (the write-path lookup existence check), read under a bare `{ isSystem: true }`. That context has no `tenantId`, so `buildDriverOptions` sent the driver no tenant and the check looked in every organization. The probe now runs under `ObjectQL.referenceCheckContext(context)`. This is the `sudo()`-shaped `{ ...callerContext, isSystem: true }` that the pre-delete reference check already uses, so both reference checks now build their elevated context the same way: - `isSystem` still skips RBAC/RLS/FLS. That is why the original check was elevated: a user may link to a record they are not allowed to read. - The caller's `tenantId` now reaches the driver. Under the `group` posture, `accessible_org_ids` reaches it too and is widened into `tenantIds`. - A record in another organization now gets the same `reference_not_found` refusal as a record that exists nowhere. - `buildDriverOptions` still sends no tenant for `tenancy.enabled: false` objects and for federated objects. References to those objects keep working from any organization. `assertReferencesResolve` now passes its `context` (already its 4th parameter) to the probe. All three call sites (insert, update by id, bulk update) already passed `opCtx.context`, so no call site changed. The dangling-reference audit calls the probe without a context. It gets `referenceCheckContext(undefined)`, which is `{ isSystem: true }`, the same as before. The audit's behaviour does not change. Code changes are confined to `assertReferencesResolve` and `referenceExists` (one argument, one context expression, rewritten docblocks). The docblock section that explained why the probe ignored tenancy now says why it skips RLS but keeps the tenant filter. ## The card's first step: measured before the fix Question from triage: after the cross-organization reference is stored, can the org-X caller read any field of the org-Y row through any read path? Measured on `origin/main` c118524. The setup was a real `SecurityPlugin` (posture `isolated`, `org-scoping` on) over a real `ObjectQL` over `SqlDriver` (better-sqlite3 `:memory:`). The test was a scratch file under `plugin-security`, which aliases `@objectstack/objectql` and `@objectstack/driver-sql` to source. The file was deleted after the run and never committed. | read path, org-X member, stored contact.account = org-Y account | result | |:--|:--| | `find` with `expand: { account: {} }` | `account` stays the bare id `acc_y`, no fields | | `find` with `expand: { account: { fields: [name, secret, status] } }` | bare id `acc_y`, no fields | | `findOne` with `expand` | bare id `acc_y`, no fields | | direct `find` of the org-Y account | `[]` | | roll-up `summary` on the org-Y parent (count of contacts) | org-Y `contact_count` stays 0. The org-X insert threw `SummaryRecomputeError` after the row was written, because the recompute's update is tenant-scoped and cannot find the parent | | `count` of the org-Y account | 0 | | master-detail header with a `requiredWhen: parent.status == 'locked'` detail field, note omitted | header = org-Y locked row: `note: required`. Header = org-Y open row: **write committed** | So no read path returned an org-Y field value. The last row is different: a parent-scoped predicate over an org-Y header can be observed. `resolveMasterDetailParents` / `resolveMasterDetailParent` read the header under a bare `{ isSystem: true }` too, and this oracle **survives this fix**. After the fix: locked header → `note: required`, open header → `reference_not_found`. The two answers still differ, because `evaluateValidationRules` runs before `assertReferencesResolve`. This is reported to the PM as a separate finding. It is not changed here: it is outside this card's two methods, and open PR #19728 also edits `engine.ts`. ## After the fix: same harness | write, org-X member | before | after | |:--|:--|:--| | lookup to a row only in org Y | committed (plus `SummaryRecomputeError` on the roll-up) | `VALIDATION_FAILED` / `reference_not_found` | | lookup to an id that exists nowhere | `VALIDATION_FAILED` / `reference_not_found` | same | | lookup to an org-X row | commits | commits | | lookup to a `tenancy: { enabled: false }` row | commits | commits | | lookup to an org-X row of an object the member may NOT read (per-object `allowRead: false`, direct read = 403 `PERMISSION_DENIED`) | commits | commits (RLS still skipped under the real SecurityPlugin) | | lookup to an org-Y row of that unreadable object | commits | `reference_not_found` | | lookup to a NULL-organization row of an object exempted only by the deployment's `platformGlobalObjects` | commits | commits | | lookup to an org-Y-stamped row of that deployment-exempted object | commits | `reference_not_found` | The last row is a behaviour change, and it is the hazard the dispatch asked me to measure. The driver still filters a `platformGlobalObjects`-exempted object by organization (#15831, open, `pm:blocked`). So the probe now agrees with what the caller's own `find` of that object already returns (measured: only the NULL-organization row). This PR does not work around it. The changeset tells deployments about it. ## Tests New file `packages/objectql/src/engine-reference-tenant-scope.test.ts`, 15 cases. objectql cannot import `driver-sql`, so the test driver copies `SqlDriver.applyTenantScope`: it filters on `DriverOptions.tenantId`, keeps `OR organization_id IS NULL`, and honours the `tenantIds` union. It also records every call's options. - Refusal, on all three doors (insert, update by id, bulk update): `ValidationError`. `resolveThrownHttpError` reads `{ status: 400, code: 'VALIDATION_FAILED' }` and the fields are `[{ field: 'account', code: 'reference_not_found' }]`. Nothing is written or repointed. - Oracle shut, on all three doors: "only in another organization" and "exists nowhere" give the same envelope. The messages are also identical once the caller's own id is removed. - Controls: a same-org reference commits on all three doors. A `tenancy.enabled: false` target commits, and its probe's driver options carry no `tenantId`; the row is stamped with another org on purpose, so it passes only because the engine withholds the tenant. An `isSystem` write stays unchecked and runs no probe. - Wiring checks: the probe's operation context is `{ isSystem: true, tenantId, userId }` and the driver sees `tenantId`. Under the `group` posture the membership union reaches the probe. The audit's unscoped probe is pinned, so any change to its behaviour has to be a deliberate decision. Ablation, run on committed HEAD 92662fe through `scripts/ablation-replace.mjs`, with an EXIT/INT/TERM trap that restores and checks the blob hash. The test imports `./engine.js` from source, so no dist build was involved. - Leg A: the probe context was changed back to the pre-fix bare `{ isSystem: true }`, with an injected marker. On disk: anchor 1 → 0, marker 0 → 1. Result: **7 failed / 8 passed**. Failed: refusal ×3, oracle ×3, probe wiring. - Leg B: a hand-picked `{ isSystem: true, tenantId }` with no spread. Result: **2 failed**: probe wiring (`userId` missing) and `group` posture (a legitimate reference refused). - Restore after each leg: blob equals HEAD (`4ac24d149603`) and `git diff HEAD` is empty. Control run afterwards: 15/15. ## Local verification (final head 19624a5) - `pnpm --filter @objectstack/objectql test`: 304 files / 5072 tests passed. The trailing `-- --maxWorkers=2` in my command was dropped by vitest; the whole package ran, which is what I intended. - `pnpm --filter @objectstack/objectql typecheck`: exit 0. `check:test-typecheck` OK with the ledger unchanged (40 files / 234 errors / 65 signatures). `tsc --listFilesOnly -p tsconfig.test.json` includes the new test file. - Downstream suites that combine a real engine, tenants and lookups (objectql `dist` rebuilt, or aliased to source): plugin-security 6 files / 88 tests, plugin-sharing 4 / 284, plugin-audit 4 / 83, service-automation 1 / 6, service-settings 1 / 18. All passed. - `node scripts/pm/dispatch-gates.mjs --commands` listed 64 commands. All were run on 19624a5, with each exit code captured before any pipe. 62 exited 0. `--ran` verdict: `64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`. - NOT MEASURED: `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET). It needs every package's `dist`, and a full workspace build does not fit the 10-minute foreground limit. Narrower check instead: `require()` of objectql's `dist/index.js` and `dist/core.js` both load (exit 0). - NOT MEASURED: `check:type-check-debt` (exit 3, PREREQUISITE NOT MET). It needs 14 unbuilt workspace packages for the same reason. objectql's own test layer is covered by `check:test-typecheck` above. - `check:query-options-erasure` failed on the first pass: test surface 236 → 238, from two `as any` option bags in the new test. Fixed in 19624a5 by typing them. It is now at the ceiling (236). - `check-engine-split-ratio --days 90` first refused on the shallow clone. It passed after `git fetch --shallow-since=2026-06-18 origin main`. ## Acceptance notes - The `inspectDanglingReferences` docblock still says the audit "can never be more or less strict than the rule it reports on". That is no longer true for cross-organization references: the write check refuses them, and the audit's unscoped probe does not report them. The `referenceExists` docblock states this gap. The audit's docblock and its behaviour are left alone, because both are outside this card's region and the audit question is open with the PM. - The docblock of `referenceCheckContext` still describes only the pre-delete check. The write-path probe now uses it too. Left as is (outside the region). - Before the fix, a cross-organization child write under a roll-up parent was written and then threw `SummaryRecomputeError`: HTTP 500 after commit. Non-system writes are now refused before the write. `isSystem` writes that name another organization's parent were not measured. Issue not filed; no current owner. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a6a4361 commit afc3b64

3 files changed

Lines changed: 432 additions & 12 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a lookup can no longer point at a record in another organization
6+
7+
When a user in one organization saved a `lookup` (or any other reference field) whose id named a record that exists only in a **different** organization, the write was accepted and the cross-organization link was stored. An id that exists nowhere was refused. So a caller could tell "this id belongs to another organization" apart from "this id does not exist", without being able to read that record.
8+
9+
The reference check now looks only where the caller's organization can see. A record in another organization is treated exactly like a record that does not exist: the write is refused with the existing `VALIDATION_FAILED` error, and the field error code is `reference_not_found`. This applies on create, on update by id and on bulk update. The two cases now give the same response.
10+
11+
What does not change:
12+
13+
- References inside the caller's own organization resolve as before.
14+
- References to platform-global objects (`tenancy: { enabled: false }`) and to federated (`external`) objects still resolve from any organization. The engine already sends no tenant to the driver for those objects.
15+
- The check still ignores row-level security. A user can still link to a record they are not allowed to read, as long as it is in their organization (or in their membership set under the `group` tenancy posture). Whether they may create that link at all is still decided by the permission layer.
16+
- System-context writes (seed replay, package install, provisioning) are still not checked.
17+
- The dangling-reference audit (`inspectDanglingReferences`) still checks existence across all organizations.
18+
19+
One case to check if your deployment uses it: an object made global only by the deployment's `platformGlobalObjects` setting (not by its own `tenancy: { enabled: false }`) is still scoped by organization when the database is read. So a reference to a record of that object that another organization created is now refused. This matches what the caller already gets when reading that object directly. Records with no organization still resolve.
Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19808] The write-path reference check answers "does this id name a row"
5+
* for the CALLER's organization, not for the whole database.
6+
*
7+
* `referenceExists` — the probe behind `assertReferencesResolve` (#4441) — used
8+
* to read under a bare `{ isSystem: true }`. That context carries no
9+
* `tenantId`, so `buildDriverOptions` handed the driver no tenant and the
10+
* existence check spanned every organization. Measured on a real
11+
* `SecurityPlugin` + `ObjectQL` + `SqlDriver` stack, an org-bound caller
12+
* writing a lookup to a row that exists only in ANOTHER organization got a
13+
* committed write, while an id that exists nowhere got `VALIDATION_FAILED`:
14+
* a stored cross-tenant foreign key, and a cross-tenant existence oracle.
15+
*
16+
* The probe now runs under the `sudo()`-shaped `referenceCheckContext`
17+
* (`{ ...callerContext, isSystem: true }`): RLS/FLS stay bypassed — the #4441
18+
* docblock's reason for elevating, which is about row-level VISIBILITY — and
19+
* the caller's `tenantId` survives to the driver.
20+
*
21+
* ## The seam under test, and the double
22+
*
23+
* `@objectstack/objectql` cannot import `@objectstack/driver-sql` (the
24+
* dependency runs the other way — see `engine-external-tenant-scope.test.ts`),
25+
* so the driver below applies the SQL driver's tenant wall to the rows it
26+
* holds, keyed off exactly the input `SqlDriver.applyTenantScope` keys off:
27+
* `DriverOptions.tenantId` (early return when `undefined | null | ''`), the
28+
* `organization_id` column, `OR organization_id IS NULL`, and the `group`
29+
* posture's `tenantIds` union. Every call's options are recorded as well, so
30+
* the cases that matter pin both the behaviour AND the option the engine
31+
* decided.
32+
*
33+
* ## Every write door, both directions
34+
*
35+
* `assertReferencesResolve` has three call sites (insert, update by id, bulk
36+
* update); a guard wired into one is still a hole one call site over. Each
37+
* refusal case runs over all three, and each carries its lit control (a
38+
* same-organization reference still commits through the same door), so a fix
39+
* that refused EVERYTHING cannot pass either.
40+
*/
41+
42+
import { describe, it, expect, beforeEach } from 'vitest';
43+
import type { ExecutionContext } from '@objectstack/spec/kernel';
44+
import { resolveThrownHttpError } from '@objectstack/types';
45+
import { ObjectQL } from './engine.js';
46+
import { ValidationError } from './validation/record-validator.js';
47+
48+
const ORG_X = 'org_x_acme';
49+
const ORG_Y = 'org_y_globex';
50+
51+
/** A normal, non-system member bound to organization X. */
52+
const MEMBER_X = { userId: 'u_x', tenantId: ORG_X } as ExecutionContext;
53+
54+
const PACKAGE_ID = 'com.example.reference-tenant-scope';
55+
56+
/** Tenant-scoped by default: the registry injects `organization_id`. */
57+
const ACCOUNT = {
58+
name: 'rts_account',
59+
label: 'Account',
60+
fields: {
61+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
62+
name: { name: 'name', label: 'Name', type: 'text' as const },
63+
},
64+
} as any;
65+
66+
/** ADR-0066's declared way to say "rows of this object belong to no org". */
67+
const CATALOG = {
68+
name: 'rts_catalog',
69+
label: 'Catalog',
70+
tenancy: { enabled: false },
71+
fields: {
72+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
73+
name: { name: 'name', label: 'Name', type: 'text' as const },
74+
},
75+
} as any;
76+
77+
const CONTACT = {
78+
name: 'rts_contact',
79+
label: 'Contact',
80+
fields: {
81+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
82+
title: { name: 'title', label: 'Title', type: 'text' as const },
83+
account: { name: 'account', label: 'Account', type: 'lookup' as const, reference: 'rts_account' },
84+
catalog: { name: 'catalog', label: 'Catalog', type: 'lookup' as const, reference: 'rts_catalog' },
85+
},
86+
} as any;
87+
88+
interface ObservedCall {
89+
object: string;
90+
method: string;
91+
options: Record<string, any> | undefined;
92+
}
93+
94+
/** `SqlDriver.applyTenantScope`'s predicate, over one row. */
95+
function inTenantScope(row: Record<string, unknown>, options: any): boolean {
96+
const tenantId = options?.tenantId;
97+
if (tenantId === undefined || tenantId === null || tenantId === '') return true;
98+
const own = row.organization_id ?? null;
99+
if (own === null) return true;
100+
const union = Array.isArray(options?.tenantIds)
101+
? options.tenantIds.filter((v: unknown) => typeof v === 'string' && v !== '')
102+
: [];
103+
if (union.length > 0) return union.includes(String(own));
104+
return String(own) === String(tenantId);
105+
}
106+
107+
function matchesValue(actual: unknown, cond: unknown): boolean {
108+
if (cond && typeof cond === 'object' && !Array.isArray(cond)) {
109+
const c = cond as Record<string, unknown>;
110+
if ('$eq' in c) return (actual ?? null) === (c.$eq ?? null);
111+
if ('$in' in c) return Array.isArray(c.$in) && c.$in.includes(actual);
112+
throw new Error(`double does not understand the operator in ${JSON.stringify(cond)}`);
113+
}
114+
return (actual ?? null) === (cond ?? null);
115+
}
116+
117+
function matches(row: Record<string, unknown>, where: any): boolean {
118+
if (!where || typeof where !== 'object') return true;
119+
for (const [k, v] of Object.entries(where)) {
120+
if (k === '$and') { if (!(v as any[]).every((w) => matches(row, w))) return false; continue; }
121+
if (k === '$or') { if (!(v as any[]).some((w) => matches(row, w))) return false; continue; }
122+
if (k.startsWith('$')) throw new Error(`double does not understand the operator ${k}`);
123+
if (!matchesValue(row[k], v)) return false;
124+
}
125+
return true;
126+
}
127+
128+
function makeTenantScopedDriver(observed: ObservedCall[]) {
129+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
130+
const storeFor = (obj: string) => {
131+
let s = stores.get(obj);
132+
if (!s) { s = new Map(); stores.set(obj, s); }
133+
return s;
134+
};
135+
const record = (object: string, method: string, options: any) => observed.push({ object, method, options });
136+
let nextId = 0;
137+
const driver: any = {
138+
name: 'memory', version: '0.0.0', supports: {} as any,
139+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
140+
async execute() { return null; },
141+
async find(object: string, ast: any, options: any) {
142+
record(object, 'find', options);
143+
const rows = Array.from(storeFor(object).values())
144+
.filter((r) => inTenantScope(r, options) && matches(r, ast?.where));
145+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
146+
},
147+
async findOne(object: string, ast: any, options: any) {
148+
record(object, 'findOne', options);
149+
for (const r of storeFor(object).values()) {
150+
if (inTenantScope(r, options) && matches(r, ast?.where)) return r;
151+
}
152+
return null;
153+
},
154+
async count(object: string, ast: any, options: any) {
155+
return (await this.find(object, ast, options)).length;
156+
},
157+
async create(object: string, data: Record<string, unknown>, options: any) {
158+
record(object, 'create', options);
159+
nextId += 1;
160+
const id = (data.id as string) ?? `r_${nextId}`;
161+
// `injectTenantOnInsert`: the active organization is the write target.
162+
const tenant = options?.tenantId;
163+
const row = {
164+
...data,
165+
...(data.organization_id === undefined && tenant ? { organization_id: tenant } : {}),
166+
id,
167+
};
168+
storeFor(object).set(id, row);
169+
return row;
170+
},
171+
async update(object: string, id: string, data: Record<string, unknown>, options: any) {
172+
record(object, 'update', options);
173+
const s = storeFor(object);
174+
const cur = s.get(id);
175+
if (!cur || !inTenantScope(cur, options)) return null;
176+
const next = { ...cur, ...data, id };
177+
s.set(id, next);
178+
return next;
179+
},
180+
async updateMany(object: string, ast: any, data: Record<string, unknown>, options: any) {
181+
const rows = await this.find(object, ast, options);
182+
for (const r of rows) storeFor(object).set(r.id as string, { ...r, ...data, id: r.id });
183+
return rows.length;
184+
},
185+
async bulkCreate(object: string, rows: Record<string, unknown>[], options: any) {
186+
return Promise.all(rows.map((r) => this.create(object, r, options)));
187+
},
188+
async bulkUpdate() { return []; },
189+
async bulkDelete() {},
190+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
191+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
192+
async commit() {}, async rollback() {},
193+
};
194+
return { driver, stores, storeFor };
195+
}
196+
197+
/** The refusal as every HTTP door reads it (ADR-0112), plus its field codes. */
198+
function envelopeOf(err: unknown) {
199+
const thrown = resolveThrownHttpError(err);
200+
const fields = ((err as any)?.fields ?? []).map((f: any) => ({ field: f.field, code: f.code }));
201+
return { status: thrown.status, code: thrown.code, fields };
202+
}
203+
204+
async function refusalOf(run: () => Promise<unknown>): Promise<unknown> {
205+
try {
206+
await run();
207+
} catch (e) {
208+
return e;
209+
}
210+
throw new Error('expected the write to be refused, but it succeeded');
211+
}
212+
213+
type Door = 'insert' | 'update by id' | 'bulk update';
214+
const DOORS: Door[] = ['insert', 'update by id', 'bulk update'];
215+
216+
describe('[#19808] the lookup existence probe is scoped to the caller\'s organization', () => {
217+
let engine: ObjectQL;
218+
let observed: ObservedCall[];
219+
let storeFor: (obj: string) => Map<string, Record<string, unknown>>;
220+
221+
beforeEach(async () => {
222+
observed = [];
223+
engine = new ObjectQL();
224+
const stub = makeTenantScopedDriver(observed);
225+
storeFor = stub.storeFor;
226+
engine.registerDriver(stub.driver, true);
227+
await engine.init();
228+
for (const o of [ACCOUNT, CATALOG, CONTACT]) engine.registry.registerObject(o, PACKAGE_ID);
229+
// Seeded straight into the store, the way rows already in a database are.
230+
storeFor('rts_account').set('acc_x', { id: 'acc_x', name: 'X', organization_id: ORG_X });
231+
storeFor('rts_account').set('acc_y', { id: 'acc_y', name: 'Y', organization_id: ORG_Y });
232+
// Stamped with ANOTHER organization on purpose: this double scopes every
233+
// row it is asked to, so the only way this row resolves for an org-X
234+
// caller is the engine withholding `tenantId` for a tenancy-disabled
235+
// object. An org-less row would resolve either way and light nothing.
236+
storeFor('rts_catalog').set('cat_1', { id: 'cat_1', name: 'Global', organization_id: ORG_Y });
237+
// The row the two update doors repoint.
238+
storeFor('rts_contact').set('ct_x', { id: 'ct_x', title: 'mine', account: 'acc_x', organization_id: ORG_X });
239+
});
240+
241+
const write = (door: Door, account: string, context: ExecutionContext = MEMBER_X) => {
242+
if (door === 'insert') {
243+
return engine.insert('rts_contact', { id: 'ct_new', title: 'new', account }, { context } as any);
244+
}
245+
if (door === 'update by id') {
246+
return engine.update('rts_contact', { account }, { where: { id: 'ct_x' }, context } as any);
247+
}
248+
return engine.update('rts_contact', { account }, { where: { title: 'mine' }, multi: true, context } as any);
249+
};
250+
251+
/** What the store holds for the row the door writes — `undefined` when absent. */
252+
const stored = (door: Door) => storeFor('rts_contact').get(door === 'insert' ? 'ct_new' : 'ct_x');
253+
254+
it('premise: the org-X caller cannot read the org-Y row directly', async () => {
255+
const rows = await engine.find('rts_account', { where: { id: 'acc_y' }, context: MEMBER_X });
256+
expect(rows).toEqual([]);
257+
});
258+
259+
it.each(DOORS)('%s — a reference to a row that exists only in ANOTHER organization is refused', async (door) => {
260+
const err = await refusalOf(() => write(door, 'acc_y'));
261+
262+
expect(err).toBeInstanceOf(ValidationError);
263+
expect(envelopeOf(err)).toEqual({
264+
status: 400,
265+
code: 'VALIDATION_FAILED',
266+
fields: [{ field: 'account', code: 'reference_not_found' }],
267+
});
268+
// Nothing landed: no new row, and a repoint left the stored value alone.
269+
if (door === 'insert') expect(stored(door)).toBeUndefined();
270+
else expect(stored(door)?.account).toBe('acc_x');
271+
});
272+
273+
it.each(DOORS)('%s — "exists only in another organization" and "exists nowhere" are indistinguishable', async (door) => {
274+
const elsewhere = await refusalOf(() => write(door, 'acc_y'));
275+
const nowhere = await refusalOf(() => write(door, 'acc_nowhere'));
276+
277+
expect(envelopeOf(elsewhere)).toEqual(envelopeOf(nowhere));
278+
// The same sentence, too, once the caller's own id is taken out of it —
279+
// the only difference left between the two answers is what the caller sent.
280+
const shape = (err: unknown, id: string) => String((err as Error).message).split(id).join('ID');
281+
expect(shape(elsewhere, 'acc_y')).toBe(shape(nowhere, 'acc_nowhere'));
282+
});
283+
284+
it.each(DOORS)('%s — lit control: a reference inside the caller\'s own organization still commits', async (door) => {
285+
if (door === 'insert') {
286+
await write(door, 'acc_x');
287+
expect(stored(door)?.account).toBe('acc_x');
288+
return;
289+
}
290+
// Repoint to a second org-X account, so a no-op cannot pass for a commit.
291+
storeFor('rts_account').set('acc_x2', { id: 'acc_x2', name: 'X2', organization_id: ORG_X });
292+
await write(door, 'acc_x2');
293+
expect(stored(door)?.account).toBe('acc_x2');
294+
});
295+
296+
it('the probe runs ELEVATED and TENANT-SCOPED — the two halves of `{ ...context, isSystem: true }`', async () => {
297+
const probes: ExecutionContext[] = [];
298+
engine.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
299+
if (opCtx.operation === 'findOne' && opCtx.object === 'rts_account') probes.push(opCtx.context);
300+
await next();
301+
});
302+
observed.length = 0;
303+
304+
await write('insert', 'acc_x');
305+
306+
// `isSystem` is what the security middleware's total bypass keys on — the
307+
// #4441 reason for elevating (a link to a row the caller cannot READ).
308+
expect(probes).toHaveLength(1);
309+
expect(probes[0]).toMatchObject({ isSystem: true, tenantId: ORG_X, userId: 'u_x' });
310+
// …and the tenant reaches the driver: the seam `applyTenantScope` keys off.
311+
const driverProbe = observed.filter((c) => c.object === 'rts_account' && c.method === 'findOne');
312+
expect(driverProbe).toHaveLength(1);
313+
expect(driverProbe[0].options?.tenantId).toBe(ORG_X);
314+
});
315+
316+
it('lit control: a reference to a tenancy-disabled (platform-global) object still resolves from an org-bound caller', async () => {
317+
observed.length = 0;
318+
319+
const row: any = await engine.insert('rts_contact', { id: 'ct_cat', title: 'c', catalog: 'cat_1' }, { context: MEMBER_X } as any);
320+
321+
expect(row.catalog).toBe('cat_1');
322+
// Resolved because the engine withheld the tenant for this object
323+
// (`buildDriverOptions`, ADR-0066) — not because the double let it through.
324+
const catalogProbe = observed.filter((c) => c.object === 'rts_catalog' && c.method === 'findOne');
325+
expect(catalogProbe).toHaveLength(1);
326+
expect(catalogProbe[0].options?.tenantId).toBeUndefined();
327+
});
328+
329+
it('lit control: an `isSystem` write stays unchecked — the method\'s early return, and no probe runs', async () => {
330+
observed.length = 0;
331+
const SYSTEM_X = { isSystem: true, tenantId: ORG_X } as ExecutionContext;
332+
333+
await engine.insert('rts_contact', { id: 'ct_sys_1', title: 's', account: 'acc_y' }, { context: SYSTEM_X } as any);
334+
await engine.insert('rts_contact', { id: 'ct_sys_2', title: 's', account: 'acc_nowhere' }, { context: SYSTEM_X } as any);
335+
336+
expect(storeFor('rts_contact').get('ct_sys_1')?.account).toBe('acc_y');
337+
expect(storeFor('rts_contact').get('ct_sys_2')?.account).toBe('acc_nowhere');
338+
expect(observed.filter((c) => c.object === 'rts_account')).toEqual([]);
339+
});
340+
341+
it('the `group` posture: the probe reaches the caller\'s whole membership set, as its reads do', async () => {
342+
// The spread carries `accessible_org_ids`, which `buildDriverOptions`
343+
// widens into `tenantIds` under `group` (ADR-0105 D2). A hand-picked
344+
// `{ isSystem, tenantId }` would drop it and refuse a reference the
345+
// caller can legitimately read.
346+
engine.setTenancyPostureProvider(() => 'group');
347+
const GROUP_MEMBER = { ...MEMBER_X, accessible_org_ids: [ORG_X, ORG_Y] } as unknown as ExecutionContext;
348+
349+
const readable = await engine.find('rts_account', { where: { id: 'acc_y' }, context: GROUP_MEMBER });
350+
expect(readable.map((r: any) => r.id)).toEqual(['acc_y']);
351+
352+
await engine.insert('rts_contact', { id: 'ct_grp', title: 'g', account: 'acc_y' }, { context: GROUP_MEMBER } as any);
353+
expect(storeFor('rts_contact').get('ct_grp')?.account).toBe('acc_y');
354+
});
355+
356+
it('the dangling-reference audit keeps its unscoped probe — a stored cross-organization reference is NOT reported', async () => {
357+
// Pinned so the audit's semantics move only by decision. The audit has no
358+
// caller: it probes under the bare `{ isSystem: true }` it always used, so
359+
// a row holding another organization's id reads as resolving. Whether the
360+
// audit should probe under each row's OWN organization is an open
361+
// question on the card, not something this fix decided.
362+
storeFor('rts_contact').set('ct_cross', { id: 'ct_cross', title: 'legacy', account: 'acc_y', organization_id: ORG_X });
363+
364+
const out = await engine.inspectDanglingReferences({ objects: ['rts_contact'] });
365+
366+
expect(out.undetermined).toBe(0);
367+
expect(out.dangling).toEqual([]);
368+
});
369+
});

0 commit comments

Comments
 (0)