Skip to content

Commit 449c858

Browse files
fix(plugin-security): canReadObject, getReadFilter and security/explain answer the delegated business-unit read as the read door does (#31142)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi9Hju7DThD7eoLKoJU9hnHj)_ Fixes #30758 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:委托管理员(只持有某个业务单元子树的 adminScope)能读到的业务单元,现在 `canReadObject`、`getReadFilter` 和 `security/explain` 都如实回答“能读,且只能读这几个”,与读门(find/findOne/count/aggregate)一致。 - 为什么改:读门早已按裁决放行这类读取,但三处“回答”仍说不能读,单元选择器和 explain 会拒绝委托管理员实际能读的单元(多拒,不多放)。 - 风险与回滚:三处都复用读门同一个判断函数,`canReadObject` 变 true 时 `getReadFilter` 同步收窄到所覆盖单元,绕过中间件的门不会读到范围外单元;回滚即还原本 PR 一个提交。 - 先看:`security-plugin-read-filter.ts`(收窄,最关键)、`explain-engine.ts`(object_crud 与记录级判定)、`security-plugin-object-admission.ts`(第 5 臂的补充)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run src/delegated-admin-own-name-set.posture.test.ts`,预期 57/57 通过,其中 #30758 新增的 24 个用例覆盖三种租户姿态。 - 需要你做的:无,席位落地。 ## What changes A delegated administrator reads the business units its admin scopes cover (ADR-0090 D12, ruling of record 6663849489). The read door admits that read beside the CRUD grant (`delegatedUnitReadScope`, which reached `main` in eb96480, the squash of #31031; #30747 itself closed unmerged). The answers outside the door did not reflect it. This PR makes them ask the door's own judgment; it does not change what the door admits. - `delegated-admin-gate.ts`: the judgment is split out as `delegatedUnitReadAdmission(gate, object, operation, context, sets, delegatorSets)`. `delegatedUnitReadScope` (the door's call) now checks only that the read carries a query and hands the rest to it, so there is one judgment and no second copy. Conditions are unchanged: a read (`find`, `findOne`, `count`, `aggregate`) of `sys_business_unit`, own behalf, gate composed, at least one covered unit, fail closed. - `canReadObject`: arm 5 (the `find` grant) accepts, where no set grants, the same admission the middleware makes at step 2. Arms 3, 4 and 6 are unchanged and still run. Object-level "some rows" follows the plugin's existing precedent for every row-restricted read: `canReadObject` answers `true` and `getReadFilter` carries the rows (its docblock: "`true` never means unrestricted — the row scope is still getReadFilter's"). - `getReadFilter`: where no set grants `find`, the same admission's confinement (an `id` `$in` filter over the covered units) is ANDed in beside RLS and controlled-by-parent, as step 3 ANDs it. Without this half, flipping `canReadObject` alone would have let a bypassing door read every unit of the organization (measured below). - `security/explain`: a new optional dep `delegatedUnitReadScope` (wired to the same judgment). `object_crud` grants through it only where the principal's sets do not; the `rls` layer's `readFilter` carries the confinement; the record pass reports a unit outside the scope as not visible (`decidedBy: object_crud`), which matters when an organization administrator explains the delegate. `export`, writes and on-behalf-of principals are not admitted, as at their doors. Before, measured on this branch's base 5f00374 with the new posture tests, identical in all three postures (`single`, `group`, `isolated`), delegate scope over `bu_east` + subtree, `bu_hq` outside: | question | read door | before | after | |:--|:--|:--|:--| | `find` / `findOne` / `count` / `aggregate` | `bu_east`, `bu_east_field` | same | same | | `canReadObject('sys_business_unit')` | | `false` | `true` | | units under `getReadFilter` | | all three, `bu_hq` included | `bu_east`, `bu_east_field` | | `explain` read: `allowed` / `object_crud` | | `false` / `denies` | `true` / `grants` | | units under `explain`'s `readFilter` | | all three | `bu_east`, `bu_east_field` | | `explain` record `bu_east` / `bu_hq` | | not visible / not visible | visible / not visible | ## Tests One new top-level `describe` in the existing shared harness `delegated-admin-own-name-set.posture.test.ts` (real `ObjectQL` over SQLite with the real `SecurityPlugin`), eight cases per posture: - `canReadObject` admits the delegate, and `getReadFilter` returns exactly the read door's units. - `security/explain` read: allowed, `object_crud` grants, `readFilter` returns exactly the door's units. - `security/explain` of one unit, asked by the delegate and by its organization administrator: covered units visible, `bu_hq` not. - `aggregate`: inside the scope counts the covered units, a `where` on `bu_hq` counts none, and a `groupBy` on an FLS-unreadable field is refused by step 2.5b (`forbiddenFields: ['external_ref']`). - CONTROLs: a caller with no scope is refused by door, `canReadObject` and explain alike; an organization administrator's answers are unchanged; acting on behalf of another user (even one who reads every unit) admits nothing anywhere; explain still denies `create` and `export`, and `canExport` stays `false`. The `east_admin` fixture set gains one field rule (`sys_business_unit.external_ref` unreadable) so 2.5b has a field to refuse. <details><summary>Ablations: every negative pin turned red, then restored by hash</summary> Each leg ran through `scripts/ablation-replace.mjs` (anchor hit x1 → x0, replacement x0 → x1), restored with `git checkout HEAD -- PATH`, then checked by `git hash-object` against the HEAD blob and an empty `git diff HEAD`. The test imports the plugin from `src`, so no build sits between the mutation and the run. | leg | mutation | red | |:--|:--|:--| | A1 | `getReadFilter` drops the unit scope | 3/57: units read `bu_east, bu_east_field, bu_hq` | | A2 | explain `filterParts` drops the unit scope | 3/57: explain read `readFilter` | | A3 | explain record pass drops the unit-scope check | 3/57: `organization administrator · bu_hq: expected true to be false` | | A4 | middleware step 3 drops `extra.push(delegatedUnitScope)` | 15/57, including the aggregate pin: `['east:1','east_field:1','hq:1']` | | A5 | step 2.5b condition `aggregate` renamed away | 3/57: `forbiddenFields` `undefined` (step 2.9 still refuses, without naming the field) | | A6 | `canReadObject` passes `null` for the delegator sets | 3/57: on-behalf-of control, `expected true to be false` | | A6b | explain wiring passes `null` for the delegator sets | 3/57: on-behalf-of control | | A7 | explain wiring asks `find` instead of the explained operation | 3/57: `create: expected true to be false` | | A8 | `canReadObject` treats a null admission as admitted | 6/57: no-scope control and on-behalf-of control | A first A6 run against a delegator holding no read of `sys_business_unit` stayed green: arm 6 (the delegator's own grant) already refuses it. The control now names a delegator who reads every unit, which is the case only the judgment's own on-behalf-of condition refuses; A6 and A6b are red against that version (commit 7792e80). </details> ## Acceptance notes - `canReadObject` is also asked by the engine's `ReadableIds` composition over a master (ADR-0139). An app object that is `controlled_by_parent` with `sys_business_unit` as its master would now let a delegate read that object's rows under the covered units, where it read none before. No platform object is such a detail (searched `packages/platform-objects/src`), and ADR-0055 makes reading a detail follow reading its master, so this follows the ruled read rather than widening past it. The seat should confirm this reading. - `objectReadAdmission`'s other asker is unchanged: it hands no fallback, so only `canReadObject` gains the delegated read. - Out of scope per the order and untouched: the ADR-0090 D12 note and the `reference_not_found` comment in `packages/spec/src/api/errors.zod.ts`. ## Verification <details><summary>Local runs on the final head</summary> - `pnpm --filter @objectstack/plugin-security test` :: exit 0 — Test Files 248 passed, Tests 5820 passed, 45 skipped (before and after the merge). - `pnpm --filter @objectstack/plugin-security typecheck` :: exit 0 (test layer: 0 files, 0 errors in the debt ledger). - eslint (repo binary, `--no-inline-config`) on the 7 touched source files :: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 64 families for this diff; all 64 ran on head 2d9ed89 and exited 0. `dispatch-gates --ran` with the exit-coded record :: exit 0 — "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). A first pass on an earlier head had four non-zero exits, all resolved before the run above: three exit 3 for missing prerequisites (a shallow clone for `check-plugin-teardown-shape --self-test`, the CLI build closure for `check:i18n`, a full `pnpm build` for `check:dual-build-cjs-loads`), and `check:query-options-erasure` exit 1 because the new test helpers cast three query-option bags `as any`; they are typed now (commit 2d9ed89). - After merging `origin/main` (two commits, neither in plugin-security; head 40c2c8a): formula, platform-objects, plugin-auth and service-settings rebuilt, then `pnpm --filter @objectstack/plugin-security typecheck` and `test` :: exit 0 (248 files, 5820 passed, 45 skipped), and the ratchet and changeset families again on 40c2c8a: ```text pnpm check:query-options-erasure :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:i18n :: exit 0 pnpm check:nul-bytes :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 ``` - The 64 on 2d9ed89: ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-tenant-audit-census.mjs :: exit 0 node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:authz-resolver :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-stale-fill :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vouch-for-reference-callers :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2bzfJoq2gdSaX2sVcL)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 56baf78 commit 449c858

8 files changed

Lines changed: 287 additions & 29 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): `canReadObject`, `getReadFilter` and `security/explain` answer a delegated administrator's read of the business units its scope covers as the read door does
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A delegated administrator reads the business units its admin scopes cover (ADR-0090 D12), and the read door already admitted that read for `find`, `findOne`, `count` and `aggregate`. The answers outside the door did not: `canReadObject('sys_business_unit')` answered `false`, so a unit picker or a door that compiles its own statement refused units the delegate can read, and `security/explain` reported the read as denied. All three now ask the read door's own judgment. `canReadObject` answers `true`, `getReadFilter` confines the read to the covered units (an `id` `$in` filter over them), and `security/explain` reports the read as allowed with the same confinement in its `readFilter`, and a unit outside the scope as not visible. Nothing the door refuses is admitted: a unit outside the scope, a read on behalf of another user, an export and every write stay refused, and a caller with no admin scope is answered as before.

