Skip to content

Commit 2e242bb

Browse files
fix(plugin-security)!: a delegated operation runs only in an organization the delegator's membership backs (#25101)
Fixes #25063 Clause-②: no (narrowing) — on a walled posture, an operation of an on-behalf-of context whose active organization the delegator's membership does not back is refused `403 PERMISSION_DENIED`, where it used to run. Seat call of record: `6312131621` on the card (option P, narrowing confirmed). The claim `6311248489` was widened in place to the prologue and the three pins' files. ⛔ Disclosure (#9387 ruling `5323310017`): this body states the rule and an abstract reach only. ## The rule ADR-0090 D10 bounds a delegated operation by the delegator's scope. The operation runs in the live principal's active organization, and `resolveDelegatorContext` resolves the delegator in that same organization, assuming the agent and its delegator are in the same organization. Nothing checked that assumption. The delegated-principal prologue (`resolveOperationPrincipals`, `packages/plugins/plugin-security/src/security-plugin.ts`) now checks it. It sits beside the dangling-delegator refusal and runs before any gate. It refuses `403 PERMISSION_DENIED`, for every verb, when all three hold: - the tenancy posture enforces the organization wall (`isolated` or `group`); - the on-behalf-of context carries a live active organization; - `vetOrganizationClaim(liveOrganization, delegator.accessible_org_ids, posture)` answers `undefined`. This is the one existing spelling of "a membership backs this organization", called rather than copied. No error code is added. The message names the organization and the delegator, and the developer message carries the `[ADR-0090 D10]` tag. This PR implements ADR-0090 D10 and does not edit the ADR. ## Reach, abstractly Reach was shown in process under both walled postures. No public door builds the refused shape: the `/mcp` OAuth door, the one producer of an on-behalf-of context, vets the request's organization against the same membership. Runtime's `resolve-execution-context.oauth-organization.test.ts`, which drives that producer into the real `SecurityPlugin`, stays green against the rebuilt `dist/`. ## Boundary, before and after - **Before:** under `isolated` or `group`, an on-behalf-of operation in an organization the delegator is not a member of ran, answered by the gates the middleware composed for the two principals. - **After:** that operation is refused `403 PERMISSION_DENIED` at the prologue, every verb, and nothing runs. - **Unchanged:** - the `single` posture, because `vetOrganizationClaim` answers the claim unchanged there; - an on-behalf-of context with no active organization, which keeps ADR-0123 D2's answers; - a context without `onBehalfOf`; - a dangling delegator, still refused as before; - system contexts, which never reach the prologue; - every context the `/mcp` door builds. ## Pins - **New: `packages/plugins/plugin-security/src/delegated-organization-membership.test.ts`** (25 cases). It runs on real `ObjectQL` (source), a real `SqlDriver` and a real `SecurityPlugin`, with the delegator rebuilt from stored `sys_user` / `sys_member` / `sys_user_permission_set` rows. - **Refusal cells (16):** `group` and `isolated` × a public and a private object × a single insert, an array insert, a read, and an update plus a delete. Each asserts `code: 'PERMISSION_DENIED'`, status `403`, the `[ADR-0090 D10]` developer-message tag and the two subjects the message names. Each also asserts nothing stored or changed. The delegator holds the private object's grant in every organization involved, so the D10 grant intersection never answers for it. - **Preservation cells (9):** per walled posture, a delegated insert and read in an organization the delegator belongs to; the same caller without the delegation link; a delegated context with no active organization (ADR-0123 D2's refusal, by its tag); and a system context. Plus the `single` posture. - The file passes a no-op `hook`, so `kernel:ready` never fires, as other real-stack files here do. Without it, the plugin starts its platform bootstrap un-awaited, and a short case destroys the engine under that bootstrap's reads (knex `Acquire connection error: aborted` noise; no case failed). - **Rewritten, because each built a delegated context in an organization the delegator does not belong to:** 1. `tenant-layer0-verdict-on-operation.test.ts`, "the recorded verdict is the INTERSECTION — an organization set neither wall names alone". The delegator's memberships go from `org-2, org-3, org-9` to `org-1, org-3, org-9`, so they include the live `org-1`. The asserted walls and the recorded intersection move with them, from `org-2, org-3` to `org-1, org-3`. That is still a set neither wall names alone. Its control case ("the SAME fixture without the delegation link") takes the same memberships so it stays the same fixture. 2. Same file, "a delegator with NO membership makes the recorded verdict `deny`". A delegator with no membership backs no organization, so this case cannot move to an in-set organization. The caller now carries no active organization instead: the prologue has nothing to vet, and the composed wall's `deny` is still the subject. 3. `objects/default-row-scope-declarations.test.ts`, persona "mcp agent for the platform admin". The fixture gains a `sys_member` row for `p_admin` in `org_x`, the organization every agent persona's request runs in. The expected rows are unchanged. - No other fixture went red. The full `plugin-security` suite is listed below. The one dependent test that drives an on-behalf-of context into the real `SecurityPlugin` is runtime's OAuth-organization file, and it is green. ## Ablation - **Method:** the check was disabled (`false &&` on its first conjunct) through `node scripts/ablation-replace.mjs`, in WRAP mode with its own restore. The subject is imported from source, so there is no `dist/` leg. - **Predicted before the run:** the 16 refusal cells go red, and the 9 preservation cells and both rewritten files stay green. - **Observed:** `Tests 16 failed | 63 passed (79)`, and the 16 are exactly the refusal cells. Each became an admitted operation, except where the walls composed for the two principals already hid the row (`RECORD_NOT_FOUND` on a `group` update). - **Restore:** the blob after restore equals HEAD (`2f6dfd3fd761`), and `git diff HEAD` is empty. ## Tests and gates (head `3da7379b5`) - `plugin-security` full suite: `Test Files 205 passed (205) · Tests 4202 passed | 45 skipped (4247)`, `VERDICT command-exit 0`. - `plugin-security` `typecheck` (`tsc --noEmit`, the scripts project, and `check:test-typecheck`): exit 0, with 0 files / 0 errors / 0 pinned signatures in the test-typecheck debt ledger. - runtime: `resolve-execution-context.oauth-organization.test.ts` and `http-dispatcher.test.ts`, against `plugin-security`'s rebuilt `dist/` (the rule's text was confirmed in `dist/index.mjs`): `Test Files 2 passed · Tests 264 passed`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 69 commands from 5 paths against merge base `4feccc268`. All 69 exit 0, each exit captured before any pipe. `pnpm check:dual-build-cjs-loads` and `pnpm check:i18n` first exited 3 (`PREREQUISITE NOT MET`: no full build). After a full `turbo run build` both exit 0. `--ran` reads "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN" and exits 0. - **Not run locally:** the dogfood on-behalf-of files, which build agent contexts with no active organization, so the rule is inert there by construction. They run on `main`'s battery. ## Acceptance notes - **The rule lives at the operation prologue.** Paths that resolve a delegator on their own (explain, the admission probes, a diagnostic) do not carry the refusal. They are listed for the seat in the dev report, kept off this PR per the disclosure ruling. No door builds the shape. - **`resolveDelegatorContext`'s comment** ("the agent and its delegator are, by construction, in the same org") could point at the prologue check. `explain-engine.ts` is outside this card's file surface, so it is left as is. - **`origin/main` moved after the merge base** (`28c9a2cb6`, spec and driver-turso). It shares no file with this diff, so it is not merged; `dispatch-gates` reports that none of those commits touched what this derivation reads. --- _Generated by [Claude Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 066dabc commit 2e242bb

