Skip to content

Commit 98c3d00

Browse files
fix(plugin-security): a platform operator's insert with no active organization gets the ADR-0123 D2 403, not a 500 (#24868)
Fixes #24821 Clause-②: no ## What changed A platform operator (the `PLATFORM_ADMIN` rung, `admin_full_access`) whose session has no active organization sent a create on a tenant object whose posture lets the operator cross the organization wall (`access.default: 'private'`, ADR-0095 D3). Under `group` and `isolated` it got `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. It now gets the answer a member gets in the same state: `403 PERMISSION_DENIED`, with a message that names the missing active organization (ADR-0123 D2/D4). The fix is in `plugin-security`'s Layer 0 write wall, `security-plugin.ts` step 3.7, and nowhere else: - **Why the operator reached the engine.** Crossing makes the operator's own Layer 0 `null` on such an object. So neither the D2 pre-test (`organizationWallRefusal`) nor the union-scope insert arm from #24784 reached it. The row then met the engine's organization check (`resolveSystemInsertOrganization`, ADR-0131 D9) as an organization-less system write, which refuses with a fixed 500 and keeps the prose off the wire. - **The new arm.** An insert from a caller that crosses the wall and has no `tenantId` is judged on its stored image, in the same `postHookWriteImageCheck` seam and with the same D2 throw (`refuseNoActiveOrganization`) as the #24784 arm. The seam runs after the `beforeInsert` chain and before the engine's organization check. One row with no organization refuses the whole write. - **The crossing fact.** `computeLayeredRlsFilter` now also returns `crossedByPlatformAdmin`. It asks the one Layer 0 ladder (`computeTenantLayer0Verdict`) a second time with only `isPlatformAdmin` withheld: the verdict is `none`, and it would not be `none` without the exemption. No rung of the ladder (posture, tenant object, phantom anchor, tenancy opt-out) is re-derived. `computeWriteTenantWall` hands step 3.7 the filter and the fact from one computation. - **Not touched.** `organizationWallRefusal`, `callerHasOrganizationScope`, the union-scope arm, the forge guard, `tenant-layer.ts`, and everything in `packages/objectql`. No new error code. The engine's 500 still answers system contexts, which are what it was written for. ## Why this answer (governing text) - **ADR-0131 D9** decides the outcome: "a write on a tenant-column object with no organization is derived under `single` … and refused otherwise, with a message naming what is missing (ADR-0123 D4). ⛔ Nothing defaults to any owner other than the derivable one." No governing text lands an operator's organization-less insert anywhere. I searched ADR-0123, ADR-0131 (D5, D9, D12 item 12) and the security plugin's docblocks. So the insert was already refused; this changes the answer, not the outcome. - **The answer.** The condition is the member's: no active organization, and a new row with nowhere to land. One condition gets one spelling (ADR-0112 D3), the D2 403. The 500 also failed D9's own "message naming what is missing" for an HTTP caller, because a 5xx withholds its prose. The `[#12974]` note in `computeLayeredRlsFilter` already records an org-less write by the platform owner as "REFUSED 403 … (500 where the 403 refusal belongs)". - **The fork, reported, not resolved by code.** ADR-0123 D2's scope paragraph says the refusal fires for a caller "who is not a platform operator crossing the wall by ADR-0095 D3", and its Consequences list "Platform operators" under "What does not change". That text predates ADR-0131 D9, which refuses this write anyway. The runtime is now wider than that sentence, in the same way #24784's arm made it wider than D2's "empty membership set" sentence. #24823 is the open card for that amendment note. This PR changes no ADR text (`docs/adr/**` is Tier H). ## Measured (real stack: ObjectQL + SqlDriver + SecurityPlugin) Before, at `4be728131` (the pin alone, committed as `743cacb33` and unchanged since): `Tests 4 failed | 12 passed (16)`. The four failures, under both `group` and `isolated`, were the single and bulk operator inserts: `expected 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED' to be 'PERMISSION_DENIED'`. Every control was already green. After: the operator's insert gets exactly the member's outcome on the same object (`code`, `status` and message compared whole), and nothing is stored. These are unchanged, each pinned per posture: - the operator WITH an active organization is admitted, and the row lands there; - a row a `beforeInsert` hook places in an organization is admitted (the stored image is judged, not the session); - the operator's update of an existing row is admitted, and the row keeps its organization; - the operator's insert into an object that opted out of tenancy is admitted; - the operator's read spans every organization; - a system context with no organization still gets the engine's own `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. Also measured at base (scratch file, not committed): a payload that names `organization_id` is stripped by the engine for a session on a business object, so the operator's org-less insert naming one was a 500 as well. It is now the same 403. ## Tests - New `no-active-organization-insert-platform-operator.test.ts`: the real-stack pin above, one block per posture, with the member's D2 403 as the control. - `no-active-organization-write-refusal.test.ts`: its control "a true PLATFORM operator crosses the wall on a posture-permitting object" pinned exactly the changed branch, an org-less insert passing the middleware. In the real stack that insert answered 500. It is re-judged as follows: - the crossing is now pinned on what it really admits, the operator's UPDATE with no active organization; - the operator's INSERT with no active organization is refused with the conforming D2 refusal; - an insert of a row that names its organization lands, and so does an insert WITH an active organization; - the business-object sibling now refuses both the insert and the update, so the posture-scoping of the exemption stays measured. - Final head `d366c1712` (the merge of `origin/main` `abd5acdaf`), one locked run: closure build OK; `pnpm --filter @objectstack/plugin-security test`: `Test Files 196 passed (196)`, `Tests 4066 passed | 45 skipped (4111)`; `typecheck` exit 0 (`check:test-typecheck: OK … 0 error(s)`). `tsc -p tsconfig.test.json --listFiles` compiles both edited test files (196 test files in all). ### Ablations (each one committed first, mutated through `scripts/ablation-replace.mjs` in a script with a restore trap, and restored to the HEAD blob with `git diff HEAD` empty) No build or `dist/` leg was needed: the pins import `./security-plugin.js` relatively, and objectql and driver-sql are source aliases in this package's vitest config. 1. **The arm off.** Anchor `callerCrossesTenantWall &&` was replaced by `/* ABLATION-24821 */ false &&` (anchor x1 to x0, marker x0 to x1, blob `25d6a049` to `9786d9c1`). Result: `Tests 5 failed | 34 passed (39)`, in the predicted direction (red). The four real-stack refusals failed with `Received: "ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED"`, and the unit refusal failed with `the write was not refused at all`. Every control stayed green. 2. **The crossing discriminator off** (any `none` verdict for an operator counts as a crossing). The second-ladder comparison was replaced by `/* ABLATION-24821-B */ true;` (blob `25d6a049` to `f9b75967`). Result: `Tests 2 failed | 37 passed (39)`, in the predicted direction: the opted-out-of-tenancy control under both postures (`expected the write to be admitted`). ## Gates (local, head `d366c1712`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 69 commands from the 4 changed paths against merge base `abd5acdaf`. All 69 exit 0, each code captured before any pipe. `check:dual-build-cjs-loads` and `check:i18n` first answered exit 3 `PREREQUISITE NOT MET`, which is not a measurement. After a workspace build (`turbo run build --filter=!@objectstack/docs`, 76/76) both measured exit 0. `--ran`: `69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN`. The repo-wide runs (`pnpm lint`, `test:repo`) are CI's. ## Acceptance notes (observed, not fixed here) 1. **ADR-0123 D2's scope sentence now under-describes the runtime.** It still says a platform operator crossing the wall is outside the refusal. Carrier: #24823 (its amendment note can name this arm next to the union-scope one). See the open question in the report. 2. **A platform operator's cross-organization by-id update on a posture-permitting object answers 404, while the same caller reads the row.** Measured in-process at `4be728131` on `qa_vault` (private), row in `org_b`: - `group` with no active organization: `404 RECORD_NOT_FOUND`; - `group` with active `org_a`: `404`; - `isolated` with active `org_a`: `404`; - `isolated` with no active organization: `200`. `findOne` returns the row in all four. This looks like the engine's by-id write scope being computed over its own facts (no platform-admin fact), against ADR-0131 D8's one predicate. Reach was not measured at a public door. Carrier: none. 3. **The engine's organization check has no `isSystem` test.** Any non-system caller that reaches it without an organization still gets the system-write 500. After #24784 and this PR the wall covers members and crossing operators on every object the wall itself recognises as a tenant object. Populations it may not cover, all unmeasured: - an object whose declared `tenancy.tenantField` is not `organization_id`; - `systemFields.tenant: false` together with an explicit `organization_id` column; - a federated object that declares a real remote `organization_id`; - a composition with no security plugin. This is the card's second candidate, which is `domain:engine`'s. Carrier: none. 4. **`canWriteObject`'s create preview.** It is row-less, so for this operator it still answers `true` (as it did while the write answered 500). The same boundary #24784 recorded. --- _Generated by [Claude Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c2d4072 commit 98c3d00

