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
17 changes: 17 additions & 0 deletions .changeset/authz-2852-readfilter-onbehalfof-sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down