Skip to content

Commit 331613c

Browse files
fix(plugin-security)!: the raw-SQL read scope (getReadFilter) answers the deny sentinel where the delegated prologue refuses (#25150)
Fixes #25106 Clause-②: no (narrowing) — on a walled posture, the security service's raw-SQL read scope for an on-behalf-of context whose live active organization the delegator's membership does not back is the deny sentinel, where it used to be a composed scope. Metadata-protocol: none Seat call of record: `6328262579` on the card (option A, the provider mirror). The claim `6328928374` carries `Clause-②: no`. This body adds the `(narrowing)` arm and the BREAKING banner, following #25063's precedent under the amended 「甲」 (the dispatch says to follow it and say so); the level is `patch`, per ruling A's level axis (`Metadata-protocol: none`). ⛔ Disclosure (#9387 ruling `5323310017`): this body states the rule and an abstract reach only. ## The rule ADR-0021 D-C makes the raw-SQL read scope the find path's scope, and `getReadFilter`'s own doc promises agreement with the engine middleware. Under `isolated` or `group`, the delegated-principal prologue (`resolveOperationPrincipals`) refuses every operation of an on-behalf-of context whose live active organization the delegator's own membership does not back (ADR-0090 D10, landed with #25063). `computeDelegatedReadFilter` mirrored the prologue's dangling-delegator refusal but not that one, so for such a context the provider answered a composed scope where the find path refuses. `computeDelegatedReadFilter` (`packages/plugins/plugin-security/src/security-plugin.ts`) now asks `vetOrganizationClaim` once, on the resolved-delegator arm, right after the dangling-delegator mirror and before the delegator's sets resolve: the prologue's position. It passes the prologue's inputs (the live organization, the delegator's memberships, the posture) and answers `RLS_DENY_FILTER` itself, by reference, where the prologue refuses. The refusal logs at `warn` (a refusal is not a fault), as the prologue's does. No error code, no new rule text: `vetOrganizationClaim` stays the one spelling of "a membership backs this organization". Nothing else in the file moves: not the prologue, not `getReadFilter`, not `canReadObject`. ## Reach, abstractly In process only. No served door builds the shape (readings `6328225139`, seat-verified in `6328262579`): the one producer of an on-behalf-of context, the `/mcp` OAuth door, vets the organization against the same membership, and the engine's own composition runs behind the prologue. The population is an in-process caller that hands such a context to the published provider, or to the analytics service. ## Boundary, before and after - **Before:** under `isolated` or `group`, the provider answered a composed read scope for that context, while the engine's find refused it `403 PERMISSION_DENIED`. - **After:** the provider answers the deny sentinel, so a door that bypasses the engine reads zero rows, on every object (public, private and organization-less alike: the rule is about the context). - **Unchanged:** the `single` posture; an on-behalf-of context with no active organization; an on-behalf-of context in an organization the delegator belongs to; a context without `onBehalfOf`; a dangling delegator (still the sentinel); system contexts. ## Pins All in `packages/plugins/plugin-security/src/delegated-organization-membership.test.ts`, the rule's existing file (real `ObjectQL` from source, real `SqlDriver`, real `SecurityPlugin`; the delegator rebuilt from stored rows). 17 new cases; the file goes from 25 to 42. - **Refusal (6):** `group` and `isolated` × a public, a private and an organization-less object: `getReadFilter` answers `toBe(RLS_DENY_FILTER)`. - **Preservation (9):** per walled posture, an in-set delegated context, the undelegated caller, a delegated context with no active organization and a system context; plus the `single` posture. Each scope is pinned by the rows it admits. - **Parity (2):** per walled posture, a table over the contexts the prologue decides × the three objects: find refused `403 PERMISSION_DENIED` ⇔ the provider's deny sentinel, and where find admits, the scope admits exactly the rows find returns. The table asserts its refused set, so neither side is vacuous. One-time proofs (not left as tests): - **Before/after answers.** The provider's answer, the rows it admits and the find outcome were recorded for 45 cells (three postures × five context kinds × three objects) on the unmodified provider and again with the fix. 39 answers are byte-identical (scope JSON and admitted rows). The 6 that changed are exactly the refusal cells, each of which find refuses. - **Ablation.** With the fix committed, `scripts/ablation-replace.mjs` replaced the `vetOrganizationClaim` ask with `false` (anchor x1 → x0, blob `130693ab6579` → `447156f4c27f`, marker on disk x1), and the file ran: `Tests 8 failed | 34 passed (42)`. The 8 are the 6 refusal cells and the 2 parity cells; every preservation cell and all 25 prologue cells stayed green. Restored by the tool: blob after restore = HEAD blob `130693ab6579`, `git diff HEAD` empty (re-read independently: `git status --porcelain` empty, `git hash-object` = `HEAD:` blob). The subject is imported from source (`./security-plugin.js`), so there is no `dist/` leg. - **Red first.** Before the fix, the 6 refusal cells were red and every other case green (`Tests 6 failed | 28 passed`, with the temporary measuring cell). The six existing `plugin-security` files the readings flagged as building delegated contexts through `getReadFilter` under a walled posture all stay green: their delegated cells are dangling-delegator or `single`-posture cases, or reach the prologue directly. No pin needed flipping. ## Tests and gates Read on head `03a9717ca` (the fix `58623eceb` plus a clean merge of `origin/main` `459afea1f`, which moved `packages/lint` and three driver-sql test files only; `@objectstack/lint` was rebuilt after it). Every run went through `scripts/pm/os-verify-lock.sh`; each exit code was captured before any pipe. - **Dependency closure (①):** `pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' build` (19 packages): `VERDICT command-exit 0`. - **The package (②):** `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: `Test Files 207 passed (207)`, `Tests 4253 passed | 45 skipped (4298)`. `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 (`check:test-typecheck: OK`; the new cases' file is in `tsconfig.test.json`'s program, counted with `--listFiles`). Read on `58623eceb`; on `03a9717ca` the rule's file and the six flagged files re-ran: `Test Files 7 passed (7)`, `Tests 340 passed (340)`. - **The derived gate families (③):** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `03a9717ca` derived 69 commands; 67 exit 0. Two exit 3, `PREREQUISITE NOT MET`, NOT MEASURED locally: `pnpm check:dual-build-cjs-loads` (needs every workspace package's `dist/`) and `pnpm check:i18n` (needs the built CLI and the build closures of nine packages). This diff changes no translation key, label or build output shape; CI's required `Lint & Repo Gates` runs both. `--ran` reconciliation: `69 derived, 67 run, 2 NOT-MEASURED, 0 UNRUN`, exit 0. - **Not run locally, declared to CI:** `test:repo` (the diff changes none of its tests), the type-check lanes and the path-scheduled CI jobs `dispatch-gates` names. - **Full dogfood battery:** owed on this head (the title carries `!`); its reading is reported in the `os-dev-report` comment on the card. ## Acceptance notes - **`canReadObject` does not vet the organization** (#25107 carries it, and #25107 remains open). After this change, an in-process analytics read of the refused shape is admitted at the object level and then answers zero rows through this scope, not a `403`. - **`plugin-audit`'s `parent-record-read-gate.ts` header is stale:** it still says the security service's read scope does not carry the delegator intersection, which `getReadFilter`'s doc and the engine's ADR-0139 D5.1 (as amended) contradict. The routing it describes stays safe. Documentation drift, outside this claim's surface; no carrier. - **The parity table covers the contexts the prologue decides.** An object-admission refusal (the D10 grant intersection) is `canReadObject`'s half, not the row scope's, for any caller; the preservation cell for a context with no active organization leaves the private object out for that reason. - **Observed, not investigated:** under a walled posture, the engine's find under a system context that carries an active organization returned only that organization's rows in this fixture, while the provider answers no scope for any system context. That is what its doc promises (it mirrors the middleware's system skip); noted only. - A review at `CONTRACT_REVIEW_TIER` is owed before the queue (the seat arranges it). --- _Generated by [Claude Code](https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2aaee3b commit 331613c

3 files changed

Lines changed: 192 additions & 2 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security)!: the raw-SQL read scope (`getReadFilter`) answers the deny sentinel for a delegated context the operation prologue refuses — the delegator's membership must back the organization the read runs in, on a walled posture
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- 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 read scope the security service answers 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. -->
11+
12+
**BREAKING** (the security service's read scope narrows for one context shape). ADR-0021 D-C makes the raw-SQL read scope the find path's scope, and the provider's own contract is agreement with the engine middleware. Under `isolated` or `group`, the delegated-principal prologue already refuses every operation of an on-behalf-of context whose active organization the delegator's membership does not back (ADR-0090 D10, `vetOrganizationClaim`). `getReadFilter` did not ask that question, so for the same context it answered a composed read scope where the find path refuses. It now asks it, once, beside the dangling-delegator refusal it already mirrored.
13+
14+
- **FROM:** under `isolated` or `group`, `getReadFilter` for an execution context carrying `onBehalfOf` and an active organization the delegator is not a member of answered a read scope composed from the two principals' layers, where the engine's find refuses the same context.
15+
- **TO:** it answers `RLS_DENY_FILTER` itself (the spec's sentinel, by reference), so a door that bypasses the engine reads zero rows for that context, as the engine's find refuses it.
16+
- **Unchanged:** the `single` posture, an on-behalf-of context with no active organization, an on-behalf-of context in an organization the delegator is a member of, a context without `onBehalfOf`, a dangling delegator (still the deny sentinel) 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 keep their scope.
17+
- **The fix for an in-process producer:** code that builds an `ExecutionContext` with `onBehalfOf` by hand and hands it to the security service or to the analytics service must set its `tenantId` to an organization the delegating user is a member of, or leave it unset.

‎packages/plugins/plugin-security/src/delegated-organization-membership.test.ts‎

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,24 @@
3939
* without the delegation link; a delegated context with no active
4040
* organization (it keeps ADR-0123 D2's refusal); a system context; and the
4141
* `single` posture, which has no wall.
42+
*
43+
* ## The raw-SQL read scope answers the same rule [#25106 · ADR-0021 D-C]
44+
*
45+
* `getReadFilter` is the read scope a door that bypasses the engine ANDs into
46+
* its query, and its contract is agreement with the find path. So where the
47+
* prologue refuses a delegated context, the provider answers the deny
48+
* sentinel (`RLS_DENY_FILTER` itself, by reference), on a public, a private
49+
* and an organization-less object alike: the rule is about the context, not
50+
* the object. Every context the prologue admits keeps the scope it had, and
51+
* one parity cell reads the provider beside the engine's own find over the
52+
* same contexts.
4253
*/
4354

4455
import { describe, it, expect, afterEach, vi } from 'vitest';
4556
import { ObjectQL } from '@objectstack/objectql';
4657
import { SqlDriver } from '@objectstack/driver-sql';
4758
import type { PermissionSet } from '@objectstack/spec/security';
59+
import { RLS_DENY_FILTER } from '@objectstack/spec/data';
4860
import { SecurityPlugin } from './security-plugin.js';
4961
import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity';
5062
import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js';
@@ -65,6 +77,9 @@ const FIELDS = {
6577
const OBJECTS = [
6678
{ name: 'qa_account', label: 'Account', fields: FIELDS },
6779
{ name: 'qa_vault', label: 'Vault', access: { default: 'private' }, fields: FIELDS },
80+
// [#25106] An organization-less object: no wall applies to it, so only the
81+
// context rule can answer the deny sentinel for it.
82+
{ name: 'qa_catalog', label: 'Catalog', tenancy: { enabled: false }, fields: FIELDS },
6883
];
6984

7085
const MEMBER: PermissionSet = {
@@ -201,7 +216,7 @@ async function boot(posture: Posture) {
201216
const rows = await knex(object).select('id', 'name', 'organization_id');
202217
return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id)));
203218
};
204-
return { engine, stored };
219+
return { engine, plugin, stored };
205220
}
206221

207222
interface Outcome { ok: boolean; value?: unknown; code?: string; status?: number; message?: string; developerMessage?: string }
@@ -305,3 +320,137 @@ describe('[#25063] what the rule leaves alone', () => {
305320
expect((await b.stored('qa_account')).find((r) => r.id === 'r1')).toEqual({ id: 'r1', name: 'one', organization_id: ORG_D });
306321
});
307322
});
323+
324+
// ---------------------------------------------------------------------------
325+
// [#25106 · ADR-0021 D-C] The raw-SQL read scope (`getReadFilter`) answers the
326+
// same rule. Its contract is agreement with the find path: where the prologue
327+
// refuses a delegated context, the provider answers the deny sentinel.
328+
// ---------------------------------------------------------------------------
329+
330+
const PROVIDER_OBJECTS = ['qa_account', 'qa_vault', 'qa_catalog'] as const;
331+
332+
/** One row per tenant object in `org_a` and `org_c` (`org_d`'s is `boot`'s), and two catalog rows, which carry no organization. */
333+
async function seedForReads(engine: ObjectQL) {
334+
for (const object of ['qa_account', 'qa_vault']) {
335+
for (const [org, suffix] of [[ORG_A, 'a'], [ORG_C, 'c']] as const) {
336+
await engine.insert(object, { id: `${object}_in_${suffix}`, name: 'seeded', organization_id: org }, { context: { ...SYS_CTX, tenantId: org } } as never);
337+
}
338+
}
339+
for (const id of ['qa_catalog_1', 'qa_catalog_2']) {
340+
await engine.insert('qa_catalog', { id, name: 'seeded' }, { context: SYS_CTX } as never);
341+
}
342+
}
343+
344+
const sortedIds = (rows: unknown) => (rows as Array<{ id: string }>).map((r) => r.id).sort();
345+
346+
/** The ids a read scope admits, applied as the only predicate under a system context — as a door that bypasses the engine applies it. */
347+
const idsAdmittedBy = async (engine: ObjectQL, object: string, scope: Record<string, unknown> | undefined) =>
348+
sortedIds(await engine.find(object, { where: scope ?? {}, context: { isSystem: true } } as never));
349+
350+
describe('[#25106 · ADR-0021 D-C] getReadFilter answers the deny sentinel where the prologue refuses', () => {
351+
for (const posture of ['group', 'isolated'] as const) {
352+
for (const object of PROVIDER_OBJECTS) {
353+
it(`\`${posture}\` · ${object}: a delegated context in an organization the delegator is not a member of answers RLS_DENY_FILTER itself`, async () => {
354+
const b = await boot(posture);
355+
await seedForReads(b.engine);
356+
expect(await b.plugin.getReadFilter(object, operator(ORG_D))).toBe(RLS_DENY_FILTER);
357+
});
358+
}
359+
}
360+
});
361+
362+
describe('[#25106] what the rule leaves alone in the read scope', () => {
363+
// Each answer below was read on the provider before this rule and after it,
364+
// and is the same: the rule moves only the contexts the prologue refuses.
365+
// A scope is pinned by the rows it admits (`idsAdmittedBy`).
366+
const scopeRows = async (b: Awaited<ReturnType<typeof boot>>, object: string, context: Record<string, unknown>) => {
367+
const scope = await b.plugin.getReadFilter(object, { ...context });
368+
expect(scope, `${object}: a scope, not the deny sentinel`).not.toBe(RLS_DENY_FILTER);
369+
return idsAdmittedBy(b.engine, object, scope);
370+
};
371+
372+
const IN_SET: Record<'group' | 'isolated', Record<string, string[]>> = {
373+
// `group` walls the delegator's leg by its own memberships (`org_a`, `org_c`);
374+
// the operator crosses on the private object, so its rows come from both.
375+
group: { qa_account: ['qa_account_in_a'], qa_vault: ['qa_vault_in_a', 'qa_vault_in_c'], qa_catalog: ['qa_catalog_1', 'qa_catalog_2'] },
376+
isolated: { qa_account: ['qa_account_in_a'], qa_vault: ['qa_vault_in_a'], qa_catalog: ['qa_catalog_1', 'qa_catalog_2'] },
377+
};
378+
379+
for (const posture of ['group', 'isolated'] as const) {
380+
it(`\`${posture}\`: a delegated context in an organization the delegator belongs to keeps its scope`, async () => {
381+
const b = await boot(posture);
382+
await seedForReads(b.engine);
383+
for (const object of PROVIDER_OBJECTS) {
384+
expect(await scopeRows(b, object, operator(ORG_A)), object).toEqual(IN_SET[posture][object]);
385+
}
386+
});
387+
388+
it(`\`${posture}\`: the same caller in \`org_d\` WITHOUT the delegation link keeps its scope`, async () => {
389+
const b = await boot(posture);
390+
await seedForReads(b.engine);
391+
expect(await scopeRows(b, 'qa_account', operator(ORG_D, false))).toEqual(['qa_account_in_d']);
392+
expect(await scopeRows(b, 'qa_vault', operator(ORG_D, false))).toEqual(['qa_vault_in_a', 'qa_vault_in_c', 'qa_vault_in_d']);
393+
expect(await scopeRows(b, 'qa_catalog', operator(ORG_D, false))).toEqual(['qa_catalog_1', 'qa_catalog_2']);
394+
});
395+
396+
it(`\`${posture}\`: a delegated context with NO active organization is not this rule's — its scope is unchanged`, async () => {
397+
// The wall decides the tenant object (no organization: no row), and the
398+
// organization-less object keeps its rows. The private object is left
399+
// out: the delegator holds its grant only inside an organization, so
400+
// object admission refuses it (`canReadObject`'s half), not this rule.
401+
const b = await boot(posture);
402+
await seedForReads(b.engine);
403+
expect(await scopeRows(b, 'qa_account', operator(undefined))).toEqual([]);
404+
expect(await scopeRows(b, 'qa_catalog', operator(undefined))).toEqual(['qa_catalog_1', 'qa_catalog_2']);
405+
});
406+
407+
it(`\`${posture}\`: a system context is unscoped`, async () => {
408+
const b = await boot(posture);
409+
for (const object of PROVIDER_OBJECTS) {
410+
expect(await b.plugin.getReadFilter(object, { ...SYS_CTX, onBehalfOf: { userId: DELEGATOR } }), object).toBeUndefined();
411+
}
412+
});
413+
}
414+
415+
it('`single`: there is no wall, so a delegated context in `org_d` keeps its scope', async () => {
416+
const b = await boot('single');
417+
await seedForReads(b.engine);
418+
expect(await scopeRows(b, 'qa_account', operator(ORG_D))).toEqual(['qa_account_in_a', 'qa_account_in_c', 'qa_account_in_d']);
419+
expect(await scopeRows(b, 'qa_vault', operator(ORG_D))).toEqual(['qa_vault_in_a', 'qa_vault_in_c', 'qa_vault_in_d']);
420+
expect(await scopeRows(b, 'qa_catalog', operator(ORG_D))).toEqual(['qa_catalog_1', 'qa_catalog_2']);
421+
});
422+
});
423+
424+
describe('[#25106 · ADR-0021 D-C] parity: the read scope agrees with the engine find on the same contexts', () => {
425+
for (const posture of ['group', 'isolated'] as const) {
426+
it(`\`${posture}\`: refused by find ⇔ the deny sentinel; admitted ⇒ the scope admits exactly the rows find returns`, async () => {
427+
const b = await boot(posture);
428+
await seedForReads(b.engine);
429+
const contexts: Record<string, Record<string, unknown>> = {
430+
'delegated in org_d': operator(ORG_D),
431+
'delegated in org_a': operator(ORG_A),
432+
'undelegated in org_d': operator(ORG_D, false),
433+
};
434+
const refused: string[] = [];
435+
for (const [label, context] of Object.entries(contexts)) {
436+
for (const object of PROVIDER_OBJECTS) {
437+
const cell = `${label} · ${object}`;
438+
const scope = await b.plugin.getReadFilter(object, { ...context });
439+
const found = await attempt(() => b.engine.find(object, { context: { ...context } } as never));
440+
if (!found.ok) {
441+
refused.push(cell);
442+
expect(found.code, cell).toBe('PERMISSION_DENIED');
443+
expect(found.status, cell).toBe(403);
444+
expect(scope, cell).toBe(RLS_DENY_FILTER);
445+
} else {
446+
expect(scope, cell).not.toBe(RLS_DENY_FILTER);
447+
expect(await idsAdmittedBy(b.engine, object, scope), cell).toEqual(sortedIds(found.value));
448+
}
449+
}
450+
}
451+
// The table is not vacuous on either side: find refuses exactly the
452+
// delegated context the delegator's membership does not back.
453+
expect(refused).toEqual(PROVIDER_OBJECTS.map((object) => `delegated in org_d · ${object}`));
454+
});
455+
}
456+
});

