Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/33051-operation-own-access-depth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/plugin-security': patch
---

fix(plugin-security): each data operation reads at its own object's access depth, even when sibling operations share one request

Clause-②: no
Metadata-protocol: none

The authz middleware resolves an access depth per object (`readScope` / `writeScope`, ADR-0057 D1) and hands it to plugin-sharing's owner-match further down the same operation. It used to write that depth onto the caller's execution context in place. One caller context serves every operation a request runs, concurrent ones included, so a sibling read on another object could overwrite the depth before plugin-sharing read it. The record page payload (`POST /api/v1/data/:object/:id/page`) reads its related lists concurrently: on the showcase, a member staffed `showcase_manager` got the Contacts list filtered at the owner depth of the next list's object, so it showed 1 row beside `total: 26` whenever Contacts was not the last list. The admin was not affected.

The depth, and the external audience's grant stamp beside it, now ride on a copy of the envelope that the middleware hands to the rest of its own operation. The caller's context is never written, so concurrent operations cannot see each other's depth. Request-wide memos (membership sets, queues) are still staged on the caller's context before the copy is made.
15 changes: 9 additions & 6 deletions packages/core/src/security/operation-private-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@
*
* - the ADR-0057 D1 access DEPTH the sharing owner-match expands to —
* `__readScope` / `__writeScope`, plus the ADR-0090 D10 delegator halves
* `__delegatorReadScope` / `__delegatorWriteScope`, stamped in place by
* `security-plugin.ts` (`sc.__readScope = …`);
* `__delegatorReadScope` / `__delegatorWriteScope`, stamped by the authz
* middleware (`security-plugin-authz-middleware.ts`, step 2.6) onto the
* envelope it hands the rest of ITS operation's chain — since #33051 a copy
* of the caller's, never the caller's own, because one caller envelope
* serves concurrent operations on different objects;
* - the engine's internal privilege markers on the same channel —
* `__expandRead` marks a read as a lookup EXPANSION sub-read (it no longer
* relaxes any gate — #7626 removed that waiver — but it still travels with
Expand Down Expand Up @@ -101,10 +104,10 @@ export const OPERATION_PRIVATE_KEY_PREFIX = '__';
* receives (which is exactly what plugin-security does before it calls the
* sharing service) can never write back into the operation context the caller
* was handed;
* - inbound — the engine's middleware stamps a depth for the object it is about
* to read onto whatever it is handed, so forwarding a caller's envelope BY
* REFERENCE would write that depth back into the request context the route
* goes on using.
* - inbound — a middleware that stamps a depth for the object it is about to
* read onto whatever it is handed (plugin-security's did, in place, until
* #33051) would, through an envelope forwarded BY REFERENCE, write that
* depth back into the request context the route goes on using.
*
* ⛔ Never `return exec;` on the "nothing to strip" path. The copy is the point,
* not an optimisation to skip when the envelope happens to be clean — the two
Expand Down
15 changes: 11 additions & 4 deletions packages/plugins/plugin-security/src/authz-matrix-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -771,15 +771,22 @@ describe('ADR-0099 P0 — probe vs carried-rung equivalence (#3211 M1)', () => {
const h = makeHarness({
...OBJECTS.task,
orgScoping: true,
findOneImpl: (q: any) => (q?.where?.$and ? null : { id: 'r1' }),
// [#33051] The stamp rides the operation's own envelope, handed on at
// `next()`, so the update must reach it: its pre-image re-read answers
// a row `portal_rows` and the owner check admit, whatever row predicate
// the gate composes.
findOneImpl: () => ({ id: 'r1', status: 'portal', organization_id: 'org-1', created_by: 'x1' }),
});
await plugin.init(h.ctx); await plugin.start(h.ctx);
const context = { ...roleCtx };
const opCtx: any = operation === 'find'
? { object: 'task', operation, ast: { where: undefined }, context }
: { object: 'task', operation, data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context };
try { await h.run(opCtx); } catch { /* the stamp is made before any refusal */ }
return { read: context.__externalReadGrant, write: context.__externalWriteGrant };
: { object: 'task', operation, data: { id: 'r1', name: 'x', status: 'portal' }, options: { where: { id: 'r1' } }, context };
try { await h.run(opCtx); } catch { /* a refusal before `next()` hands nothing on */ }
// [#33051] Handed on the OPERATION's own envelope; the caller's is never written.
expect(context.__externalReadGrant).toBe(roleCtx.__externalReadGrant);
expect(context.__externalWriteGrant).toBe(roleCtx.__externalWriteGrant);
return { read: opCtx.context.__externalReadGrant, write: opCtx.context.__externalWriteGrant };
};

// A stamp names the object it was resolved for, so a nested operation on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ import {
writesQueue,
} from './ownership-queue-gate.js';
import { assertEngineOwnedWriteAllowed } from './system-write-guard.js';
import { grantRowPolicySpeaks, stampExternalGrant } from './external-owd-grant-stamp.js';
import { externalGrantPatch } from './external-owd-grant-stamp.js';

/**
* The layered computation's options, as the class declares them: the
Expand Down Expand Up @@ -490,6 +490,15 @@ export function registerAuthzMiddleware(
// judgement or both; the two flags say which, for the assertion's
// wording and for its one stand-down (the data door, see there).
let writeImageCheckSeam: WriteImageCheckSeam | null = null;
// [#33051] What step 2.6 resolved for THIS operation alone — the access
// depth plugin-sharing reads, and the external rung's grant stamp. Handed
// to the rest of the chain on the operation's OWN envelope at `next()`
// below, never written into the caller's: one caller envelope serves
// every operation a request runs, concurrent ones included (a record
// page's related lists are sibling reads), and a depth stamped onto it in
// place was overwritten by a sibling's for another object before
// plugin-sharing read it.
let operationStamps: Record<string, unknown> | null = null;
let businessCheckSeamInstalled = false;
let tenantWallSeamInstalled = false;
// [#10757] Retire every memoized permission-set resolution the moment a
Expand Down Expand Up @@ -1070,6 +1079,8 @@ export function registerAuthzMiddleware(
// (plugin-sharing), so we pass the scope STRING, not the resolved set.
if (permissionSets.length > 0) {
const sc: any = opCtx.context;
const stamps: Record<string, unknown> = {};
operationStamps = stamps;
// [ADR-0090 D10 — maintainer ruling 2026-09-08, option 1] The DELEGATED
// principal's own depth is its ceiling's DECLARED depth, and a ceiling
// that declares none says nothing about visibility — so the delegator's
Expand All @@ -1092,7 +1103,7 @@ export function registerAuthzMiddleware(
// agent identity (unchanged on the non-delegated path).
if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) {
const delegated = depthFor('read');
sc.__readScope = delegated
stamps.__readScope = delegated
? delegated.agentLegScope
: this.permissionEvaluator.getEffectiveScope('read', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate });
// [ADR-0090 D10] Stash the DELEGATOR's own read depth SEPARATELY (not a
Expand All @@ -1102,13 +1113,13 @@ export function registerAuthzMiddleware(
// intersection. Narrowing __readScope alone would wrongly scope the
// AGENT's identity to the delegator's depth (owner_id = agentId),
// hiding the very rows the delegator legitimately owns.
if (delegated) sc.__delegatorReadScope = delegated.delegatorScope;
if (delegated) stamps.__delegatorReadScope = delegated.delegatorScope;
} else if (['update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation)) {
const delegated = depthFor('write');
sc.__writeScope = delegated
stamps.__writeScope = delegated
? delegated.agentLegScope
: this.permissionEvaluator.getEffectiveScope('write', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate });
if (delegated) sc.__delegatorWriteScope = delegated.delegatorScope;
if (delegated) stamps.__delegatorWriteScope = delegated.delegatorScope;
}
// [Ruling of record 6355206139, letter B] The EXTERNAL rung's OWD seam.
// plugin-sharing floors an external principal's org-wide default at
Expand All @@ -1121,9 +1132,9 @@ export function registerAuthzMiddleware(
const collect = (rlsOperation: string) =>
this.collectRLSPolicies(permissionSets, opCtx.object, rlsOperation, (sc.positions ?? []) as string[], sc);
if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) {
stampExternalGrant(sc, opCtx.object, 'read', grantRowPolicySpeaks(collect, 'read'));
Object.assign(stamps, externalGrantPatch(sc, opCtx.object, 'read', collect));
} else if (['update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation)) {
stampExternalGrant(sc, opCtx.object, 'write', grantRowPolicySpeaks(collect, 'write', rlsOperationForVerb(opCtx.operation)));
Object.assign(stamps, externalGrantPatch(sc, opCtx.object, 'write', collect, rlsOperationForVerb(opCtx.operation)));
}
}
}
Expand Down Expand Up @@ -2610,6 +2621,12 @@ export function registerAuthzMiddleware(
}
}

// [#33051] The operation's own envelope: the caller's, with step 2.6's
// stamps for THIS operation on top. A copy, so nothing this operation
// resolved is written back into the envelope its siblings share; built
// here rather than at 2.6, so the request-wide memos the steps above
// staged on the caller's envelope (membership sets, queues) ride along.
if (operationStamps && Object.keys(operationStamps).length > 0) opCtx.context = { ...opCtx.context, ...operationStamps };
await next();

// [commit a016f08b8] FAIL CLOSED on a seam that was never run. `honoured` is set by
Expand Down
38 changes: 24 additions & 14 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4221,8 +4221,14 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => {
// The agent keeps its own (org-wide) depth so plugin-sharing scopes the
// AGENT identity correctly; the delegator's narrower depth is stashed apart
// so plugin-sharing can re-run the owner-match under the DELEGATOR identity.
expect(ctx.__readScope).toBe('org');
expect(ctx.__delegatorReadScope).toBe('own');
expect(opCtx.context.__readScope).toBe('org');
expect(opCtx.context.__delegatorReadScope).toBe('own');
// [#33051] …on the OPERATION's own envelope: the caller's is never written,
// so a concurrent sibling operation sharing it cannot overwrite the depth
// between this stamp and plugin-sharing's read of it.
expect(opCtx.context).not.toBe(ctx);
expect(ctx).not.toHaveProperty('__readScope');
expect(ctx).not.toHaveProperty('__delegatorReadScope');
});

// ── [#16549 / maintainer ruling 2026-09-08] "a ceiling that says nothing
Expand Down Expand Up @@ -4252,13 +4258,14 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => {
schemaExtra: { sharingModel: 'private' },
});
const ctx: any = agentCtx();
await h.run({ object: 'task', operation: 'find', ast: { where: undefined }, context: ctx });
const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: ctx };
await h.run(opCtx);
// Was 'own' before the ruling — the manufactured opinion that produced
// `crm_opportunity: 0`. Both legs now say 'org', so plugin-sharing's
// `buildReadFilter` returns null on each and the agent reads exactly the
// rows the human reads. THIS is pin 1: equal, not merely closer.
expect(ctx.__readScope).toBe('org');
expect(ctx.__delegatorReadScope).toBe('org');
expect(opCtx.context.__readScope).toBe('org');
expect(opCtx.context.__delegatorReadScope).toBe('org');
});

it('[#16549] NEGATIVE CONTROL: a delegator WITHOUT viewAllRecords is unchanged — the delegator leg still bounds the read', async () => {
Expand All @@ -4269,13 +4276,14 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => {
schemaExtra: { sharingModel: 'private' },
});
const ctx: any = agentCtx();
await h.run({ object: 'task', operation: 'find', ast: { where: undefined }, context: ctx });
const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: ctx };
await h.run(opCtx);
// The widening is on the CEILING's leg only. The delegator's own 'own'
// depth is untouched and still AND-s in, so a user with no viewAllRecords
// sees exactly what she saw before: own + shares. ⛔ The fix widens the
// manager's view, not everyone's.
expect(ctx.__readScope).toBe('org');
expect(ctx.__delegatorReadScope).toBe('own');
expect(opCtx.context.__readScope).toBe('org');
expect(opCtx.context.__delegatorReadScope).toBe('own');
});

it('[#16549] NEGATIVE CONTROL: a ceiling that DOES declare a depth keeps its full subtractive force', async () => {
Expand All @@ -4286,10 +4294,11 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => {
schemaExtra: { sharingModel: 'private' },
});
const ctx: any = agentCtx();
await h.run({ object: 'task', operation: 'find', ast: { where: undefined }, context: ctx });
const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: ctx };
await h.run(opCtx);
// Silence is "no opinion"; a DECLARATION is an opinion and still narrows.
expect(ctx.__readScope).toBe('own');
expect(ctx.__delegatorReadScope).toBe('org');
expect(opCtx.context.__readScope).toBe('own');
expect(opCtx.context.__delegatorReadScope).toBe('org');
});

it('[#16549] the write leg follows the same rule — a silent write ceiling takes the delegator\'s modifyAllRecords depth', async () => {
Expand All @@ -4307,9 +4316,10 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => {
schemaExtra: { sharingModel: 'private' },
});
const ctx: any = agentCtx();
await h.run({ object: 'task', operation: 'update', data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context: ctx });
expect(ctx.__writeScope).toBe('org');
expect(ctx.__delegatorWriteScope).toBe('org');
const opCtx: any = { object: 'task', operation: 'update', data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context: ctx };
await h.run(opCtx);
expect(opCtx.context.__writeScope).toBe('org');
expect(opCtx.context.__delegatorWriteScope).toBe('org');
});

it('[#16549] NEGATIVE CONTROL: the read-only ceiling still cannot WRITE, whatever the delegator holds', async () => {
Expand Down
Loading
Loading