5 files changed

Lines changed: 379 additions & 5 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
fix(plugin-security)!: a delegated operation runs only in an organization the delegator is a member of, on a walled posture
6+
7+
ADR-0090 D10 bounds an on-behalf-of operation by the delegator's scope. The operation runs in the live principal's active organization, and the delegator context is resolved in that same organization, on the stated assumption that the agent and its delegator are in the same organization. Nothing checked that assumption. The delegated-principal prologue (`resolveOperationPrincipals`) now checks it with `vetOrganizationClaim`, the one rule for "a membership backs this organization": under `isolated` or `group`, when the on-behalf-of context carries an active organization the delegator is not a member of, every operation is refused before any gate runs.
8+
9+
Clause-②: no (narrowing) — on a walled posture, an operation of an on-behalf-of context whose active organization the delegator's membership does not back is refused `403 PERMISSION_DENIED`, where it used to run.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the security middleware gives an in-process caller that builds an on-behalf-of execution context in an organization the delegator does not belong to; the remedy is to build that context in an organization the delegator is a member of, never a rewrite of a package source or a stored row. 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 nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
**BREAKING** (the security middleware's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
- **FROM:** under `isolated` or `group`, an operation (read, insert, update or delete) of an execution context carrying `onBehalfOf` and an active organization the delegator is not a member of ran, answered by the gates the middleware composed for the two principals.
16+
- **TO:** the same operation is refused `403` with `code: 'PERMISSION_DENIED'` before any gate runs. The message names the organization and the delegator.
17+
- **Unchanged:** the `single` posture, an on-behalf-of context with no active organization (it keeps the ADR-0123 D2 answers), a context without `onBehalfOf`, a dangling delegator (still refused as before), and system contexts. The `/mcp` OAuth door, the one producer of an on-behalf-of context in the platform, vets the request's organization against the same membership, so the contexts it builds are never refused by this rule.
18+
- **The fix for an in-process producer:** code that builds an `ExecutionContext` with `onBehalfOf` by hand (a plugin, a service, an agent runtime) must set its `tenantId` to an organization the delegating user is a member of, or leave it unset.
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25063 · ADR-0090 D10] A delegated operation runs only in an organization
5+
* the delegator's own membership backs.
6+
*
7+
* ## The rule
8+
*
9+
* An on-behalf-of context runs in the LIVE principal's active organization,
10+
* and `resolveDelegatorContext` hands the delegator that same organization,
11+
* on the stated assumption that the agent and its delegator are in the same
12+
* organization. A delegated operation is bounded by the delegator's scope
13+
* (ADR-0090 D10), and that bound holds only while the assumption does. So on
14+
* a walled posture the delegated-principal prologue
15+
* (`resolveOperationPrincipals`) refuses `403 PERMISSION_DENIED`, for every
16+
* verb, when the live organization is one the delegator is not a member of.
17+
* The test is `vetOrganizationClaim` (`@objectstack/core`), the one spelling
18+
* of "a membership backs this organization".
19+
*
20+
* The one producer of an on-behalf-of context in this tree (the `/mcp` OAuth
21+
* door) vets the organization against the same membership, so it never builds
22+
* the refused shape. An in-process caller that builds one by hand is the
23+
* population this pins.
24+
*
25+
* ## What this file pins
26+
*
27+
* Real `ObjectQL` (source), real `SqlDriver`, real `SecurityPlugin`; the
28+
* delegator is rebuilt from stored `sys_user` / `sys_member` /
29+
* `sys_user_permission_set` rows. It is a member of `org_a` and `org_c`, and
30+
* holds the private object's grant in `org_a`, `org_c` AND `org_d`, so the
31+
* D10 grant intersection never answers for it: every refusal below is this
32+
* rule's, and with the rule removed each cell is admitted (ablation-verified).
33+
*
34+
* - REFUSED, under `group` and `isolated`: an insert (single and array, on a
35+
* public and on a private object), a read, an update and a delete by a
36+
* delegated context whose active organization is `org_d`. Nothing is stored
37+
* or changed.
38+
* - UNCHANGED: a delegated context in `org_a`; the same caller in `org_d`
39+
* without the delegation link; a delegated context with no active
40+
* organization (it keeps ADR-0123 D2's refusal); a system context; and the
41+
* `single` posture, which has no wall.
42+
*/
43+
44+
import { describe, it, expect, afterEach, vi } from 'vitest';
45+
import { ObjectQL } from '@objectstack/objectql';
46+
import { SqlDriver } from '@objectstack/driver-sql';
47+
import type { PermissionSet } from '@objectstack/spec/security';
48+
import { SecurityPlugin } from './security-plugin.js';
49+
import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity';
50+
import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js';
51+
import { SysUserPosition } from './objects/sys-user-position.object.js';
52+
import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js';
53+
54+
const ORG_A = 'org_a';
55+
const ORG_C = 'org_c';
56+
const ORG_D = 'org_d';
57+
const DELEGATOR = 'usr_delegator';
58+
59+
const FIELDS = {
60+
id: { name: 'id', type: 'text', primaryKey: true },
61+
name: { name: 'name', type: 'text' },
62+
};
63+
64+
const OBJECTS = [
65+
{ name: 'qa_account', label: 'Account', fields: FIELDS },
66+
{ name: 'qa_vault', label: 'Vault', access: { default: 'private' }, fields: FIELDS },
67+
];
68+
69+
const MEMBER: PermissionSet = {
70+
name: 'member_default',
71+
label: 'Member',
72+
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
73+
} as unknown as PermissionSet;
74+
75+
const PLATFORM_ADMIN: PermissionSet = {
76+
name: 'admin_full_access',
77+
label: 'Platform Administrator',
78+
objects: {
79+
'*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true },
80+
},
81+
systemPermissions: ['manage_platform_settings', 'manage_metadata'],
82+
} as unknown as PermissionSet;
83+
84+
/** A private object is outside the `'*'` grant, so the delegator holds it by name. */
85+
const VAULT_WRITER: PermissionSet = {
86+
name: 'qa_vault_writer',
87+
label: 'Vault writer',
88+
objects: { qa_vault: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
89+
} as unknown as PermissionSet;
90+
91+
const SETS = [MEMBER, PLATFORM_ADMIN, VAULT_WRITER];
92+
93+
const SYS_CTX = { isSystem: true, userId: 'usr_system', tenantId: ORG_A };
94+
95+
/** The caller: a platform operator, walled on the public object and crossing on the private one. */
96+
function operator(activeOrganization: string | undefined, delegated = true) {
97+
return {
98+
userId: 'usr_operator',
99+
...(activeOrganization ? { tenantId: activeOrganization } : {}),
100+
accessible_org_ids: activeOrganization ? [activeOrganization] : [],
101+
positions: [],
102+
permissions: ['admin_full_access'],
103+
posture: 'PLATFORM_ADMIN',
104+
...(delegated ? { onBehalfOf: { userId: DELEGATOR } } : {}),
105+
};
106+
}
107+
108+
type Posture = 'group' | 'isolated' | 'single';
109+
110+
const engines: ObjectQL[] = [];
111+
afterEach(async () => {
112+
while (engines.length) {
113+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
114+
}
115+
});
116+
117+
async function boot(posture: Posture) {
118+
const engine = new ObjectQL();
119+
engine.registerDriver(
120+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never) as never,
121+
true,
122+
);
123+
await engine.init();
124+
engine.registerApp({
125+
id: 'com.objectstack.qa.delegated-organization-membership',
126+
name: 'A delegated operation runs in an organization the delegator belongs to',
127+
version: '1.0.0',
128+
type: 'plugin',
129+
scope: 'system',
130+
objects: [
131+
SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet,
132+
SysOrganization, SysUser, SysMember,
133+
...OBJECTS,
134+
],
135+
} as never);
136+
await engine.syncSchemas();
137+
engines.push(engine);
138+
139+
for (const id of [ORG_A, ORG_C, ORG_D]) {
140+
await engine.insert('sys_organization', { id, name: id, slug: id }, { context: SYS_CTX } as never);
141+
}
142+
await engine.insert('sys_user', { id: DELEGATOR, name: DELEGATOR, email: 'delegator@example.test' }, { context: SYS_CTX } as never);
143+
for (const org of [ORG_A, ORG_C]) {
144+
await engine.insert('sys_member', { id: `mem_${org}`, user_id: DELEGATOR, organization_id: org, role: 'member' }, { context: SYS_CTX } as never);
145+
}
146+
const driverOf = (o: string) => (engine as unknown as { getDriver(x: string): { create(x: string, r: Record<string, unknown>): Promise<unknown> } }).getDriver(o);
147+
await driverOf('sys_permission_set').create('sys_permission_set', {
148+
id: 'ps_qa_vault_writer', name: 'qa_vault_writer', label: 'Vault writer', organization_id: null, managed_by: 'admin', active: true,
149+
object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]),
150+
});
151+
// The grant in `org_d` too: the D10 grant intersection must not be what
152+
// refuses a cell below, or removing the rule would leave the cell green.
153+
for (const org of [ORG_A, ORG_C, ORG_D]) {
154+
await engine.insert('sys_user_permission_set', { id: `ups_${org}`, user_id: DELEGATOR, permission_set: 'qa_vault_writer', organization_id: org }, { context: SYS_CTX } as never);
155+
}
156+
// One stored row per object in `org_d`, for the read, update and delete cells.
157+
for (const object of ['qa_account', 'qa_vault']) {
158+
await engine.insert(object, { id: `${object}_in_d`, name: 'seeded', organization_id: ORG_D }, { context: { ...SYS_CTX, tenantId: ORG_D } } as never);
159+
}
160+
161+
const services: Record<string, unknown> = {
162+
manifest: { register: vi.fn() },
163+
objectql: engine,
164+
metadata: {
165+
get: async (type: string, name: string) =>
166+
type === 'permission'
167+
? SETS.find((set) => (set as { name?: string }).name === name) ?? null
168+
: engine.getSchema(name) ?? null,
169+
list: async () => SETS,
170+
},
171+
};
172+
if (posture !== 'single') {
173+
services['org-scoping'] = { name: 'com.objectstack.org-scoping' };
174+
services.tenancy = { posture };
175+
}
176+
const ctx = {
177+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
178+
// `kernel:ready` never fires here: without a `hook` the plugin starts its
179+
// platform bootstrap un-awaited, and a short case then destroys the engine
180+
// under that bootstrap's reads. Nothing this file pins reads what it sets.
181+
hook: () => undefined,
182+
registerService: vi.fn(),
183+
getService: (name: string) => {
184+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
185+
return services[name];
186+
},
187+
};
188+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
189+
await plugin.init(ctx as never);
190+
await plugin.start(ctx as never);
191+
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);
192+
193+
const stored = async (object: string) => {
194+
const driver = (engine as unknown as { getDriver(o: string): { knex: unknown } }).getDriver(object);
195+
const knex = driver.knex as (t: string) => { select: (...c: string[]) => Promise<Array<Record<string, unknown>>> };
196+
const rows = await knex(object).select('id', 'name', 'organization_id');
197+
return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id)));
198+
};
199+
return { engine, stored };
200+
}
201+
202+
interface Outcome { ok: boolean; value?: unknown; code?: string; status?: number; message?: string; developerMessage?: string }
203+
const attempt = async (run: () => Promise<unknown>): Promise<Outcome> => {
204+
try {
205+
return { ok: true, value: await run() };
206+
} catch (e) {
207+
const err = e as { code?: string; statusCode?: number; status?: number; message?: string; developerMessage?: string };
208+
return { ok: false, code: err.code, status: err.statusCode ?? err.status, message: String(err.message ?? e), developerMessage: err.developerMessage };
209+
}
210+
};
211+
212+
/** This rule's refusal: the envelope, and the subjects it names (the live organization and the delegator). */
213+
const expectMembershipRefusal = (outcome: Outcome) => {
214+
expect(outcome.ok, 'expected the delegated operation to be refused').toBe(false);
215+
expect(outcome.code).toBe('PERMISSION_DENIED');
216+
expect(outcome.status).toBe(403);
217+
expect(outcome.developerMessage?.startsWith('[ADR-0090 D10]'), outcome.message).toBe(true);
218+
expect(outcome.message).toContain(`'${ORG_D}'`);
219+
expect(outcome.message).toContain(`'${DELEGATOR}'`);
220+
};
221+
222+
const SEEDED = (object: string) => [{ id: `${object}_in_d`, name: 'seeded', organization_id: ORG_D }];
223+
224+
describe('[#25063 · ADR-0090 D10] a delegated operation in an organization the delegator is not a member of is refused', () => {
225+
for (const posture of ['group', 'isolated'] as const) {
226+
describe(`\`${posture}\``, () => {
227+
for (const object of ['qa_account', 'qa_vault']) {
228+
it(`${object}: a single insert is refused, and nothing is stored`, async () => {
229+
const b = await boot(posture);
230+
expectMembershipRefusal(await attempt(() => b.engine.insert(object, { id: 'r1', name: 'one' }, { context: operator(ORG_D) } as never)));
231+
expect(await b.stored(object)).toEqual(SEEDED(object));
232+
});
233+
234+
it(`${object}: an array insert is refused whole, and nothing is stored`, async () => {
235+
const b = await boot(posture);
236+
const rows = [{ id: 'r1', name: 'one' }, { id: 'r2', name: 'two' }];
237+
expectMembershipRefusal(await attempt(() => b.engine.insert(object, rows, { context: operator(ORG_D) } as never)));
238+
expect(await b.stored(object)).toEqual(SEEDED(object));
239+
});
240+
241+
it(`${object}: a read is refused`, async () => {
242+
const b = await boot(posture);
243+
expectMembershipRefusal(await attempt(() => b.engine.find(object, { context: operator(ORG_D) } as never)));
244+
});
245+
246+
it(`${object}: an update and a delete are refused, and the stored row is unchanged`, async () => {
247+
const b = await boot(posture);
248+
expectMembershipRefusal(await attempt(() => b.engine.update(object, { id: `${object}_in_d`, name: 'renamed' }, { context: operator(ORG_D) } as never)));
249+
expectMembershipRefusal(await attempt(() => b.engine.delete(object, { where: { id: `${object}_in_d` }, context: operator(ORG_D) } as never)));
250+
expect(await b.stored(object)).toEqual(SEEDED(object));
251+
});
252+
}
253+
});
254+
}
255+
});
256+
257+
describe('[#25063] what the rule leaves alone', () => {
258+
for (const posture of ['group', 'isolated'] as const) {
259+
it(`\`${posture}\`: a delegated insert in an organization the delegator belongs to is stored there, and read back`, async () => {
260+
const b = await boot(posture);
261+
for (const object of ['qa_account', 'qa_vault']) {
262+
const outcome = await attempt(() => b.engine.insert(object, { id: 'r1', name: 'one' }, { context: operator(ORG_A) } as never));
263+
expect(outcome.ok, outcome.message).toBe(true);
264+
expect((await b.stored(object)).find((r) => r.id === 'r1')).toEqual({ id: 'r1', name: 'one', organization_id: ORG_A });
265+
const read = await attempt(() => b.engine.find(object, { where: { id: 'r1' }, context: operator(ORG_A) } as never));
266+
expect(read.ok, read.message).toBe(true);
267+
expect((read.value as Array<{ id: string }>).map((r) => r.id)).toEqual(['r1']);
268+
}
269+
});
270+
271+
it(`\`${posture}\`: the same caller in \`org_d\` WITHOUT the delegation link is not this rule's — its insert is stored in \`org_d\``, async () => {
272+
const b = await boot(posture);
273+
const outcome = await attempt(() => b.engine.insert('qa_account', { id: 'r1', name: 'one' }, { context: operator(ORG_D, false) } as never));
274+
expect(outcome.ok, outcome.message).toBe(true);
275+
expect((await b.stored('qa_account')).find((r) => r.id === 'r1')).toEqual({ id: 'r1', name: 'one', organization_id: ORG_D });
276+
});
277+
278+
it(`\`${posture}\`: a delegated context with NO active organization keeps ADR-0123 D2's refusal`, async () => {
279+
const b = await boot(posture);
280+
const outcome = await attempt(() => b.engine.insert('qa_account', { id: 'r1', name: 'one' }, { context: operator(undefined) } as never));
281+
expect(outcome.ok).toBe(false);
282+
expect(outcome.code).toBe('PERMISSION_DENIED');
283+
expect(outcome.status).toBe(403);
284+
expect(outcome.developerMessage?.startsWith('[ADR-0123 D2]'), outcome.message).toBe(true);
285+
expect(await b.stored('qa_account')).toEqual(SEEDED('qa_account'));
286+
});
287+
288+
it(`\`${posture}\`: a system context is untouched — its explicit organization is stored as sent`, async () => {
289+
const b = await boot(posture);
290+
const outcome = await attempt(() => b.engine.insert('qa_account', { id: 'r1', name: 'one', organization_id: ORG_C }, { context: SYS_CTX } as never));
291+
expect(outcome.ok, outcome.message).toBe(true);
292+
expect((await b.stored('qa_account')).find((r) => r.id === 'r1')).toEqual({ id: 'r1', name: 'one', organization_id: ORG_C });
293+
});
294+
}
295+
296+
it('`single`: there is no wall, so a delegated insert in `org_d` is stored as before', async () => {
297+
const b = await boot('single');
298+
const outcome = await attempt(() => b.engine.insert('qa_account', { id: 'r1', name: 'one' }, { context: operator(ORG_D) } as never));
299+
expect(outcome.ok, outcome.message).toBe(true);
300+
expect((await b.stored('qa_account')).find((r) => r.id === 'r1')).toEqual({ id: 'r1', name: 'one', organization_id: ORG_D });
301+
});
302+
});