‎packages/plugins/plugin-security/src/security-plugin.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6081,7 +6081,10 @@ export class SecurityPlugin implements Plugin {
60816081
* Fails CLOSED where the chain refuses, with the deny sentinel by identity:
60826082
* a principal that resolves no set (ADR-0056 D2); a delegator the link names
60836083
* who does not exist (the chain's `PERMISSION_DENIED`; `canReadObject`'s
6084-
* `false`); a delegator who resolves no set (object admission refuses them);
6084+
* `false`); [#25106] on a walled posture, a live active organization the
6085+
* delegator's own membership does not back (the prologue's
6086+
* `PERMISSION_DENIED`, asked of `vetOrganizationClaim` with the prologue's
6087+
* inputs); a delegator who resolves no set (object admission refuses them);
60856088
* a sharing scope that cannot be resolved. A throw anywhere propagates to the
60866089
* caller's catch, which denies.
60876090
*
@@ -6106,6 +6109,27 @@ export class SecurityPlugin implements Plugin {
61066109
return RLS_DENY_FILTER;
61076110
}
61086111
const delegatorContext = del.context;
6112+
// [#25106 · ADR-0090 D10] The prologue's organization-membership refusal
6113+
// (`resolveOperationPrincipals`), mirrored at the same point: after the
6114+
// dangling-delegator refusal, before the delegator's sets resolve. On a
6115+
// walled posture the find path refuses every operation of a delegated
6116+
// context whose live active organization the delegator's own membership
6117+
// does not back, so the scope that agrees with it is zero rows, on every
6118+
// object. One ask of the one spelling of the rule, with the inputs the
6119+
// prologue passes; under `single` the claim stands, and a context with no
6120+
// active organization is not this rule's (ADR-0123 D2).
6121+
const liveOrganization =
6122+
context.tenantId != null && context.tenantId !== '' ? String(context.tenantId) : undefined;
6123+
if (
6124+
liveOrganization !== undefined &&
6125+
vetOrganizationClaim(liveOrganization, delegatorContext?.accessible_org_ids ?? [], this.tenancyPosture) === undefined
6126+
) {
6127+
this.logger.warn?.(
6128+
`[security] getReadFilter received an on-behalf-of context for object '${object}' whose delegator is not a ` +
6129+
`member of the organization it runs in — denying, as the operation prologue refuses it (ADR-0090 D10, fail-closed)`,
6130+
);
6131+
return RLS_DENY_FILTER;
6132+
}
61096133
const delegatorSets = await this.resolvePermissionSetsForContext(delegatorContext);
61106134
if (delegatorSets.length === 0) return RLS_DENY_FILTER;
61116135

0 commit comments

Comments
 (0)