4 files changed

Lines changed: 478 additions & 24 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a platform operator's tenant-scoped insert from a session with no active organization is refused 403 naming the missing organization, as a member's is, instead of a 500
6+
7+
Clause-②: no (no accept set moves: the insert was refused before and is refused now, with the ADR-0123 D2 answer in place of an internal error)
8+
9+
- **Before.** Under `group` or `isolated`, a platform operator (`admin_full_access`) whose session had no active organization got `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` for a create on a tenant object whose posture lets it cross the organization wall (`access.default: 'private'`). The operator crosses the wall there, so the wall let the insert through, and the engine then refused the row as a system write with no organization.
10+
- **After.** That create gets `403 PERMISSION_DENIED` with the message a member gets in the same state. The message names the missing active organization and tells the user to join or select one.
11+
- **Unchanged.** Nothing was written before and nothing is written now. The refusal judges the row as it is about to be stored, the same row the engine's own organization check reads, so it covers exactly the inserts that answered 500 and no insert that was admitted. These are as before:
12+
- a row a `beforeInsert` hook places in an organization;
13+
- the operator's reads and updates across organizations;
14+
- inserts from an operator with an active organization;
15+
- inserts into objects that opted out of tenancy;
16+
- system contexts (which still meet the engine's own refusal);
17+
- every `single` answer.
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0123 D2/D4 · ADR-0131 D9 · ADR-0095 D3] A PLATFORM OPERATOR whose
5+
* session has no active organization, inserting into a tenant object whose
6+
* posture lets it cross the wall, gets the same refusal a member gets —
7+
* `403 PERMISSION_DENIED` naming the missing active organization — under
8+
* every walled posture. Measured on the real stack (ObjectQL + SqlDriver +
9+
* SecurityPlugin), never a 500 and never a row.
10+
*
11+
* ## The rule
12+
*
13+
* A true `PLATFORM_ADMIN` crosses the Layer 0 wall on a posture-permitting
14+
* object (`access.default: 'private'`, ADR-0095 D3), so the wall's own
15+
* no-active-organization refusal does not reach it. Crossing lets the
16+
* operator READ every organization's rows and UPDATE them where they are;
17+
* it does not give a NEW row somewhere to land. A session's new row lands
18+
* in its active organization — the engine stamps it from `tenantId` and
19+
* strips the column from a session payload — and ADR-0131 D9 refuses an
20+
* organization-less write under a walled posture ("nothing defaults to any
21+
* owner other than the derivable one"). So the insert was refused, but by
22+
* the engine, as an organization-less SYSTEM write:
23+
* `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` with status 500 and its prose
24+
* withheld from the wire. The wall now judges the stored image of that
25+
* insert and gives it the member's answer, before the engine is reached.
26+
*
27+
* ## What this file pins, per walled posture
28+
*
29+
* - the operator's insert is refused with exactly the member's D2 answer
30+
* (the member's refusal on the same object is the control), single and
31+
* bulk, and nothing is stored;
32+
* - the unchanged neighbours: the operator WITH an active organization is
33+
* admitted and the row lands there; a row a `beforeInsert` hook places in
34+
* an organization is admitted (the stored image is judged, not the
35+
* payload); the operator's update of an existing row is admitted and the
36+
* row keeps its organization; its insert into an object
37+
* that opted out of tenancy is admitted; its read spans every
38+
* organization; and a SYSTEM context with no organization still meets the
39+
* engine's own refusal, untouched.
40+
*/
41+
42+
import { describe, it, expect, afterEach, vi } from 'vitest';
43+
import { ObjectQL } from '@objectstack/objectql';
44+
import { SqlDriver } from '@objectstack/driver-sql';
45+
import type { PermissionSet } from '@objectstack/spec/security';
46+
import { SecurityPlugin } from './security-plugin.js';
47+
48+
const OWN_ORG = 'org_a';
49+
const OTHER_ORG = 'org_b';
50+
51+
const FIELDS = {
52+
id: { name: 'id', type: 'text', primaryKey: true },
53+
name: { name: 'name', type: 'text' },
54+
// An app hook derives the row's organization from this field (below).
55+
target_org: { name: 'target_org', type: 'text' },
56+
};
57+
58+
const OBJECTS = [
59+
// A PRIVATE tenant object: the platform injects `organization_id`, and the
60+
// posture permits a platform operator to cross the wall (ADR-0095 D3).
61+
{ name: 'qa_vault', label: 'Vault', access: { default: 'private' }, fields: FIELDS },
62+
// An object that opted out of tenancy: no organization column, no wall.
63+
{ name: 'qa_global', label: 'Global', systemFields: { tenant: false }, fields: FIELDS },
64+
];
65+
66+
/**
67+
* Plain CRUD and no row-level policy — Layer 0 is the only enforcer under test.
68+
* The wildcard does not cover a `private` object (ADR-0066 D2), so the member
69+
* holds an explicit grant on it: the member's control then meets the wall on
70+
* the very object the operator writes, not an object-permission denial.
71+
*/
72+
const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true };
73+
const MEMBER: PermissionSet = {
74+
name: 'member_default',
75+
label: 'Member',
76+
objects: { '*': CRUD, qa_vault: CRUD },
77+
} as unknown as PermissionSet;
78+
79+
/** A platform operator: the superuser bit AND a platform-exclusive capability (ADR-0095 D3). */
80+
const PLATFORM_ADMIN: PermissionSet = {
81+
name: 'admin_full_access',
82+
label: 'Platform Administrator',
83+
objects: {
84+
'*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true },
85+
},
86+
systemPermissions: ['manage_platform_settings', 'manage_metadata'],
87+
} as unknown as PermissionSet;
88+
89+
const SEED_CTX = { isSystem: true, userId: 'usr_system', tenantId: OWN_ORG };
90+
91+
/**
92+
* The subject: a platform operator whose session has no active organization.
93+
* ONE context for every posture; it holds a membership, so under `group` it
94+
* is not the empty-set case either.
95+
*/
96+
const OPERATOR_NO_ORG = {
97+
userId: 'usr_admin',
98+
positions: [],
99+
permissions: ['admin_full_access'],
100+
posture: 'PLATFORM_ADMIN',
101+
accessible_org_ids: [OWN_ORG],
102+
};
103+
/** The same operator one `set-active` later — the single-fact control. */
104+
const OPERATOR_ACTIVE = { ...OPERATOR_NO_ORG, tenantId: OWN_ORG };
105+
/** The member in the same state: the D2 answer this file holds the operator to. */
106+
const MEMBER_NO_ORG = {
107+
userId: 'usr_a',
108+
positions: [],
109+
permissions: [],
110+
posture: 'MEMBER',
111+
accessible_org_ids: [OWN_ORG],
112+
};
113+
114+
type Posture = 'isolated' | 'group';
115+
116+
const engines: ObjectQL[] = [];
117+
afterEach(async () => {
118+
while (engines.length) {
119+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
120+
}
121+
});
122+
123+
interface Booted {
124+
engine: ObjectQL;
125+
/** A table's rows read straight off the driver, past every scope. */
126+
table: (name: string) => Promise<Array<Record<string, unknown>>>;
127+
}
128+
129+
const SEEDED = [
130+
{ id: 'r1', name: 'own', organization_id: OWN_ORG },
131+
{ id: 'r2', name: 'other', organization_id: OTHER_ORG },
132+
];
133+
134+
async function boot(posture: Posture): Promise<Booted> {
135+
const engine = new ObjectQL();
136+
engine.registerDriver(
137+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never) as never,
138+
true,
139+
);
140+
await engine.init();
141+
engine.registerApp({
142+
id: 'com.objectstack.qa.no-active-organization-insert-platform-operator',
143+
name: 'No active organization, platform operator',
144+
version: '1.0.0',
145+
type: 'plugin',
146+
scope: 'system',
147+
objects: OBJECTS,
148+
} as never);
149+
await engine.syncSchemas();
150+
engines.push(engine);
151+
152+
// The app's hook: it places a row in the organization `target_org` names.
153+
engine.on('beforeInsert', 'qa_vault', (async (ctx: { input: { data: Record<string, unknown> } }) => {
154+
const target = ctx.input.data.target_org;
155+
if (typeof target === 'string' && target !== '') ctx.input.data.organization_id = target;
156+
}) as never);
157+
158+
const services: Record<string, unknown> = {
159+
manifest: { register: vi.fn() },
160+
objectql: engine,
161+
metadata: {
162+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
163+
list: async () => [MEMBER, PLATFORM_ADMIN],
164+
},
165+
'org-scoping': { name: 'com.objectstack.org-scoping' },
166+
tenancy: { posture },
167+
};
168+
const ctx = {
169+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
170+
registerService: vi.fn(),
171+
getService: (name: string) => {
172+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
173+
return services[name];
174+
},
175+
};
176+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
177+
await plugin.init(ctx as never);
178+
await plugin.start(ctx as never);
179+
// The expected refusals log through the engine's own logger.
180+
const engineLogger = (engine as unknown as { logger: { warn: () => void; error: () => void } }).logger;
181+
vi.spyOn(engineLogger, 'warn').mockImplementation(() => undefined);
182+
vi.spyOn(engineLogger, 'error').mockImplementation(() => undefined);
183+
184+
await engine.insert('qa_vault', SEEDED.map((r) => ({ ...r })), { context: SEED_CTX } as never);
185+
186+
const table = async (name: string) => {
187+
const driver = (engine as unknown as { getDriver(o: string): { knex: unknown } }).getDriver(name);
188+
const knex = driver.knex as (t: string) => { select: (...c: string[]) => Promise<Array<Record<string, unknown>>> };
189+
const columns = name === 'qa_global' ? ['id', 'name'] : ['id', 'name', 'organization_id'];
190+
const rows = await knex(name).select(...columns);
191+
return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id)));
192+
};
193+
return { engine, table };
194+
}
195+
196+
interface Outcome { ok: boolean; code?: string; status?: number; message?: string }
197+
198+
const attempt = async (run: () => Promise<unknown>): Promise<Outcome> => {
199+
try {
200+
await run();
201+
return { ok: true };
202+
} catch (e) {
203+
const err = e as { code?: string; statusCode?: number; status?: number; message?: string };
204+
return { ok: false, code: err.code, status: err.statusCode ?? err.status, message: String(err.message ?? e) };
205+
}
206+
};
207+
208+
const insert = (b: Booted, object: string, data: unknown, context: object) =>
209+
b.engine.insert(object, data as never, { context } as never);
210+
211+
/** The ADR-0123 D2 answer on the ADR-0112 envelope: `code` + `status`, then the D4 naming. */
212+
const expectNoActiveOrganizationRefusal = (outcome: Outcome) => {
213+
expect(outcome.ok, 'expected a refusal, got a completed write').toBe(false);
214+
expect(outcome.code, 'ADR-0112 error code').toBe('PERMISSION_DENIED');
215+
expect(outcome.status, 'ADR-0112 HTTP status').toBe(403);
216+
// ADR-0123 D4: the refusal names what is missing.
217+
expect(outcome.message).toMatch(/no active organization/i);
218+
};
219+
220+
const expectAdmitted = (outcome: Outcome) =>
221+
expect(outcome, 'expected the write to be admitted').toEqual({ ok: true });
222+
223+
for (const posture of ['group', 'isolated'] as const) {
224+
describe(`[ADR-0123 D2 · ADR-0131 D9] a platform operator's insert with no active organization — ${posture}, real stack`, () => {
225+
it("is refused with exactly the member's D2 answer on the same object, and stores nothing", async () => {
226+
const b = await boot(posture);
227+
228+
const operator = await attempt(() => insert(b, 'qa_vault', { id: 'n1', name: 'new' }, OPERATOR_NO_ORG));
229+
const member = await attempt(() => insert(b, 'qa_vault', { id: 'n1', name: 'new' }, MEMBER_NO_ORG));
230+
231+
// The control first: the member's answer IS the D2 refusal.
232+
expectNoActiveOrganizationRefusal(member);
233+
expectNoActiveOrganizationRefusal(operator);
234+
expect(operator, "the operator gets exactly the member's answer").toEqual(member);
235+
expect(await b.table('qa_vault'), 'no row stored, NULL-organization or otherwise').toEqual(SEEDED);
236+
});
237+
238+
it('a bulk insert is refused as a whole, and stores nothing', async () => {
239+
const b = await boot(posture);
240+
241+
const outcome = await attempt(() =>
242+
insert(b, 'qa_vault', [{ id: 'n1', name: 'a' }, { id: 'n2', name: 'b' }], OPERATOR_NO_ORG),
243+
);
244+
245+
expectNoActiveOrganizationRefusal(outcome);
246+
expect(await b.table('qa_vault')).toEqual(SEEDED);
247+
});
248+
249+
it('control: the same operator WITH an active organization is admitted, and the row lands in it', async () => {
250+
const b = await boot(posture);
251+
252+
expectAdmitted(await attempt(() => insert(b, 'qa_vault', { id: 'n1', name: 'new' }, OPERATOR_ACTIVE)));
253+
expect(await b.table('qa_vault')).toEqual([{ id: 'n1', name: 'new', organization_id: OWN_ORG }, ...SEEDED]);
254+
});
255+
256+
it('control: a row a beforeInsert hook places in an organization is admitted — the stored image is judged', async () => {
257+
const b = await boot(posture);
258+
259+
expectAdmitted(
260+
await attempt(() => insert(b, 'qa_vault', { id: 'n1', name: 'new', target_org: OTHER_ORG }, OPERATOR_NO_ORG)),
261+
);
262+
expect(await b.table('qa_vault')).toEqual([{ id: 'n1', name: 'new', organization_id: OTHER_ORG }, ...SEEDED]);
263+
});
264+
265+
it("control: the operator's update of an existing row is admitted, and the row keeps its organization", async () => {
266+
const b = await boot(posture);
267+
268+
expectAdmitted(
269+
await attempt(() => b.engine.update('qa_vault', { id: 'r1', name: 'renamed' } as never, { context: OPERATOR_NO_ORG } as never)),
270+
);
271+
expect(await b.table('qa_vault')).toEqual([{ id: 'r1', name: 'renamed', organization_id: OWN_ORG }, SEEDED[1]]);
272+
});
273+
274+
it('control: an insert into an object that opted out of tenancy is admitted', async () => {
275+
const b = await boot(posture);
276+
277+
expectAdmitted(await attempt(() => insert(b, 'qa_global', { id: 'g1', name: 'global' }, OPERATOR_NO_ORG)));
278+
expect(await b.table('qa_global')).toEqual([{ id: 'g1', name: 'global' }]);
279+
});
280+
281+
it("control: the operator's read still spans every organization", async () => {
282+
const b = await boot(posture);
283+
284+
const rows = (await b.engine.find('qa_vault', { context: OPERATOR_NO_ORG } as never)) as Array<Record<string, unknown>>;
285+
286+
expect(rows.map((r) => r.id).sort()).toEqual(['r1', 'r2']);
287+
});
288+
289+
it("control: a SYSTEM context with no organization still meets the engine's own refusal, untouched", async () => {
290+
const b = await boot(posture);
291+
292+
const outcome = await attempt(() => insert(b, 'qa_vault', { id: 'n1', name: 'new' }, { isSystem: true }));
293+
294+
expect(outcome.ok).toBe(false);
295+
expect(outcome.code).toBe('ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED');
296+
expect(outcome.status).toBe(500);
297+
expect(await b.table('qa_vault')).toEqual(SEEDED);
298+
});
299+
});
300+
}

0 commit comments

Comments
 (0)