diff --git a/.changeset/25107-delegation-probes-organization-membership.md b/.changeset/25107-delegation-probes-organization-membership.md new file mode 100644 index 00000000000..f68111d470d --- /dev/null +++ b/.changeset/25107-delegation-probes-organization-membership.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security)!: the delegation probes, field projections, explanation and narrowing diagnostic answer the refusal the operation prologue makes for a delegated context whose delegator is not a member of the organization it runs in, on a walled posture + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** (the security service's answers narrow for one context shape, and `ExplainEngineDeps` gains a required member). Under `isolated` or `group`, the delegated-principal prologue refuses every operation of an on-behalf-of context whose active organization the delegator's own membership does not back (ADR-0090 D10, `vetOrganizationClaim`), and the raw-SQL read scope answers that refusal too. The other readers in this package that resolve a delegator on their own did not ask that question, so for the same context they answered an admission the operation refuses. Each now asks it at the prologue's point, through one helper (`delegatedOrganizationRefusal`) that the prologue and the read scope ask as well. + +- **FROM:** under `isolated` or `group`, for an execution context carrying `onBehalfOf` and an active organization the delegator is not a member of, `canReadObject`, `canWriteObject` and `canExport` could answer `true`; `getReadableFields`, `getMetadataReadableFields`, `getWritableFields` and `getQueryableFields` answered the intersected field list; the access explanation reported the D10 intersection and could answer `allowed: true`; `describeDelegationNarrowing` could report a narrowing; and the explanation's per-record write gate asked the sharing service as the agent and the delegator. +- **TO:** the three probes answer `false`; the four field projections answer `[]`; the explanation answers `allowed: false`, its principal and object_crud layers `denies`, naming the organization the delegator is not a member of; `describeDelegationNarrowing` answers `{ narrowed: false }`, as for a dangling delegator; and the per-record write gate refuses without asking the sharing service. +- **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, 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 every answer. +- **The fix for an in-process producer:** code that builds an `ExecutionContext` with `onBehalfOf` by hand must set its `tenantId` to an organization the delegating user is a member of, or leave it unset. +- **`ExplainEngineDeps`:** an embedder that builds the explain engine's dependencies itself passes the deployment's tenancy posture as `tenancyPosture`, the value the security plugin's enforcement runs under. The compiler names the missing member. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index e084f60b8d5..77dec6f211a 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -102,7 +102,7 @@ that silently does not happen. | 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable (`getReadableFields`) and writable (`getWritableFields`). Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#resolveProjectionFieldMask` | | 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` | | 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` | -| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | +| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture, the D10 dangling delegator and, on a walled posture, the D10 delegator who is not a member of the organization the request runs in), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | | 6c | The tenant wall's per-row verdict for the write PREVIEW answers **"not asked"** | plugin-security | Get: `writeWallVerdict` returns `null`, so `ObjectQL.validate()` treats the rows exactly as it does with no verdict registered: no row is refused by the Layer 0 tenant wall, and the elevated preview's `readonly` strips stay skipped as they always were. That is what the elevated write does: the whole security middleware short-circuits before step 3.7, so the wall never judges an elevated row's organization. Lose: nothing the write enforces. The verdict exists so a preview answers a row's organization as its write answers it (#25062), and an elevated write meets no tenant wall to answer for | `packages/plugins/plugin-security/src/security-plugin.ts#writeWallVerdict` | | 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | diff --git a/packages/plugins/plugin-security/src/delegated-organization-membership.test.ts b/packages/plugins/plugin-security/src/delegated-organization-membership.test.ts index 73cbbeecddb..e5da9abc336 100644 --- a/packages/plugins/plugin-security/src/delegated-organization-membership.test.ts +++ b/packages/plugins/plugin-security/src/delegated-organization-membership.test.ts @@ -50,9 +50,25 @@ * the object. Every context the prologue admits keeps the scope it had, and * one parity cell reads the provider beside the engine's own find over the * same contexts. + * + * ## The probes and the diagnostics answer the same rule [#25107] + * + * Every other reader in this plugin that resolves a delegator on its own asks + * the prologue's check too (`delegatedOrganizationRefusal`, the one ask of + * `vetOrganizationClaim` they share with the prologue), so none of them + * answers an admission for a context the operation refuses: the admission + * probes `canReadObject`, `canWriteObject` and `canExport` answer `false`; the + * four field projections answer no field; the explanation denies on the + * principal layer; the narrowing diagnostic reports nothing; and the + * explanation's per-record write gate asks the sharing service nothing. Every + * context the prologue admits keeps its answer, and a parity cell reads each + * probe beside the engine operation it describes. */ import { describe, it, expect, afterEach, vi } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import type { PermissionSet } from '@objectstack/spec/security'; @@ -80,19 +96,24 @@ const OBJECTS = [ // [#25106] An organization-less object: no wall applies to it, so only the // context rule can answer the deny sentinel for it. { name: 'qa_catalog', label: 'Catalog', tenancy: { enabled: false }, fields: FIELDS }, + // [#25107] Read only by the narrowing diagnostic's cells: the delegator reads + // it org-wide, and the agent's ceiling declares owner-only. + { name: 'qa_ledger', label: 'Ledger', fields: FIELDS }, ]; +// [#25107] `allowExport` on every grant, so the export probe's admitted +// cells are admitted by a grant and only this rule can refuse one. const MEMBER: PermissionSet = { name: 'member_default', label: 'Member', - objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true } }, } as unknown as PermissionSet; const PLATFORM_ADMIN: PermissionSet = { name: 'admin_full_access', label: 'Platform Administrator', objects: { - '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true, viewAllRecords: true, modifyAllRecords: true }, }, systemPermissions: ['manage_platform_settings', 'manage_metadata'], } as unknown as PermissionSet; @@ -101,10 +122,24 @@ const PLATFORM_ADMIN: PermissionSet = { const VAULT_WRITER: PermissionSet = { name: 'qa_vault_writer', label: 'Vault writer', - objects: { qa_vault: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + objects: { qa_vault: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true } }, +} as unknown as PermissionSet; + +/** [#25107] The delegator reads the ledger org-wide... */ +const LEDGER_READER: PermissionSet = { + name: 'qa_ledger_reader', + label: 'Ledger reader', + objects: { qa_ledger: { allowRead: true, viewAllRecords: true } }, +} as unknown as PermissionSet; + +/** ...and an agent's ceiling declares it owner-only, so the narrowing diagnostic has something to say. */ +const QA_CEILING: PermissionSet = { + name: 'qa_ceiling', + label: 'Agent ceiling', + objects: { qa_ledger: { allowRead: true, readScope: 'own' } }, } as unknown as PermissionSet; -const SETS = [MEMBER, PLATFORM_ADMIN, VAULT_WRITER]; +const SETS = [MEMBER, PLATFORM_ADMIN, VAULT_WRITER, LEDGER_READER, QA_CEILING]; const SYS_CTX = { isSystem: true, userId: 'usr_system', tenantId: ORG_A }; @@ -121,6 +156,19 @@ function operator(activeOrganization: string | undefined, delegated = true) { }; } +/** [#25107] An agent acting for the delegator under the ceiling above. */ +function agent(activeOrganization: string) { + return { + userId: 'usr_agent', + principalKind: 'agent', + tenantId: activeOrganization, + accessible_org_ids: [activeOrganization], + positions: [], + permissions: ['qa_ceiling'], + onBehalfOf: { userId: DELEGATOR }, + }; +} + type Posture = 'group' | 'isolated' | 'single'; const engines: ObjectQL[] = []; @@ -130,7 +178,7 @@ afterEach(async () => { } }); -async function boot(posture: Posture) { +async function boot(posture: Posture, options: { sharing?: Record } = {}) { const engine = new ObjectQL(); engine.registerDriver( new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never) as never, @@ -164,14 +212,17 @@ async function boot(posture: Posture) { await engine.insert('sys_member', { id: `mem_${org}`, user_id: DELEGATOR, organization_id: org, role: 'member' }, { context: SYS_CTX } as never); } const driverOf = (o: string) => (engine as unknown as { getDriver(x: string): { create(x: string, r: Record): Promise } }).getDriver(o); - await driverOf('sys_permission_set').create('sys_permission_set', { - id: 'ps_qa_vault_writer', name: 'qa_vault_writer', label: 'Vault writer', organization_id: null, managed_by: 'admin', active: true, - object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), - }); + for (const [id, name, label] of [['ps_qa_vault_writer', 'qa_vault_writer', 'Vault writer'], ['ps_qa_ledger_reader', 'qa_ledger_reader', 'Ledger reader']]) { + await driverOf('sys_permission_set').create('sys_permission_set', { + id, name, label, organization_id: null, managed_by: 'admin', active: true, + object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), + }); + } // The grant in `org_d` too: the D10 grant intersection must not be what // refuses a cell below, or removing the rule would leave the cell green. for (const org of [ORG_A, ORG_C, ORG_D]) { await engine.insert('sys_user_permission_set', { id: `ups_${org}`, user_id: DELEGATOR, permission_set: 'qa_vault_writer', organization_id: org }, { context: SYS_CTX } as never); + await engine.insert('sys_user_permission_set', { id: `ups_ledger_${org}`, user_id: DELEGATOR, permission_set: 'qa_ledger_reader', organization_id: org }, { context: SYS_CTX } as never); } // One stored row per object in `org_d`, for the read, update and delete cells. for (const object of ['qa_account', 'qa_vault']) { @@ -193,13 +244,15 @@ async function boot(posture: Posture) { services['org-scoping'] = { name: 'com.objectstack.org-scoping' }; services.tenancy = { posture }; } + if (options.sharing) services.sharing = options.sharing; + const registered = new Map(); const ctx = { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, // `kernel:ready` never fires here: without a `hook` the plugin starts its // platform bootstrap un-awaited, and a short case then destroys the engine // under that bootstrap's reads. Nothing this file pins reads what it sets. hook: () => undefined, - registerService: vi.fn(), + registerService: (name: string, service: unknown) => { registered.set(name, service); }, getService: (name: string) => { if (!(name in services)) throw new Error(`service not registered: ${name}`); return services[name]; @@ -216,7 +269,10 @@ async function boot(posture: Posture) { const rows = await knex(object).select('id', 'name', 'organization_id'); return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id))); }; - return { engine, plugin, stored }; + const security = () => registered.get('security') as { + describeDelegationNarrowing(object: string, context: unknown): Promise<{ narrowed: boolean; effectiveScope?: string }>; + }; + return { engine, plugin, stored, security }; } interface Outcome { ok: boolean; value?: unknown; code?: string; status?: number; message?: string; developerMessage?: string } @@ -454,3 +510,191 @@ describe('[#25106 · ADR-0021 D-C] parity: the read scope agrees with the engine }); } }); + +// --------------------------------------------------------------------------- +// [#25107 · ADR-0090 D10] The admission probes, the field projections, the +// explanation and the narrowing diagnostic answer the same rule: where the +// prologue refuses a delegated context, none of them answers an admission. +// --------------------------------------------------------------------------- + +type Booted = Awaited>; + +const FIELD_PROJECTIONS = ['getReadableFields', 'getMetadataReadableFields', 'getWritableFields', 'getQueryableFields'] as const; + +/** Every probe this card covers, for one context on one object: what each answers. */ +async function probeAnswers(b: Booted, object: string, context: Record) { + const explained = await b.plugin.explainAccessForCaller({ object, operation: 'read' }, { ...context }); + const fields: Record = {}; + for (const projection of FIELD_PROJECTIONS) fields[projection] = await b.plugin[projection](object, { ...context }); + return { + canReadObject: await b.plugin.canReadObject(object, { ...context }), + canWriteInsert: await b.plugin.canWriteObject(object, 'insert', { ...context }, { id: 'probe', name: 'probe' }), + canWriteUpdate: await b.plugin.canWriteObject(object, 'update', { ...context }, { name: 'probe' }), + canExport: await b.plugin.canExport(object, { ...context }), + fields, + explained, + }; +} + +const MEMBERSHIP_DETAIL = (organization: string) => `not a member of organization '${organization}'`; + +describe('[#25107 · ADR-0090 D10] the probes answer the refusal where the prologue refuses', () => { + for (const posture of ['group', 'isolated'] as const) { + for (const object of PROVIDER_OBJECTS) { + it(`\`${posture}\` · ${object}: every admission probe is false, every field projection is empty, and the explanation denies`, async () => { + const b = await boot(posture); + const answers = await probeAnswers(b, object, operator(ORG_D)); + expect(answers.canReadObject, 'canReadObject').toBe(false); + expect(answers.canWriteInsert, 'canWriteObject insert').toBe(false); + expect(answers.canWriteUpdate, 'canWriteObject update').toBe(false); + expect(answers.canExport, 'canExport').toBe(false); + for (const projection of FIELD_PROJECTIONS) expect(answers.fields[projection], projection).toEqual([]); + + const { explained } = answers; + expect(explained.allowed).toBe(false); + const layer = (name: string) => explained.layers.find((l) => l.layer === name); + expect(layer('principal')?.verdict).toBe('denies'); + expect(layer('principal')?.detail).toContain(MEMBERSHIP_DETAIL(ORG_D)); + expect(layer('object_crud')?.verdict).toBe('denies'); + expect(layer('object_crud')?.detail).toContain(MEMBERSHIP_DETAIL(ORG_D)); + }); + } + + it(`\`${posture}\`: the narrowing diagnostic reports nothing for a delegated read the prologue refuses`, async () => { + const b = await boot(posture); + expect(await b.security().describeDelegationNarrowing('qa_ledger', agent(ORG_D))).toEqual({ narrowed: false }); + }); + + it(`\`${posture}\`: the explanation's per-record write gate asks the sharing service nothing for such a context`, async () => { + const canEdit = vi.fn(async () => true); + const b = await boot(posture, { sharing: { canEdit } }); + const explained = await b.plugin.explainAccessForCaller( + { object: 'qa_account', operation: 'update', recordId: 'qa_account_in_d' }, + operator(ORG_D), + ); + expect(explained.allowed).toBe(false); + expect(canEdit).not.toHaveBeenCalled(); + }); + } +}); + +describe('[#25107] what the rule leaves alone in the probes', () => { + const ALL_FIELDS = ['id', 'name']; + const expectAdmitted = (answers: Awaited>, cell: string) => { + expect(answers.canReadObject, `${cell}: canReadObject`).toBe(true); + expect(answers.canWriteInsert, `${cell}: canWriteObject insert`).toBe(true); + expect(answers.canWriteUpdate, `${cell}: canWriteObject update`).toBe(true); + expect(answers.canExport, `${cell}: canExport`).toBe(true); + for (const projection of FIELD_PROJECTIONS) { + expect(answers.fields[projection], `${cell}: ${projection}`).toEqual(expect.arrayContaining(ALL_FIELDS)); + } + expect(answers.explained.allowed, `${cell}: explain`).toBe(true); + expect(answers.explained.layers.find((l) => l.layer === 'principal')?.verdict, `${cell}: principal layer`).toBe('neutral'); + }; + + for (const posture of ['group', 'isolated'] as const) { + it(`\`${posture}\`: a delegated context in an organization the delegator belongs to is admitted by every probe`, async () => { + const b = await boot(posture); + for (const object of PROVIDER_OBJECTS) expectAdmitted(await probeAnswers(b, object, operator(ORG_A)), object); + }); + + it(`\`${posture}\`: the same caller in \`org_d\` WITHOUT the delegation link is admitted by every probe`, async () => { + const b = await boot(posture); + for (const object of PROVIDER_OBJECTS) expectAdmitted(await probeAnswers(b, object, operator(ORG_D, false)), object); + }); + + it(`\`${posture}\`: a delegated context with NO active organization is not this rule's — the read probes keep their answer`, async () => { + // Its writes are ADR-0123 D2's, and the private object's grant lives + // only inside an organization, so the public objects' reads are the + // cells this rule could have moved and did not. + const b = await boot(posture); + for (const object of ['qa_account', 'qa_catalog']) { + expect(await b.plugin.canReadObject(object, operator(undefined)), object).toBe(true); + expect(await b.plugin.getReadableFields(object, operator(undefined)), object).toEqual(expect.arrayContaining(ALL_FIELDS)); + } + }); + + it(`\`${posture}\`: the narrowing diagnostic still reports a narrowed delegated read in an organization the delegator belongs to`, async () => { + const b = await boot(posture); + const verdict = await b.security().describeDelegationNarrowing('qa_ledger', agent(ORG_A)); + expect(verdict.narrowed).toBe(true); + expect(verdict.effectiveScope).toBe('own'); + }); + + it(`\`${posture}\`: the explanation's per-record write gate still asks the sharing service as the agent, then as the delegator`, async () => { + const canEdit = vi.fn(async (_object: string, _recordId: string, _context: { userId?: string; onBehalfOf?: unknown }) => true); + const b = await boot(posture, { sharing: { canEdit } }); + await seedForReads(b.engine); + await b.plugin.explainAccessForCaller( + { object: 'qa_account', operation: 'update', recordId: 'qa_account_in_a' }, + operator(ORG_A), + ); + expect(canEdit.mock.calls.map(([, , c]) => [c.userId, c.onBehalfOf ? 'on behalf' : 'own'])).toEqual([ + ['usr_operator', 'on behalf'], + [DELEGATOR, 'own'], + ]); + }); + } + + it('`single`: there is no wall, so a delegated context in `org_d` is admitted by every probe and its narrowing is reported', async () => { + const b = await boot('single'); + for (const object of PROVIDER_OBJECTS) expectAdmitted(await probeAnswers(b, object, operator(ORG_D)), object); + expect((await b.security().describeDelegationNarrowing('qa_ledger', agent(ORG_D))).narrowed).toBe(true); + }); +}); + +describe('[#25107 · ADR-0090 D10] parity: each probe agrees with the engine operation it describes', () => { + for (const posture of ['group', 'isolated'] as const) { + it(`\`${posture}\`: refused by the engine ⇔ the probe refuses, for a read, an insert and an export`, async () => { + const b = await boot(posture); + await seedForReads(b.engine); + const contexts: Record> = { + 'delegated in org_d': operator(ORG_D), + 'delegated in org_a': operator(ORG_A), + 'undelegated in org_d': operator(ORG_D, false), + }; + const refused: string[] = []; + let n = 0; + for (const [label, context] of Object.entries(contexts)) { + for (const object of PROVIDER_OBJECTS) { + const cell = `${label} · ${object}`; + const found = await attempt(() => b.engine.find(object, { context: { ...context } } as never)); + const row = { id: `parity_${++n}`, name: 'parity' }; + const inserted = await attempt(() => b.engine.insert(object, { ...row }, { context: { ...context } } as never)); + const answers = await probeAnswers(b, object, context); + if (!found.ok) refused.push(cell); + expect(answers.canReadObject, `${cell}: canReadObject ⇔ find`).toBe(found.ok); + expect(answers.canExport, `${cell}: canExport ⇔ find (an export reads as a find)`).toBe(found.ok); + expect(answers.explained.allowed, `${cell}: explain read ⇔ find`).toBe(found.ok); + expect(answers.canWriteInsert, `${cell}: canWriteObject insert ⇔ insert`).toBe(inserted.ok); + } + } + // Not vacuous on either side: the engine refuses exactly the delegated + // context the delegator's membership does not back. + expect(refused).toEqual(PROVIDER_OBJECTS.map((object) => `delegated in org_d · ${object}`)); + }); + } +}); + +describe('[#25107] every reader in this package that resolves a delegator asks the organization refusal', () => { + // An enumeration pin over this package's own source: a reader that resolves + // a delegator and does not ask the prologue's check is the defect this card + // closed, at a site nobody has written yet. Each source file must ask the + // check as often as it resolves a delegator (the definitions excluded). + const SRC = dirname(fileURLToPath(import.meta.url)); + const calls = (text: string, name: string) => + (text.match(new RegExp(`\\b${name}\\(`, 'g')) ?? []).length - (text.match(new RegExp(`function ${name}\\(`, 'g')) ?? []).length; + + it('per source file, the asks equal the resolutions — and the two files that resolve one are both counted', () => { + const counted: Record = {}; + for (const file of readdirSync(SRC, { recursive: true }) as string[]) { + if (!file.endsWith('.ts') || file.endsWith('.test.ts') || file.includes('.testkit.')) continue; + const text = readFileSync(join(SRC, file), 'utf8'); + const resolutions = calls(text, 'resolveDelegatorContext'); + if (resolutions === 0) continue; + counted[file] = [resolutions, calls(text, 'delegatedOrganizationRefusal')]; + } + expect(Object.keys(counted).sort()).toEqual(['explain-engine.ts', 'security-plugin.ts']); + for (const [file, [resolutions, asks]] of Object.entries(counted)) expect(asks, file).toBe(resolutions); + }); +}); diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts index 703574a3274..4a15cdf6d38 100644 --- a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -500,6 +500,7 @@ describe('the read question, engine level: who it is asked of, and how often', ( getFieldMask: () => ({}), getPartialMaskRules: async () => ({}), baselinePermissionSets: [], + tenancyPosture: 'single', computeLayeredRlsFilter: async () => ({ layer0: null, layer1: null }), fetchRecord: async (_o, id) => (id === STORED.id ? { ...STORED } : null), recordAbsentToCaller: question, diff --git a/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts b/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts index 2b8c5e1512d..b91d766d577 100644 --- a/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts +++ b/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts @@ -83,6 +83,7 @@ function deps(opts: { getFieldMask: () => ({}), getPartialMaskRules: async () => ({}), baselinePermissionSets: ['member_default'], + tenancyPosture: 'single', computeLayeredRlsFilter: async () => ({ layer0: { organization_id: 'org1' }, layer1: opts.layer1 ?? null }), fetchRecord: async () => (opts.record !== undefined ? opts.record : { ...RECORD }), sharingReadFilter: async () => null, diff --git a/packages/plugins/plugin-security/src/explain-dependency-fault.test.ts b/packages/plugins/plugin-security/src/explain-dependency-fault.test.ts index 7c3e69c218b..9954ad48b25 100644 --- a/packages/plugins/plugin-security/src/explain-dependency-fault.test.ts +++ b/packages/plugins/plugin-security/src/explain-dependency-fault.test.ts @@ -120,6 +120,11 @@ function makeEngine(schema: Record, storeFaults: ReadonlyMap & { sets?: any[]; schema getFieldMask: () => ({}), getPartialMaskRules: async () => ({}), baselinePermissionSets: ['member_default'], + tenancyPosture: 'single', ...overrides, }; } diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index c788b207abe..aee792270aa 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -26,6 +26,7 @@ import { readDisabledCatalogNames, resolveUserAuthzGrants, securityCatalogReaderOf, + vetOrganizationClaim, } from '@objectstack/core'; import { crossFieldClassRefusalCarriedBy, @@ -51,6 +52,7 @@ import type { ExplainMatchedRule, ExplainOperation, ExplainRecordAttribution, + TenancyPosture, } from '@objectstack/spec/security'; import type { PermissionEvaluator } from './permission-evaluator.js'; import { superuserBypassBitForOperation } from './permission-evaluator.js'; @@ -335,6 +337,18 @@ export interface ExplainEngineDeps { * anything at all". */ baselinePermissionSets: string[]; + /** + * [#25107 · ADR-0090 D10] The tenancy posture enforcement runs under, the + * one the delegated-principal prologue hands {@link delegatedOrganizationRefusal}, + * so an on-behalf-of context whose live organization the delegator's + * membership does not back is explained as the denial the operation answers. + * + * REQUIRED, deliberately, as {@link getPartialMaskRules} is: an engine wired + * without it would explain such a context as admitted, the misreport this + * member exists to close, and a required member makes the omission a compile + * error. + */ + tenancyPosture: TenancyPosture; // ── [C2 / ADR-0095] Record-grained deps. All OPTIONAL: absent → the engine // stays object-level and byte-compatible. Present → the sharing / rls / owd / @@ -746,10 +760,14 @@ export type DelegatorResolution = * intersect against baseline-level access. The `sys_user` existence check is * the only correct fail-closed point. * - **Tenant-scoped bags are inherited from the live principal, and the - * inheritance still WINS.** The agent and its delegator are, by construction, - * in the same org, so `tenantId` / `org_user_ids` carry over — delegator-side - * RLS that substitutes them then compiles faithfully instead of collapsing to - * the deny sentinel. Since #6352, `buildContextForUser` returns the resolver's + * inheritance still WINS.** The delegated operation runs in the live + * principal's organization, so `tenantId` / `org_user_ids` carry over — + * delegator-side RLS that substitutes them then compiles faithfully instead + * of collapsing to the deny sentinel. [#25107] That the delegator belongs to + * that organization is not assumed here: on a walled posture it is the + * precondition {@link delegatedOrganizationRefusal} vets, and every reader of + * this resolution asks it before it uses the delegator (the operation + * prologue refuses the operation, each probe answers that refusal). Since #6352, `buildContextForUser` returns the resolver's * own `org_user_ids`; the live principal's org peer set is the answer the * delegated request runs under, so the assignment below overwrites it * exactly as before. The delegator's GRANTS are the one tenant-scoped thing @@ -803,7 +821,9 @@ export async function resolveDelegatorContext( ? context.tenantId : undefined; const dctx = await buildContextForUser(ql, oboId, nowMs, liveTenantId); - // Inherit tenant-scoped substitution bags from the live principal (same org). + // Inherit tenant-scoped substitution bags from the live principal: the + // organization the operation runs in, which the delegator's membership must + // back on a walled posture ({@link delegatedOrganizationRefusal}). if (context?.tenantId != null) dctx.tenantId = context.tenantId; if (context?.org_user_ids != null) dctx.org_user_ids = context.org_user_ids; // [ADR-0105 D2] The delegator's own org access set is NOT inherited — it is @@ -814,6 +834,44 @@ export async function resolveDelegatorContext( return { kind: 'resolved', context: dctx }; } +/** + * [#25063 · ADR-0090 D10] The organization a delegated operation is REFUSED + * in: the live principal's active organization, answered where the + * delegator's own membership does not back it on this tenancy posture, and + * `undefined` where the operation may run there. + * + * A delegated operation runs in the live principal's organization and is + * bounded by the delegator's scope; {@link resolveDelegatorContext} hands the + * delegator that organization. On a walled posture the bound holds only while + * the delegator is a member of it, so the delegated-principal prologue + * (`resolveOperationPrincipals`) refuses every verb where this answers an + * organization. [#25106 / #25107] Every reader that resolves a delegator on + * its own asks it too, at the prologue's point (a delegator that resolved, + * before its sets resolve), and answers that refusal: the raw-SQL read scope + * the deny sentinel, the admission probes `false`, the field projections no + * field, the explanation a denial, the narrowing diagnostic nothing. + * + * It is {@link vetOrganizationClaim} asked once, with the prologue's inputs: + * the live organization (`context.tenantId`), the delegator's OWN memberships + * (`accessible_org_ids`, never inherited), and the posture. Under `single` + * the claim stands, and a context with no active organization is not this + * rule's (ADR-0123 D2). ⛔ Not a second spelling of the membership rule: a + * reader that needs it calls this, and this calls the one function that + * spells it. + */ +export function delegatedOrganizationRefusal( + context: any, + delegatorContext: any, + tenancyPosture: TenancyPosture, +): string | undefined { + const liveOrganization = + context?.tenantId != null && context.tenantId !== '' ? String(context.tenantId) : undefined; + if (liveOrganization === undefined) return undefined; + return vetOrganizationClaim(liveOrganization, delegatorContext?.accessible_org_ids ?? [], tenancyPosture) === undefined + ? liveOrganization + : undefined; +} + const SCOPE_ORDER = ['own', 'own_and_reports', 'unit', 'unit_and_below', 'org'] as const; /** @@ -2228,6 +2286,11 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: // fail-closed denial like a missing delegator, never "no delegation", which // would let the agent's own grants decide alone. let delegatorUnresolved = false; + // [#25107 · ADR-0090 D10] The organization the operation runs in, when the + // delegator's own membership does not back it on a walled posture: the + // prologue refuses every verb there, so this report is that denial, made + // like a missing delegator's before any delegator set resolves. + let delegatorOutsideOrganization: string | undefined; let delegatorNames: string[] = []; if (context?.onBehalfOf?.userId) { const del = await settle(resolveDelegatorContext(deps.ql, context)); @@ -2236,11 +2299,15 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: } else if (del.kind === 'missing') { delegatorMissing = true; } else if (del.kind === 'resolved') { - delegatorContextForRls = del.context; - delegatorSets = await deps.resolveSets(del.context).catch(() => [] as PermissionSet[]); - delegatorNames = delegatorSets.map((s: any) => String(s.name ?? '?')); + delegatorOutsideOrganization = delegatedOrganizationRefusal(context, del.context, deps.tenancyPosture); + if (delegatorOutsideOrganization === undefined) { + delegatorContextForRls = del.context; + delegatorSets = await deps.resolveSets(del.context).catch(() => [] as PermissionSet[]); + delegatorNames = delegatorSets.map((s: any) => String(s.name ?? '?')); + } } } + const delegatorRefused = delegatorMissing || delegatorUnresolved || delegatorOutsideOrganization !== undefined; const positions: string[] = context?.positions ?? []; const viaOf = (name: string): string => { if (deps.baselinePermissionSets.includes(name)) return 'additive baseline (ADR-0090 D5)'; @@ -2282,7 +2349,7 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: const delegatorOf = (from: string): string => positionDelegators.get(from) ?? from; layers.push({ layer: 'principal', - verdict: delegatorMissing || delegatorUnresolved ? 'denies' : 'neutral', + verdict: delegatorRefused ? 'denies' : 'neutral', detail: `Principal ${principalPerson} holds position(s) [${positions.join(', ') || 'none'}] ` + `resolving to permission set(s) [${setNames.join(', ') || 'none'}] (union-merged, most-permissive).` + @@ -2291,7 +2358,10 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: ? ` Acting on behalf of ${delegatorPerson}, who no longer exists — D10 fails CLOSED (access denied).` : delegatorUnresolved ? ` Acting on behalf of ${delegatorPerson}, whose grants could not be resolved (the lookup threw) — D10 fails CLOSED (access denied).` - : ` Acting on behalf of ${delegatorPerson} — effective access is the D10 INTERSECTION with the delegator's set(s) [${delegatorNames.join(', ') || 'none'}].` + : delegatorOutsideOrganization !== undefined + ? ` Acting on behalf of ${delegatorPerson}, who is not a member of organization '${delegatorOutsideOrganization}', ` + + `the one this request runs in — D10 fails CLOSED (access denied).` + : ` Acting on behalf of ${delegatorPerson} — effective access is the D10 INTERSECTION with the delegator's set(s) [${delegatorNames.join(', ') || 'none'}].` : '') + (delegatedPositions.length > 0 ? ` ${delegatedPositions.length} position(s) held via delegation (ADR-0091 D3): [${delegatedPositions @@ -2408,7 +2478,7 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: // that layer's grant is computed FROM, and reporting the real cause beats a // misleading "no set grants it" when the sets were never the problem. const postureUnresolved = secMeta.unresolved === true; - const crudAllowed = agentCrud && delegatorCrud && !delegatorMissing && !delegatorUnresolved && !postureUnresolved; + const crudAllowed = agentCrud && delegatorCrud && !delegatorRefused && !postureUnresolved; const granting = postureUnresolved ? [] : sets @@ -2429,10 +2499,13 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: ? `Delegator no longer exists — D10 fails closed (access denied).` : delegatorUnresolved ? `The delegator's grants could not be resolved — D10 fails closed (access denied).` - : agentCrud && !delegatorCrud - ? `The agent grants ${operation} on '${object}' but the DELEGATOR does not — D10 intersection denies (an agent may not exceed the user it acts for).` - : `No resolved permission set grants ${operation} on '${object}'` + - (secMeta.isPrivate ? " (object is 'private' posture — non-superuser '*' wildcards are excluded, ADR-0066 D2)." : '.'), + : delegatorOutsideOrganization !== undefined + ? `The delegator is not a member of organization '${delegatorOutsideOrganization}', the one this request ` + + `runs in — D10 fails closed (access denied).` + : agentCrud && !delegatorCrud + ? `The agent grants ${operation} on '${object}' but the DELEGATOR does not — D10 intersection denies (an agent may not exceed the user it acts for).` + : `No resolved permission set grants ${operation} on '${object}'` + + (secMeta.isPrivate ? " (object is 'private' posture — non-superuser '*' wildcards are excluded, ADR-0066 D2)." : '.'), contributors: granting.map((n) => ({ kind: 'permission_set' as const, name: n, via: viaOf(n) })), }); diff --git a/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts b/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts index 2277b202566..2fe9f05bb2d 100644 --- a/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts +++ b/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts @@ -82,6 +82,7 @@ function makeDeps(): ExplainEngineDeps { getFieldMask: () => ({}), getPartialMaskRules: async () => ({}), baselinePermissionSets: ['member_default'], + tenancyPosture: 'single', }; } diff --git a/packages/plugins/plugin-security/src/explain-read-absent-record-story.test.ts b/packages/plugins/plugin-security/src/explain-read-absent-record-story.test.ts index 789071dbd6c..1d1cd9c3a6a 100644 --- a/packages/plugins/plugin-security/src/explain-read-absent-record-story.test.ts +++ b/packages/plugins/plugin-security/src/explain-read-absent-record-story.test.ts @@ -288,6 +288,7 @@ describe('the tenant layer of a by-id write explanation of a row the caller cann getFieldMask: () => ({}), getPartialMaskRules: async () => ({}), baselinePermissionSets: [], + tenancyPosture: 'single', computeLayeredRlsFilter: async () => ({ layer0: WALL, layer1: null }), fetchRecord: async () => stored, ...(read ? { recordAbsentToCaller: async () => read.absent } : {}), diff --git a/packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts b/packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts index 645b3f99bf0..127a24917d8 100644 --- a/packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts +++ b/packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts @@ -202,6 +202,7 @@ describe('[#3545] unresolvable object metadata — security posture fails closed getFieldMask: () => ({}), getPartialMaskRules: async () => ({}), baselinePermissionSets: ['member_default'], + tenancyPosture: 'single', }) as any; const ctx = { userId: 'u1', positions: ['everyone'], permissions: [] }; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index dcb4f8b8fc0..4e9f0dc892a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -45,6 +45,7 @@ import { explainAccessForAsker, buildContextForUser, resolveDelegatorContext, + delegatedOrganizationRefusal, intersectFieldMasks, intersectDelegatedScope, d10NarrowingStatement, @@ -2333,6 +2334,10 @@ export class SecurityPlugin implements Plugin { // A dangling delegator is a fail-CLOSED DENIAL upstream, not a // narrowing — the read never returns rows for this to describe. if (del.kind !== 'resolved') return none; + // [#25107] Nor is a delegator who is not a member of the + // organization the read runs in, on a walled posture: the prologue + // refuses the read ({@link delegatedOrganizationRefusal}). + if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) return none; const meta = await this.getObjectSecurityMeta(object); const sets = await this.resolvePermissionSetsForContext(context); const delegatorSets = await this.resolvePermissionSetsForContext(del.context); @@ -5143,11 +5148,14 @@ export class SecurityPlugin implements Plugin { * * A delegation link naming no user (`missing`) answers `null`: the write * path refuses such a principal before any gate runs, and so does every - * gate built on this. + * gate built on this. [#25107] So does a delegator who is not a member of + * the organization the write runs in, on a walled posture: the prologue + * refuses it there ({@link delegatedOrganizationRefusal}). */ const delegatedWriteContexts = async (o: string, c: any): Promise<{ agent: any; delegator: any } | null> => { const del = await resolveDelegatorContext(this.ql, c); if (del.kind !== 'resolved') return null; + if (delegatedOrganizationRefusal(c, del.context, this.tenancyPosture) !== undefined) return null; const [sets, delegatorSets, meta] = await Promise.all([ this.resolvePermissionSetsForContext(c), this.resolvePermissionSetsForContext(del.context), @@ -5212,6 +5220,9 @@ export class SecurityPlugin implements Plugin { return this.computeReadPartialMaskRules(meta, sets as any, delSets as any, basePerms, delBasePerms); }, baselinePermissionSets: this.baselinePermissionSets, + // [#25107] The prologue's posture, for its organization-membership + // refusal of a delegated context ({@link delegatedOrganizationRefusal}). + tenancyPosture: this.tenancyPosture, // ── record-grained deps (only consulted when recordId is present) ── computeLayeredRlsFilter: async (sets, o, engineOp, c) => this.computeLayeredRlsFilter( @@ -5961,15 +5972,10 @@ export class SecurityPlugin implements Plugin { // walled posture the find path refuses every operation of a delegated // context whose live active organization the delegator's own membership // does not back, so the scope that agrees with it is zero rows, on every - // object. One ask of the one spelling of the rule, with the inputs the - // prologue passes; under `single` the claim stands, and a context with no - // active organization is not this rule's (ADR-0123 D2). - const liveOrganization = - context.tenantId != null && context.tenantId !== '' ? String(context.tenantId) : undefined; - if ( - liveOrganization !== undefined && - vetOrganizationClaim(liveOrganization, delegatorContext?.accessible_org_ids ?? [], this.tenancyPosture) === undefined - ) { + // object. [#25107] The prologue's own ask ({@link delegatedOrganizationRefusal}), + // with the inputs it passes; under `single` the claim stands, and a context + // with no active organization is not this rule's (ADR-0123 D2). + if (delegatedOrganizationRefusal(context, delegatorContext, this.tenancyPosture) !== undefined) { this.logger.warn?.( `[security] getReadFilter received an on-behalf-of context for object '${object}' whose delegator is not a ` + `member of the organization it runs in — denying, as the operation prologue refuses it (ADR-0090 D10, fail-closed)`, @@ -6215,7 +6221,10 @@ export class SecurityPlugin implements Plugin { // [ADR-0090 D10] On an on-behalf-of request the projection must NOT widen // past the DELEGATOR's — intersect the delegator's field mask too. A // dangling delegator fails CLOSED (expose no columns), the same fail-closed - // stance the CRUD middleware takes on a 'missing' delegator. [#20995] Asked + // stance the CRUD middleware takes on a 'missing' delegator; [#25107] and so + // does a delegator who is not a member of the organization the request runs + // in, on a walled posture, which the middleware's prologue refuses + // ({@link delegatedOrganizationRefusal}). [#20995] Asked // only when the caller resolved a set, as the middleware asks it: a caller // who resolved none reaches here only for its masking rules and [#21063] // its field capability contract, and every one of them already applies to @@ -6226,6 +6235,9 @@ export class SecurityPlugin implements Plugin { const del = await resolveDelegatorContext(this.ql, context); if (del.kind === 'missing') return { kind: 'answer', fields: [] }; if (del.kind === 'resolved') { + if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) { + return { kind: 'answer', fields: [] }; + } delegatorSets = await this.resolvePermissionSetsForContext(del.context); delBasePerms = this.permissionEvaluator.getFieldPermissions(objectName, delegatorSets); let delFieldPerms = this.foldFieldRequiredPermissions(delBasePerms, secMeta.fieldRequiredPermissions, delegatorSets); @@ -6376,12 +6388,16 @@ export class SecurityPlugin implements Plugin { if (permissionSets.length === 0) return false; // [ADR-0090 D10] Resolve the delegator ONCE — arms 4 and 6 both need it, - // and a dangling link denies before either runs. + // and a dangling link denies before either runs. [#25107] So does a + // delegator who is not a member of the organization the read runs in, on + // a walled posture: the middleware's prologue refuses the read there + // ({@link delegatedOrganizationRefusal}). let delegatorSets: PermissionSet[] | null = null; if (context?.onBehalfOf?.userId) { const del = await resolveDelegatorContext(this.ql, context); if (del.kind === 'missing') return false; if (del.kind === 'resolved') { + if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) return false; delegatorSets = await this.resolvePermissionSetsForContext(del.context); } } @@ -6718,13 +6734,17 @@ export class SecurityPlugin implements Plugin { if (unresolved) return false; // [ADR-0090 D10] Resolve the delegator ONCE — the arms below need it, - // and a dangling link denies before any of them runs. + // and a dangling link denies before any of them runs. [#25107] So does a + // delegator who is not a member of the organization the write runs in, + // on a walled posture: the middleware's prologue refuses the write there + // ({@link delegatedOrganizationRefusal}). let delegatorSets: PermissionSet[] | null = null; let delegatorContext: unknown = null; if (context?.onBehalfOf?.userId) { const del = await resolveDelegatorContext(this.ql, context); if (del.kind === 'missing') return false; if (del.kind === 'resolved') { + if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) return false; delegatorContext = del.context; delegatorSets = await this.resolvePermissionSetsForContext(del.context); } @@ -6831,10 +6851,14 @@ export class SecurityPlugin implements Plugin { // middleware and {@link getReadableFields} take. [#21079] So does a // delegator who resolves NO set: the evaluator answers that empty list's // export as it answers any list, and the middleware refuses its read. + // [#25107] So does a delegator who is not a member of the organization the + // export runs in, on a walled posture: the middleware's prologue refuses + // the read there ({@link delegatedOrganizationRefusal}). if (context?.onBehalfOf?.userId) { const del = await resolveDelegatorContext(this.ql, context); if (del.kind === 'missing') return false; if (del.kind === 'resolved') { + if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) return false; const delegatorSets = await this.resolvePermissionSetsForContext(del.context); if ( !this.permissionEvaluator.checkObjectPermission('export', objectName, delegatorSets, { isPrivate }) @@ -9247,8 +9271,8 @@ export class SecurityPlugin implements Plugin { * access for a non-existent user). * - [#25063 · ADR-0090 D10] A delegated operation runs in the LIVE principal's * active organization, and the delegator context inherits that organization - * (`resolveDelegatorContext`: the agent and its delegator are in the same - * organization). On a walled posture that is a precondition, never an + * (`resolveDelegatorContext` hands the delegator the organization the + * operation runs in). On a walled posture that is a precondition, never an * assumption: when the delegator's own membership does not back the live * organization, the operation is REFUSED here, for every verb. Left to run, * the delegator's leg could not bound it: the inherited organization feeds @@ -9256,8 +9280,11 @@ export class SecurityPlugin implements Plugin { * on-behalf-of write could land outside every organization the delegator * belongs to. The test is `vetOrganizationClaim`, the one spelling of "a * membership backs this organization", which answers the claim unchanged - * under `single`. A context with no active organization is not this - * rule's: its answers stay with ADR-0123 D2. + * under `single`, asked through `delegatedOrganizationRefusal` [#25107]: + * the one ask every reader of a resolved delegator shares, so the probes, + * the field projections, the read scope and the explanation answer this + * refusal where it is made. A context with no active organization is not + * this rule's: its answers stay with ADR-0123 D2. */ private async resolveOperationPrincipals( operation: string, @@ -9297,12 +9324,8 @@ export class SecurityPlugin implements Plugin { ); } if (del.kind === 'resolved') { - const liveOrganization = - context.tenantId != null && context.tenantId !== '' ? String(context.tenantId) : undefined; - if ( - liveOrganization !== undefined && - vetOrganizationClaim(liveOrganization, del.context?.accessible_org_ids ?? [], this.tenancyPosture) === undefined - ) { + const liveOrganization = delegatedOrganizationRefusal(context, del.context, this.tenancyPosture); + if (liveOrganization !== undefined) { this.logger.warn?.( `[Security] on-behalf-of ${operation} '${object}' REFUSED — the delegator is not a member of the ` + `organization the request runs in (ADR-0090 D10, fail-closed)`, diff --git a/packages/plugins/plugin-security/src/unpublished-object-deny-message.test.ts b/packages/plugins/plugin-security/src/unpublished-object-deny-message.test.ts index b1734a4a84f..67d4eeafcae 100644 --- a/packages/plugins/plugin-security/src/unpublished-object-deny-message.test.ts +++ b/packages/plugins/plugin-security/src/unpublished-object-deny-message.test.ts @@ -262,6 +262,7 @@ describe('[#10401] unpublished object — the deny stays, the explanation gets h getFieldMask: () => ({}), getPartialMaskRules: async () => ({}), baselinePermissionSets: ['member_default'], + tenancyPosture: 'single', }) as any; const explainCtx = { userId: 'u1', positions: ['everyone'], permissions: [] };