From 46e0caee7be603620b637a8b56f0a9101bbb724c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 16:01:33 +0000 Subject: [PATCH] fix(security): fail-closed sentinel for on-behalf-of reads on getReadFilter (#2852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getReadFilter — the read-scope provider the analytics/raw-SQL path binds to — resolves only the caller's own ceiling; the ADR-0090 D10 delegator RLS intersection that the engine middleware applies to find/count/ aggregate is absent on this path. Computing a filter here for a delegated (on-behalf-of) context would silently widen the read past the delegator's scope — the confused-deputy widening D10 prevents on the CRUD path. Until the intersection is threaded through computeRlsFilter (tracked with #2920 B1 / ADR-0095 D1), deny fail-closed (deny sentinel + error log) when context.onBehalfOf.userId is set. System on-behalf-of bypasses ahead of the guard; no agent surface reaches analytics today, so this is a latent-invariant guard, not a live-traffic change. Tests: 2 new getReadFilter cases (delegated context → deny sentinel + logged; system on-behalf-of → bypass). plugin-security 441 passed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012RE5oFswe4DUvExuAgvbg2 --- ...thz-2852-readfilter-onbehalfof-sentinel.md | 17 +++++++++ .../src/security-plugin.test.ts | 36 +++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 21 +++++++++++ 3 files changed, 74 insertions(+) create mode 100644 .changeset/authz-2852-readfilter-onbehalfof-sentinel.md diff --git a/.changeset/authz-2852-readfilter-onbehalfof-sentinel.md b/.changeset/authz-2852-readfilter-onbehalfof-sentinel.md new file mode 100644 index 0000000000..e27fe58b6c --- /dev/null +++ b/.changeset/authz-2852-readfilter-onbehalfof-sentinel.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(security): fail-closed sentinel for on-behalf-of reads on getReadFilter (#2852) + +`getReadFilter` (the read-scope provider the analytics/raw-SQL path binds to) +resolves only the caller's own ceiling — the ADR-0090 D10 delegator RLS +intersection that the engine middleware applies to find/count/aggregate is not +implemented on this path. Computing a filter here for a delegated (on-behalf-of) +context would therefore silently widen the read past the delegator's scope. + +Until the intersection is threaded through `computeRlsFilter` (tracked with +#2920 B1 / ADR-0095 D1), `getReadFilter` now denies fail-closed (deny sentinel + +error log) when `context.onBehalfOf.userId` is set. System on-behalf-of bypasses +ahead of the guard, and no agent surface reaches analytics today, so this is a +latent-invariant guard rather than a live-traffic behavior change. diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index b4cb6f22f7..25216230ab 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -776,6 +776,42 @@ describe('SecurityPlugin', () => { expect(filter).toEqual(RLS_DENY_FILTER); }); + it('[#2852] fail-closed on an on-behalf-of (delegated) context — no D10 intersection on this path', async () => { + // getReadFilter resolves only the CALLER's ceiling; the delegator RLS + // intersection the engine middleware applies is absent here. A delegated + // read must DENY rather than silently return the agent's (wider) own + // scope. Latent today (no agent surface reaches analytics) but the + // invariant is enforced regardless. + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const filter = await plugin.getReadFilter('task', { + userId: 'agent-1', + tenantId: 'org-1', + positions: [], + permissions: ['member_default'], + onBehalfOf: { userId: 'user-1' }, + }); + expect(filter).toEqual(RLS_DENY_FILTER); + expect(harness.ctx.logger.error).toHaveBeenCalled(); + }); + + it('[#2852] a system on-behalf-of context bypasses before the deny sentinel', async () => { + // isSystem short-circuits to `undefined` (no scope) ahead of the + // on-behalf-of guard — engine self-invocation must not be denied. + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const filter = await plugin.getReadFilter('task', { + isSystem: true, + userId: 'agent-1', + onBehalfOf: { userId: 'user-1' }, + }); + expect(filter).toBeUndefined(); + }); + it('[#2936] fail-closed on a CANONICAL `==` wildcard policy targeting a missing column → deny sentinel', async () => { // The `=`-form twin above is the L692 test. This is its canonical-CEL // sibling: pre-#2936 `extractTargetField` did not recognize `==`, so the diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 021e25664d..ba4a910ecd 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1769,6 +1769,27 @@ export class SecurityPlugin implements Plugin { if (positions.length === 0 && explicit.length === 0 && !context?.userId) { return undefined; } + // [#2852] D10 delegator intersection is NOT implemented on this path. + // The engine middleware (find/count/aggregate) intersects an on-behalf-of + // read with the DELEGATOR's own RLS (resolveDelegatorContext, ~L689), but + // getReadFilter — the read-scope provider bound by the analytics/raw-SQL + // path — resolves only the CALLER's ceiling. Computing a filter here for a + // delegated (agent) context would therefore SILENTLY WIDEN the read past + // the delegator's scope (the confused-deputy widening D10 prevents on the + // CRUD path). Until the intersection is threaded through computeRlsFilter + // (tracked with #2920 B1 / ADR-0095 D1), FAIL CLOSED: a delegated read on + // this path denies rather than under-scopes. System on-behalf-of already + // returned above; today no agent surface reaches analytics, so this is a + // latent-invariant guard, not a live-traffic change. + if (context?.onBehalfOf?.userId) { + this.logger.error?.( + `[security] getReadFilter received an on-behalf-of context for object ` + + `'${object}' (agent ${context?.userId ?? 'unknown'} on behalf of ` + + `${context.onBehalfOf.userId}) — the D10 delegator intersection is not ` + + `implemented on the read-scope path; denying (fail-closed, #2852)`, + ); + return { ...RLS_DENY_FILTER }; + } try { const permissionSets = await this.resolvePermissionSetsForContext(context); const filter = await this.computeRlsFilter(permissionSets, object, 'find', context);