‎packages/plugins/plugin-security/src/delegated-admin-gate.ts‎

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -437,27 +437,50 @@ const DELEGATED_READ_OPERATIONS: ReadonlySet<string> = new Set(['find', 'findOne
437437
* The authz middleware asks this only for an operation the caller's sets do
438438
* NOT grant, and admits it only on a non-null answer: the `where` scope
439439
* `id IN <the covered units>` it then ANDs into the read. Null — the ordinary
440-
* refusal stands — unless every condition holds: a read of
441-
* `sys_business_unit` carrying a query, on the caller's own behalf (no
442-
* on-behalf-of delegator), with the gate composed, and at least one covered
443-
* unit ({@link DelegatedAdminGate.scopedBusinessUnitIds}). Nothing wider: no
444-
* other object, no write, and no unit outside the scopes the caller holds; a
445-
* caller whose sets already grant the read is untouched. A scope that cannot
446-
* be resolved admits nothing (fail closed).
440+
* refusal stands — unless every condition holds: a read carrying a query, and
441+
* every condition of {@link delegatedUnitReadAdmission}, the one judgment the
442+
* answers outside the middleware ask too.
447443
*/
448444
export async function delegatedUnitReadScope(
449445
gate: Pick<DelegatedAdminGate, 'scopedBusinessUnitIds'> | null | undefined,
450446
opCtx: { object?: string; operation?: string; ast?: unknown; context?: any },
451447
permissionSets: PermissionSet[],
452448
delegatorSets: PermissionSet[] | null | undefined,
449+
): Promise<Record<string, unknown> | null> {
450+
if (!opCtx.ast) return null;
451+
return delegatedUnitReadAdmission(gate, opCtx.object, opCtx.operation, opCtx.context, permissionSets, delegatorSets);
452+
}
453+
454+
/**
455+
* [ADR-0090 D12, ruling of record 6663849489] The delegated business-unit read
456+
* itself, for an operation the caller's sets do NOT grant: the scope
457+
* `id IN <the covered units>` the read is confined to, or null when it
458+
* admits nothing. Asked by the read door ({@link delegatedUnitReadScope}) and
459+
* by every answer that must say what that door does — `canReadObject`,
460+
* `getReadFilter` and `security/explain` — so there is one judgment, never a
461+
* second copy of it.
462+
*
463+
* Non-null only when every condition holds: a read (`find`, `findOne`,
464+
* `count` or `aggregate`) of `sys_business_unit`, on the caller's own behalf
465+
* (no on-behalf-of delegator), with the gate composed, and at least one
466+
* covered unit ({@link DelegatedAdminGate.scopedBusinessUnitIds}). Nothing
467+
* wider: no other object, no write, no export, and no unit outside the scopes
468+
* the caller holds; a caller whose sets already grant the read is untouched.
469+
* A scope that cannot be resolved admits nothing (fail closed).
470+
*/
471+
export async function delegatedUnitReadAdmission(
472+
gate: Pick<DelegatedAdminGate, 'scopedBusinessUnitIds'> | null | undefined,
473+
object: string | undefined,
474+
operation: string | undefined,
475+
context: any,
476+
permissionSets: PermissionSet[],
477+
delegatorSets: PermissionSet[] | null | undefined,
453478
): Promise<Record<string, unknown> | null> {
454479
if (!gate || delegatorSets) return null;
455-
if (opCtx.object !== DELEGATED_READ_OBJECT || !DELEGATED_READ_OPERATIONS.has(String(opCtx.operation)) || !opCtx.ast) {
456-
return null;
457-
}
480+
if (object !== DELEGATED_READ_OBJECT || !DELEGATED_READ_OPERATIONS.has(String(operation))) return null;
458481
let ids: ReadonlySet<string>;
459482
try {
460-
ids = await gate.scopedBusinessUnitIds(permissionSets, opCtx.context);
483+
ids = await gate.scopedBusinessUnitIds(permissionSets, context);
461484
} catch {
462485
return null;
463486
}

‎packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts‎

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest';
2525
import { ObjectQL } from '@objectstack/objectql';
2626
import { SqlDriver } from '@objectstack/driver-sql';
2727
import { resolveThrownHttpError } from '@objectstack/types';
28+
import type { EngineAggregateOptions, EngineQueryOptions, FilterCondition } from '@objectstack/spec/data';
2829
import { SysUser, SysMember, SysBusinessUnit, SysBusinessUnitMember } from '@objectstack/platform-objects/identity';
2930

3031
import { SecurityPlugin } from './security-plugin.js';
@@ -63,7 +64,11 @@ const PERMISSIONS = [
6364
// Carries an adminScope equal to the wide delegate's own: not strictly contained by it.
6465
{ name: 'field_lead', label: 'Field lead (own name)', objects: CRUD, adminScope: eastScope(['field_user', 'field_rep', 'field_lead']) },
6566
// Narrow: allowlists the sets the positions LIST, not their own-name sets.
66-
{ name: 'east_admin', label: 'East delegate', objects: { ...CRUD, [UNIT_NOTE]: NOTE_CRUD }, adminScope: eastScope(['field_user']) },
67+
// [#30758] It may not read `external_ref`, so the aggregate field guard (step 2.5b) has a field to refuse.
68+
{
69+
name: 'east_admin', label: 'East delegate', objects: { ...CRUD, [UNIT_NOTE]: NOTE_CRUD }, adminScope: eastScope(['field_user']),
70+
fields: { 'sys_business_unit.external_ref': { readable: false } },
71+
},
6772
// [#29029] A writer of the unit note with no admin scope at all.
6873
{ name: 'note_writer', label: 'Note writer', objects: { [UNIT_NOTE]: NOTE_CRUD } },
6974
// Wide: also allowlists the own-name sets.
@@ -327,3 +332,118 @@ describe('[ADR-0090 D12, ruling of record 6663849489; #29029] a delegate reads t
327332
});
328333
}
329334
});
335+
336+
/** [#30758] The unit ids the system context reads under `filter` — what a door that compiles its own statement returns. */
337+
async function unitsUnder(h: Awaited<ReturnType<typeof boot>>, filter: Record<string, unknown> | null | undefined): Promise<string[]> {
338+
const query: EngineQueryOptions = { ...(filter ? { where: filter as FilterCondition } : {}), context: SYS };
339+
const rows = await h.engine.find('sys_business_unit', query);
340+
return (Array.isArray(rows) ? rows : []).map((r: any) => String(r.id)).sort();
341+
}
342+
343+
/** [#30758] An aggregate of `sys_business_unit` as `context`: the group names it counts, or the refusal. */
344+
async function unitAggregate(
345+
h: Awaited<ReturnType<typeof boot>>,
346+
context: any,
347+
query: { where?: FilterCondition; groupBy: string[] },
348+
): Promise<string[] | { status: number; code?: string; forbiddenFields?: unknown }> {
349+
try {
350+
const aggregate: EngineAggregateOptions = { ...query, aggregations: [{ function: 'count', alias: 'n' }] };
351+
const rows = await h.engine.aggregate('sys_business_unit', aggregate, { context });
352+
return (Array.isArray(rows) ? rows : []).map((r: any) => `${String(r[query.groupBy[0]])}:${Number(r.n)}`).sort();
353+
} catch (e) {
354+
const r = resolveThrownHttpError(e);
355+
return { status: r.status, code: r.code, forbiddenFields: (e as any)?.details?.forbiddenFields };
356+
}
357+
}
358+
359+
const layerOf = (decision: any, layer: string) => decision.layers.find((l: any) => l.layer === layer);
360+
361+
describe('[ADR-0090 D12, ruling of record 6663849489; #30758] canReadObject and security/explain answer the delegated business-unit read as the read door does — three postures', () => {
362+
const WRITER = delegateCtx('u_writer', 'note_writer');
363+
const ORG_ADMIN = delegateCtx('u_org_admin', 'organization_admin');
364+
365+
for (const posture of POSTURES) {
366+
describe(`posture '${posture}'`, () => {
367+
it('canReadObject admits the delegate, and getReadFilter scopes a bypassing door to exactly the units the read door returns', async () => {
368+
const h = await boot(posture);
369+
const door = await unitsRead(h, NARROW);
370+
expect(door).toEqual(['bu_east', 'bu_east_field']);
371+
expect(await h.plugin.canReadObject('sys_business_unit', NARROW)).toBe(true);
372+
expect(await unitsUnder(h, await h.plugin.getReadFilter('sys_business_unit', NARROW))).toEqual(door);
373+
});
374+
375+
it('security/explain read: allowed, the object grant names the delegated scope, and its readFilter returns exactly the read door\'s units', async () => {
376+
const h = await boot(posture);
377+
const d: any = await h.plugin.explainAccessForCaller({ object: 'sys_business_unit', operation: 'read' }, NARROW);
378+
expect(d.allowed).toBe(true);
379+
expect(layerOf(d, 'object_crud').verdict).toBe('grants');
380+
expect(await unitsUnder(h, d.readFilter)).toEqual(await unitsRead(h, NARROW));
381+
});
382+
383+
it('security/explain of one unit: the units the scope covers are visible, the unit above it is not — asked by the delegate and by its organization administrator', async () => {
384+
const h = await boot(posture);
385+
await h.engine.insert('sys_user_permission_set',
386+
{ id: 'ups_delegate', user_id: 'u_delegate', permission_set: 'east_admin', organization_id: ORG_A }, { context: { isSystem: true, tenantId: ORG_A } });
387+
for (const [asker, request] of [
388+
['delegate', (recordId: string) => h.plugin.explainAccessForCaller({ object: 'sys_business_unit', operation: 'read', recordId }, NARROW)],
389+
['organization administrator', (recordId: string) =>
390+
h.plugin.explainAccessForCaller({ object: 'sys_business_unit', operation: 'read', userId: 'u_delegate', recordId }, ORG_ADMIN)],
391+
] as const) {
392+
for (const unit of ['bu_east', 'bu_east_field']) {
393+
const d: any = await request(unit);
394+
expect(d.record?.visible, `${asker} · ${unit}`).toBe(true);
395+
}
396+
const above: any = await request('bu_hq');
397+
expect(above.record?.visible, `${asker} · bu_hq`).toBe(false);
398+
}
399+
});
400+
401+
it('aggregate is the read door too: inside the scope it counts the covered units, outside it counts none, and the field guard still refuses an unreadable field', async () => {
402+
const h = await boot(posture);
403+
expect(await unitAggregate(h, NARROW, { groupBy: ['name'] })).toEqual(['east:1', 'east_field:1']);
404+
expect(await unitAggregate(h, NARROW, { where: { id: 'bu_hq' }, groupBy: ['name'] })).toEqual([]);
405+
expect(await unitAggregate(h, NARROW, { groupBy: ['external_ref'] }))
406+
.toEqual({ status: 403, code: 'PERMISSION_DENIED', forbiddenFields: ['external_ref'] });
407+
// CONTROL: the organization administrator counts every unit; a caller with no scope is refused the object.
408+
expect(await unitAggregate(h, ORG_ADMIN, { groupBy: ['name'] })).toEqual(['east:1', 'east_field:1', 'hq:1']);
409+
expect(await unitAggregate(h, WRITER, { groupBy: ['name'] })).toEqual({ status: 403, code: 'PERMISSION_DENIED', forbiddenFields: undefined });
410+
});
411+
412+
it('CONTROL: a caller holding no scope is still refused by the door, canReadObject and explain alike', async () => {
413+
const h = await boot(posture);
414+
expect(await unitsRead(h, WRITER)).toEqual({ status: 403, code: 'PERMISSION_DENIED' });
415+
expect(await h.plugin.canReadObject('sys_business_unit', WRITER)).toBe(false);
416+
const d: any = await h.plugin.explainAccessForCaller({ object: 'sys_business_unit', operation: 'read' }, WRITER);
417+
expect(d.allowed).toBe(false);
418+
expect(layerOf(d, 'object_crud').verdict).toBe('denies');
419+
});
420+
421+
it('CONTROL: an organization administrator\'s answers are unchanged — every unit, granted by its own set', async () => {
422+
const h = await boot(posture);
423+
expect(await h.plugin.canReadObject('sys_business_unit', ORG_ADMIN)).toBe(true);
424+
expect(await unitsUnder(h, await h.plugin.getReadFilter('sys_business_unit', ORG_ADMIN))).toEqual(['bu_east', 'bu_east_field', 'bu_hq']);
425+
const d: any = await h.plugin.explainAccessForCaller({ object: 'sys_business_unit', operation: 'read' }, ORG_ADMIN);
426+
expect(d.allowed).toBe(true);
427+
expect(layerOf(d, 'object_crud').contributors.map((c: any) => c.name)).toContain('organization_admin');
428+
});
429+
430+
it('CONTROL: the delegated read is the caller\'s own — acting on behalf of another user, even one who reads every unit, no answer admits it, as the door does not', async () => {
431+
const h = await boot(posture);
432+
const onBehalf = { ...NARROW, principalKind: 'agent', onBehalfOf: { userId: 'u_org_admin' } };
433+
expect(await unitsRead(h, onBehalf as any)).toEqual({ status: 403, code: 'PERMISSION_DENIED' });
434+
expect(await h.plugin.canReadObject('sys_business_unit', onBehalf)).toBe(false);
435+
const d: any = await h.plugin.explainAccessForCaller({ object: 'sys_business_unit', operation: 'read' }, onBehalf);
436+
expect(d.allowed).toBe(false);
437+
});
438+
439+
it('CONTROL: the delegated scope grants a read and nothing else — explain still denies create and export, as their doors do', async () => {
440+
const h = await boot(posture);
441+
for (const operation of ['create', 'export'] as const) {
442+
const d: any = await h.plugin.explainAccessForCaller({ object: 'sys_business_unit', operation }, NARROW);
443+
expect(d.allowed, operation).toBe(false);
444+
}
445+
expect(await h.plugin.canExport('sys_business_unit', NARROW)).toBe(false);
446+
});
447+
});
448+
}
449+
});

0 commit comments

Comments
 (0)