Skip to content

Commit 09830c3

Browse files
committed
fix(plugin-security): a delegated agent's own leg does not collect an object's declared default row scope
Ruling of record 6278784335 (letter B): the collection adds an object's defaultRowScope on an empty collection except on a delegated principal's own leg, the ceiling whose row bound is the delegator's leg (ADR-0090 D10). Without it, a platform administrator's agent read only the administrator's own rows on the identity objects, where it read every row. collectRLSPolicies takes the context it collects for as a private parameter, threaded from its six callers; the test is the read door's own (onBehalfOf.userId), in an unexported module function. The ③.11 pin now asserts each delegated agent's served ids on both trees (member, viewer, organisation admin and platform administrator), compares every persona's read explain byte-for-byte, and its census reads the collection per context, with an undelegated control. Claude-Session: https://claude.ai/code/session_01FatVyDAFEST48jZDQcbheU Co-authored-by: Claude <noreply@anthropic.com>
1 parent e331cd3 commit 09830c3

4 files changed

Lines changed: 129 additions & 50 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a delegated agent's own leg does not collect an object's declared default row scope (#24341)
6+
7+
Clause-②: no
8+
9+
An agent acting on behalf of a user (ADR-0090 D10) is judged on two legs that the read door ANDs together: its own sets, which are a capability ceiling and carry no row policy, and its delegator's sets. Since the row-policy collection began applying an object's declared `defaultRowScope` where the held sets carry no row policy, the ceiling's silent leg collected that default too. Where the delegator's own leg is wider than the default, the agent then read fewer rows than its delegator. The platform administrator is the case in point: the administrator's own leg skips row policies on the identity objects, so an agent acting for the administrator would have read only the administrator's own rows there, where it read every row before.
10+
11+
The collection no longer adds an object's default on a delegated principal's own leg, so a ceiling's silence is not read as the object's default. The delegator's leg collects the default exactly as any principal's does, and a principal that acts for no one keeps the default whenever its sets are silent.
12+
13+
What moves, against `18.0.0-next.5`: on an object that declares `defaultRowScope`, an agent acting for a user reads the rows its delegator's side admits, and its explain output names no object default on the agent's side. Before this release's reader, that was already so.
14+
15+
**Unchanged.**
16+
17+
- A principal that acts for no one, a human or otherwise, collects an object's default when its sets carry no row policy for the object and operation, as before.
18+
- The delegator's side, the superuser bypass, and every authored policy are judged as before.
19+
- No export, option or type moves.

‎.changeset/24341-identity-objects-default-row-scope.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ The security plugin's row-policy collection applies an object's declared default
2020
**Unchanged.**
2121

2222
- A deployment with the platform baseline (`member_default`, the default) serves every human exactly what it served before, on every one of the 23 objects and every operation: each human holds the baseline, and the baseline carries a policy for each object and operation. Measured on a booted composition with a member, a viewer, an organisation admin, a user in no organisation and the platform administrator, before and after the declarations.
23-
- An MCP agent acting for a user reads the same rows as before. Its ceiling set carries no row policy, so the declared default now applies on the agent's own side of the intersection, evaluated as the delegating user (the agent's `userId` is the delegator's). The explain output for such an agent now names the object default on that side.
23+
- An MCP agent acting for a user reads the same rows, and gets the same explain output, as before. Its ceiling set carries no row policy, and the security plugin does not collect an object's declared default on a delegated principal's own side of the intersection (see the `@objectstack/plugin-security` entry), so the agent's rows stay bounded by its delegator's side alone. Measured on a booted composition, before and after the declarations, for the agents of a member, a viewer, an organisation admin and the platform administrator; the platform administrator's agent still reads every row.
2424
- The platform administrator's superuser bypass is decided before any row policy is collected.
2525
- No export, option or stored shape moves.
2626

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

Lines changed: 75 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@
2323
* each user-context producer's real context:
2424
* `service-automation/src/sys-user-default-row-scope.integration.test.ts`.
2525
* 4. The security plugin's collection applies a declared default where the
26-
* caller's held sets carry no row policy for the object and operation, and
27-
* the shipped sets keep their spreads: a deployment with the shipped
28-
* baseline serves every persona the rows it served before the
29-
* declarations, and the census names where a default is now collected.
26+
* caller's held sets carry no row policy for the object and operation —
27+
* never on a delegated principal's own leg — and the shipped sets keep
28+
* their spreads: a deployment with the shipped baseline serves every
29+
* persona, delegated agents included, the rows and the explain it served
30+
* before the declarations.
3031
*/
3132

3233
import { describe, it, expect, vi, afterAll } from 'vitest';
@@ -41,6 +42,7 @@ import { isObjectDefaultRowScopePolicy } from '../object-default-row-scope.js';
4142
import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from '../__tests__/legacy-catalog-objects.testkit.js';
4243
import { SysUserPosition } from './sys-user-position.object.js';
4344
import { SysUserPermissionSet } from './sys-user-permission-set.object.js';
45+
import { bindTestSecurityCatalog } from '../__tests__/security-catalog.testkit.js';
4446

4547
type Policy = {
4648
name: string;
@@ -249,10 +251,13 @@ describe('sys_user\'s merged predicate admits the union of its two literals\' ro
249251
* Booted on the real `SecurityPlugin` + `ObjectQL` over a real `SqlDriver`,
250252
* with every declarer as this tree declares it ("after") and the same objects
251253
* with `defaultRowScope` dropped ("before"), the shipped sets, and the
252-
* platform baseline composed. Each persona reads every declarer, and is
253-
* explained for every operation, identically in both; the census then names
254-
* every persona, object and operation where the declared default is what the
255-
* collection now applies.
254+
* platform baseline composed, and a catalog the grants resolve against. Each
255+
* persona — the humans, and an MCP agent acting for each of four delegators —
256+
* reads every declarer, and is explained, identically in both. The census
257+
* reads the collection for all four operations: no baseline holder collects a
258+
* default, and no delegated agent's own leg does either (ruling of record
259+
* 6278784335 on #24341, letter B: a ceiling's silence is not Layer 1 silence,
260+
* so a platform administrator's agent keeps every row its delegator reads).
256261
*/
257262

258263
type Persona = Record<string, unknown>;
@@ -290,6 +295,11 @@ const BASELINE_PERSONAS: Record<string, Persona> = {
290295
onBehalfOf: { userId: 'u_x', principalKind: 'human' }, posture: 'MEMBER', org_user_ids: ORG_X_USERS,
291296
email: 'u_x@example.test',
292297
},
298+
'mcp agent for the viewer': {
299+
userId: 'u_x2', tenantId: 'org_x', positions: [], permissions: ['mcp_agent_data_read'], principalKind: 'agent',
300+
onBehalfOf: { userId: 'u_x2', principalKind: 'human' }, posture: 'MEMBER', org_user_ids: ORG_X_USERS,
301+
email: 'u_x2@example.test',
302+
},
293303
'mcp agent for the organization admin': {
294304
userId: 'a_x', tenantId: 'org_x', positions: [], permissions: ['mcp_agent_data_read'], principalKind: 'agent',
295305
onBehalfOf: { userId: 'a_x', principalKind: 'human' }, posture: 'TENANT_ADMIN', org_user_ids: [...ORG_X_USERS, 'a_x'],
@@ -384,17 +394,22 @@ async function bootBaseline(declared: boolean) {
384394
const plugin = new SecurityPlugin({ defaultPermissionSets });
385395
await plugin.init(ctx as never);
386396
await plugin.start(ctx as never);
397+
// The catalog the grants name their sets in (ADR-0131 D3/D4): the shipped
398+
// sets, so a delegator's grant resolves to the set it names.
399+
bindTestSecurityCatalog(engine, { permissions: defaultPermissionSets as never });
387400
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);
388401

389402
const knex = (driver as unknown as { knex: (t: string) => { insert(rows: object[]): Promise<unknown> } }).knex;
390403
for (const schema of declarers) await knex(schema.name).insert(fixtureRowsFor(schema));
391404
// What an agent's delegator resolves from (ADR-0090 D10: the delegator leg is
392-
// rebuilt from the store): the memberships, and the two administrators' grants.
405+
// rebuilt from the store): the memberships, and the grants the viewer and the
406+
// two administrators hold.
393407
const member = objects.get('sys_member')!;
394408
await knex('sys_member').insert([
395409
['u_x', 'org_x', 'member'], ['u_x2', 'org_x', 'member'], ['a_x', 'org_x', 'admin'], ['u_y', 'org_y', 'member'],
396410
].map(([user, org, role]) => rowOf(member, { id: `mem:${user}`, user_id: user, organization_id: org, role })));
397411
await knex('sys_user_permission_set').insert([
412+
{ id: 'ups:u_x2', user_id: 'u_x2', permission_set: 'viewer_readonly', organization_id: 'org_x' },
398413
{ id: 'ups:a_x', user_id: 'a_x', permission_set: 'organization_admin', organization_id: 'org_x' },
399414
{ id: 'ups:p_admin', user_id: 'p_admin', permission_set: 'admin_full_access', organization_id: null },
400415
]);
@@ -417,9 +432,6 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t
417432
const EXPLAINED = ['read'] as const;
418433
const COLLECTED = ['select', 'insert', 'update', 'delete'] as const;
419434
const isAgent = (persona: string) => BASELINE_PERSONAS[persona].principalKind === 'agent';
420-
/** What explain decided, without its prose and composed filter: `allowed` and each layer's verdict. */
421-
const decided = (e: { allowed: boolean; layers: Array<{ layer: string; verdict: string }> }) =>
422-
JSON.stringify({ allowed: e.allowed, layers: e.layers.map((l) => `${l.layer}:${l.verdict}`) });
423435

424436
it('every persona reads every declarer exactly as without the declarations, and is explained the same', { timeout: 30_000 }, async () => {
425437
const after = await bootBaseline(true);
@@ -433,15 +445,9 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t
433445
const now = await after.served(object, context);
434446
if (JSON.stringify(now) !== JSON.stringify(was)) rowsMoved.push(`${label}: ${JSON.stringify(was)} -> ${JSON.stringify(now)}`);
435447
for (const operation of EXPLAINED) {
436-
const e0 = await before.security.explain({ object, operation }, { ...context });
437-
const e1 = await after.security.explain({ object, operation }, { ...context });
438-
// A baseline holder's explain is byte-for-byte. A delegated agent's
439-
// decision is the same, but its ceiling's leg now collects the object
440-
// default, so the rls layer names it and the composed filter carries
441-
// that leg's conjunct (the delegator's own scope, `userId` being the
442-
// delegator's) beside the delegator's.
443-
const same = isAgent(persona) ? decided(e1) === decided(e0) : JSON.stringify(e1) === JSON.stringify(e0);
444-
if (!same) explainMoved.push(`${label} (${operation})`);
448+
const e0 = JSON.stringify(await before.security.explain({ object, operation }, { ...context }));
449+
const e1 = JSON.stringify(await after.security.explain({ object, operation }, { ...context }));
450+
if (e1 !== e0) explainMoved.push(`${label} (${operation})`);
445451
}
446452
}
447453
}
@@ -452,39 +458,64 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t
452458
// reads them all.
453459
expect(await after.served('sys_user', BASELINE_PERSONAS.member)).toEqual(['u_x', 'u_x2']);
454460
expect(await after.served('sys_session', BASELINE_PERSONAS['user with no organisation'])).toEqual(['sys_session:u_loner']);
455-
expect(await after.served('sys_session', BASELINE_PERSONAS['mcp agent for the member'])).toEqual(['sys_session:u_x']);
456461
expect(await after.served('sys_session', BASELINE_PERSONAS.platform_admin)).toHaveLength(BASELINE_USERS.length);
457462
});
458463

459-
it('census: the declared default is collected for no baseline holder, and on a delegated agent ceiling\'s leg', async () => {
464+
it('each delegated agent reads its delegator\'s rows on both trees: the platform administrator\'s agent reads every row', { timeout: 30_000 }, async () => {
465+
const after = await bootBaseline(true);
466+
const before = await bootBaseline(false);
467+
const everyUser = [...BASELINE_USERS].sort();
468+
const expected: Record<string, { sys_user: string[]; sys_session: string[]; sys_scim_user: string[] }> = {
469+
'mcp agent for the member': { sys_user: ['u_x', 'u_x2'], sys_session: ['sys_session:u_x'], sys_scim_user: ['sys_scim_user:u_x'] },
470+
'mcp agent for the viewer': { sys_user: ['u_x', 'u_x2'], sys_session: ['sys_session:u_x2'], sys_scim_user: ['sys_scim_user:u_x2'] },
471+
'mcp agent for the organization admin': {
472+
sys_user: ['a_x', 'u_x', 'u_x2'], sys_session: ['sys_session:a_x'], sys_scim_user: ['sys_scim_user:a_x'],
473+
},
474+
// The delegator's leg skips Layer 1 (the superuser bypass on these
475+
// objects), so the only bound a declared default could add is the
476+
// ceiling's own leg — which collects none (letter B).
477+
'mcp agent for the platform admin': {
478+
sys_user: everyUser,
479+
sys_session: everyUser.map((u) => `sys_session:${u}`),
480+
sys_scim_user: everyUser.map((u) => `sys_scim_user:${u}`),
481+
},
482+
};
483+
for (const [persona, objects] of Object.entries(expected)) {
484+
for (const [object, ids] of Object.entries(objects)) {
485+
expect(await before.served(object, BASELINE_PERSONAS[persona]), `${persona} on ${object}, before`).toEqual(ids);
486+
expect(await after.served(object, BASELINE_PERSONAS[persona]), `${persona} on ${object}, after`).toEqual(ids);
487+
}
488+
}
489+
});
490+
491+
it('census: no persona collects a declared default — the baseline carries a policy for each, and a delegated leg collects none', async () => {
460492
const { plugin } = await bootBaseline(true);
461493
const objects = await registeredObjects();
462-
const applied: string[] = [];
463-
for (const [persona, context] of Object.entries(BASELINE_PERSONAS)) {
464-
// The platform administrator's superuser bypass is decided before the collection.
465-
if (persona === 'platform_admin') continue;
494+
const census = async (context: Persona): Promise<string[]> => {
466495
const sets = await plugin.resolvePermissionSetsForContext({ ...context });
496+
const applied: string[] = [];
467497
for (const object of DECLARERS) {
468498
for (const operation of COLLECTED) {
469-
const collected = plugin.collectRLSPolicies(sets, object, operation, (context.positions ?? []) as string[]);
470-
if (collected.some((p: object) => isObjectDefaultRowScopePolicy(p))) applied.push(`${persona} ${object} ${operation}`);
499+
const collected = plugin.collectRLSPolicies(sets, object, operation, (context.positions ?? []) as string[], { ...context });
500+
if (collected.some((p: object) => isObjectDefaultRowScopePolicy(p))) applied.push(`${object} ${operation}`);
471501
}
472502
}
503+
return applied;
504+
};
505+
for (const [persona, context] of Object.entries(BASELINE_PERSONAS)) {
506+
// The platform administrator's superuser bypass is decided before the collection.
507+
if (persona === 'platform_admin') continue;
508+
expect(await census(context), persona).toEqual([]);
473509
}
474-
// Every human holds `member_default` beside their own sets, and it carries
475-
// a policy for every declarer on every operation (its literals, and its
476-
// `'*'` write floor), so no baseline holder collects a default.
477-
expect(applied.filter((line) => !line.startsWith('mcp agent'))).toEqual([]);
478-
// The agent ceilings carry no row policy (ADR-0090 D10): an agent's own
479-
// leg collects each declarer's default, on exactly the operations the
480-
// declaration names.
481-
const agents = Object.keys(BASELINE_PERSONAS).filter(isAgent);
482-
const expected = agents.flatMap((persona) =>
483-
DECLARERS.flatMap((object) => {
484-
const scope = objects.get(object)!.defaultRowScope!;
485-
return COLLECTED.filter((op) => scope.operation === 'all' || scope.operation === op).map((op) => `${persona} ${object} ${op}`);
486-
}),
487-
);
488-
expect(applied.sort()).toEqual(expected.sort());
510+
// CONTROL: the carve-out is the delegation, not the agent kind. The same
511+
// ceiling with no delegator (a hand-built shape no producer emits) has a
512+
// silent Layer 1, so it collects each declarer's default, on exactly the
513+
// operations the declaration names (ruling E′ item 2, whole).
514+
const { onBehalfOf: _delegator, ...undelegated } = BASELINE_PERSONAS['mcp agent for the member'];
515+
const expected = DECLARERS.flatMap((object) => {
516+
const scope = objects.get(object)!.defaultRowScope!;
517+
return COLLECTED.filter((op) => scope.operation === 'all' || scope.operation === op).map((op) => `${object} ${op}`);
518+
});
519+
expect((await census(undelegated)).sort()).toEqual(expected.sort());
489520
});
490521
});

0 commit comments

Comments
 (0)