‎packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,9 +440,16 @@ async function bootBaseline(declared: boolean) {
440440
// two administrators hold — each owned by `org_x`, the organization every
441441
// agent's request runs in and so the one its delegator's grants are resolved
442442
// in (an organization-less grant applies nowhere, ADR-0131 D10).
443+
//
444+
// [#25063] Every delegator is a member of `org_x`, the platform administrator
445+
// included: a delegated request runs in its live organization only where the
446+
// delegator's membership backs it, and is refused at the prologue otherwise
447+
// (`delegated-organization-membership.test.ts`). The one door that builds an
448+
// agent context vets that organization against the same membership.
443449
const member = objects.get('sys_member')!;
444450
await knex('sys_member').insert([
445451
['u_x', 'org_x', 'member'], ['u_x2', 'org_x', 'member'], ['a_x', 'org_x', 'admin'], ['u_y', 'org_y', 'member'],
452+
['p_admin', 'org_x', 'member'],
446453
].map(([user, org, role]) => rowOf(member, { id: `mem:${user}`, user_id: user, organization_id: org, role })));
447454
await knex('sys_user_permission_set').insert([
448455
{ id: 'ups:u_x2', user_id: 'u_x2', permission_set: 'viewer_readonly', organization_id: 'org_x' },

0 commit comments

Comments
 (0)