Skip to content

Commit cd6618e

Browse files
fix(plugin-security): a delegated refusal names one reason for a target user the caller cannot read (#31384)
Clause-②: no — no accept set changes at the write door: every write refused before is still refused, and every write admitted is still admitted; no error code, class or status is added. Metadata-protocol: none ## 维护者速读 - 改了什么:委派管理员(业务单元范围)直接授予权限集时,只要目标用户不在其委派子树内,拒绝就只有一个答案:`403 DELEGATION_SCOPE_VIOLATION`,同一句话、同一组 details,与目标用户是否存在、是否持有业务单元成员关系无关;授权写里点名的目标用户先按调用者自己的读权限查一次,读不到的按"不存在"处理,同样是这个答案。 - 为什么改:加固规则——委派拒绝对调用者读不到的目标用户只给一个理由;目标用户只有经由子树内的业务单元才算在子树内。 - 风险与回滚:风险低,写入口的放行与拒绝集合不变(原本被拒的仍被拒,原本放行的仍放行),不新增错误码;每次点名目标的委派授权多一次以调用者身份对 `sys_user` 的按 id 读。回滚即撤销本 PR(源码改动只在 `assertDirectGrantWrite` 及其三个小助手)。 - 先看:① `packages/plugins/plugin-security/src/delegated-admin-gate.ts` 的 `assertDirectGrantWrite`(`userBUs` 那一段与子树判断,风险 hunk),以及新增的 `callerReadsUser`、`namesTargetUser`、`referenceCheckAsksCallerAboutUser`;② `packages/plugins/plugin-security/src/delegated-admin-gate.test.ts` 末尾新增的 describe 与 `[#26189]` 列表里改动的一条;③ `.changeset/delegated-grant-refusal-one-reason.md`。 - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/delegated-admin-gate.test.ts`,预期 `Test Files 1 passed`、`Tests 165 passed (165)`。 - 需要你做的:无,席位落地。 ## What changes - **Rule.** A target user is inside a delegated business-unit subtree only through a business unit of that subtree. A delegated administrator's direct permission-set grant whose target user is outside the subtree is refused with one answer, whether or not the user exists or holds a business-unit membership: `403 DELEGATION_SCOPE_VIOLATION`, the same sentence (`target user 'ID' is outside the delegated subtree (scope from 'SET')`, or its unnamed form) and the same details. - **A target the caller cannot read.** A target user the grant NAMES (a new grant, or a stored grant pointed at another user) is first asked of the caller's own read door: one `sys_user` read by id, projected to `id`, under the caller's context (`callerReadsUser`). It is the question the engine's reference check asks of that user (`masterDetailHeadersCallerReads`, `packages/objectql/src/engine-referential-integrity.ts:501`) and the grant-holder membership hook asks before it (`callerReadsHolder`, `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts:350`). Neither can be called from the gate (the first is objectql's, which this package does not depend on; the second reads through a hook's `ctx.api`), so the gate asks the same read itself, and asks it only where the reference check would (`referenceCheckAsksCallerAboutUser`: `sys_user` registered and the live `user_id` a writable lookup; the grant object declares the lookup's target itself, so it is not resolved again). A user the read does not return is answered as a user that does not exist, the platform's rule for a user a non-system write names: outside the subtree, the same answer. A read the read door refuses (a declared 4xx) returns no user; a store fault propagates, as for the gate's other reads. - **Where.** `DelegatedAdminGate.assertDirectGrantWrite` (`packages/plugins/plugin-security/src/delegated-admin-gate.ts`): one subtree test replaces two branches, and the target's memberships are read only for a target the caller may be judged on. Nothing else in the gate moves. - **Rulings kept** (each quoted from the file and unchanged): - ruling on #26059, Q3 (`ScopeViolation` docblock): "A per-scope rejection reason that describes the caller's OWN delegated scope: a business unit outside the subtree, a set not in the allowlist, a target user outside the subtree … Only these answer `DelegationScopeViolationError`; a plain string reason keeps `PermissionDeniedError`." A user with no business unit in the subtree is a target user outside it, so the rule answers that class; every plain-string reason keeps `PERMISSION_DENIED`. - ruling of record 6649064874 (`mayName`): "Only a value the caller already has … a value it may not name is left out of the sentence and out of the details." The target is still named only when the caller wrote it or reads it. - ruling of record 5985885287 (`UnreadableAddressedRowRefusal`): "an id naming no row and a row the caller cannot read, refused alike." Untouched: a by-id write whose row the caller cannot read is still refused before any target user is judged. ## Tests - New top-level describe at the foot of `delegated-admin-gate.test.ts` (the gate's shared harness file), over a harness that registers `sys_user`, makes the grant's `user_id` a lookup to it and serves the caller a `sys_user` read without the users it cannot read. One target id answers a refusal identical in class, name, `code`, `status`, `statusCode`, message, details and the REST door's classification (`mapDataError`), and that answer is the scope violation (message matched whole, details matched exactly): - `insert` and `update` (the writes that name the target), across four states: an id naming no user, a user with no business-unit membership, a user whose business unit is outside the subtree, and a user with a business unit inside the subtree whom the caller cannot read; - `delete` (a stored grant of the target removed), across the first three. - CONTROL: the same id with a business unit inside the subtree, read by the caller, is admitted on all three shapes. - Ablation (one-time, nothing left in the tree), each leg through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed), then restored (blob equals `HEAD`, `git diff HEAD` empty): - the caller's read of a named target removed: 2 failed of 165 (the `insert` and `update` pins), every other test green; - the no-membership branch put back: 3 failed of 165 (the three shape pins), every other test green; - restored: 165 of 165 green. - Door reading (one-time, nothing left in the tree): the real `ObjectQL` engine over a real SQL driver with the real `SecurityPlugin` registered, postures `single`, `group` and `isolated`, two delegates (one whose set grants `sys_user` read, one whose set does not). With the rule, a new grant and a stored grant pointed at the target answer one identical envelope per delegate across the four states, the first of them an id no user carries (12 readings each; the rig's re-pointed grants each carry their own set name, the only difference in their details), and a target with a business unit inside the subtree that the caller reads is admitted on every shape. - `pnpm --filter @objectstack/plugin-security test`: 249 of 249 files, 5867 passed, 45 skipped; `pnpm --filter @objectstack/plugin-security typecheck` (tsc, scripts tsconfig and `check:test-typecheck`) green. ## Acceptance notes - Read in code, not measured, not filed: the write-admission preview (`canWriteObject`, `security-plugin-object-admission.ts`) returns `false` only for a `PermissionDeniedError` out of the gate; a `DelegationScopeViolationError` reaches its outer catch, which also answers `false` but logs the refusal as an admission it could not resolve. The answer is right; the log line names a failure that did not happen. Outside this PR's file surface. - `test:repo` (the vitest `repo` project) was not run locally: the diff edits none of its tests and no derived family names it; `Test Core` runs it. ## Verification All local readings below are at `842b5ef6d`. <details> <summary>Derived gate families: 66 of 66 run, all exit 0; reconciliation 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN</summary> Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (3 paths vs merge base `e2e6ec7dc`), each exit code captured before any pipe, then reconciled with `--ran`. ```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:adr-symbol-anchors · 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: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:object-def-param-keys · exit 0 pnpm check:objectql-double-limit · exit 0 pnpm check:optional-error-sink · 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> <details> <summary>Package suites and typecheck</summary> ```text pnpm --filter @objectstack/plugin-security test Test Files 249 passed (249) · Tests 5867 passed, 45 skipped (5912) pnpm --filter @objectstack/plugin-security typecheck tsc --noEmit, tsc -p tsconfig.scripts.json, check:test-typecheck OK (0 files, 0 errors) pnpm --filter @objectstack/core test:repo Test Files 7 passed (7) · Tests 96 passed (96) vitest run src/delegated-admin-gate.test.ts Test Files 1 passed (1) · Tests 165 passed (165) ``` </details> <details> <summary>Ablation, two legs: red, then restored</summary> ```text leg 1 the caller's read of a named target removed · anchor 1 to 0 · blob bbaa5aa to 50927929 Tests 2 failed | 163 passed (165) (the insert and update pins) restore: blob bbaa5aa == HEAD · git diff HEAD empty leg 2 the no-membership branch put back · anchor 1 to 0 · blob bbaa5aa to a9cf69ce Tests 3 failed | 162 passed (165) (the three shape pins) restore: blob bbaa5aa == HEAD · git diff HEAD empty after Tests 165 passed (165) ``` </details> CI check-runs on `842b5ef6d` when this body was written: every completed run `success` or `skipped`, `Test Core (2/8)` to `(8/8)` included; `Test Core (1/8)` in progress. <!-- signed: security#1 · sunhaolin · session_017xRBcT1w3zpekijraHJE4f --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 218f834 commit cd6618e

3 files changed

Lines changed: 223 additions & 10 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a delegated refusal names one reason for a target user the caller cannot read
6+
7+
Clause-②: no — no accept set changes at the write door: every write refused before is still refused, and every write admitted is still admitted. No error code, class or status is added: the answer is the gate's existing scope violation.
8+
Metadata-protocol: none
9+
10+
- **Rule.** A target user is inside a delegated business-unit subtree only through a business unit of that subtree. A delegated administrator's direct permission-set grant whose target user is outside the subtree is refused with one answer, whether or not the user exists or holds a business-unit membership: `403 DELEGATION_SCOPE_VIOLATION`, the same sentence ("target user … is outside the delegated subtree") and the same details.
11+
- **A target the caller cannot read.** A target user the grant names (a new grant, or a stored grant pointed at another user) is first asked of the caller's own read door, the question the engine's reference check asks of that user. A user the caller cannot read is answered as a user that does not exist, so it is outside the subtree and gets the same answer.
12+
- **What a client sees.** A grant to a target user who holds no business-unit membership, or whom the caller cannot read, answers that scope violation, so a console shows its reason, as it does for every other target outside the subtree.
13+
- **Unchanged.** A target user the caller reads, with a business unit inside the subtree, is admitted exactly as before.

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

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1818,15 +1818,16 @@ describe('[#26189] DelegatedAdminGate — a scope violation answers DELEGATION_S
18181818
expectOpaqueDenial(await refusalOf(() => grant({ user_id: 'u_east_1', permission_set: 'sub_admin' })), 'sys_user_permission_set');
18191819
});
18201820

1821-
it('a stored grant whose set the catalog does not hold, a grant naming no target user, or one whose target has no business unit', async () => {
1821+
it('a stored grant whose set the catalog does not hold, or a grant naming no target user', async () => {
18221822
// An INSERT naming an unknown set is judged by the allowlist (a scope
1823-
// violation, above); the catalog miss is the PRE-IMAGE's reason.
1823+
// violation, above); the catalog miss is the PRE-IMAGE's reason. A target
1824+
// user with no business unit is outside the subtree: the describe at the
1825+
// foot of this file.
18241826
h.tables.sys_user_permission_set = [{ id: 'g_retired', user_id: 'u_east_1', permission_set: 'sales_user_retired' }];
18251827
expectOpaqueDenial(await refusalOf(() => h.gate.assert({
18261828
object: 'sys_user_permission_set', operation: 'delete', options: { where: { id: 'g_retired' } }, context: h.ctxOf('delegate'),
18271829
})), 'sys_user_permission_set');
18281830
expectOpaqueDenial(await refusalOf(() => grant({ permission_set: 'sales_user' })), 'sys_user_permission_set');
1829-
expectOpaqueDenial(await refusalOf(() => grant({ user_id: 'u_delegate', permission_set: 'sales_user' })), 'sys_user_permission_set');
18301831
});
18311832

18321833
it('a self-service delegation of duty (ADR-0091 D3)', async () => {
@@ -1836,3 +1837,122 @@ describe('[#26189] DelegatedAdminGate — a scope violation answers DELEGATION_S
18361837
});
18371838
});
18381839
});
1840+
1841+
// ── [ADR-0090 D12; ruling on #26059, Q3] A target user is inside the delegated
1842+
// subtree only through a business unit of the subtree, so a direct grant to a
1843+
// target outside it is refused with ONE reason, whether or not the user
1844+
// exists or holds a business-unit membership: the same class, code, status,
1845+
// sentence and details, on the thrown value and on the REST door's
1846+
// classification (`mapDataError`). A target user the write NAMES is first
1847+
// asked of the caller's own read door, as the engine's reference check asks
1848+
// it: one the caller cannot read is answered as one that does not exist, so
1849+
// it is outside the subtree too. One target id, its stored states, and every
1850+
// write shape that judges a target user.
1851+
1852+
describe('[ADR-0090 D12] a delegated refusal names one reason for a target user outside the subtree, whether or not the user exists or holds a business-unit membership', () => {
1853+
const TARGET = 'u_target';
1854+
const STATES = [
1855+
'an id naming no user',
1856+
'a user with no business-unit membership',
1857+
'a user whose business unit is outside the subtree',
1858+
] as const;
1859+
/** A state only a write that NAMES the target meets: the caller's read door is asked about it. */
1860+
const UNREADABLE_INSIDE = 'a user with a business unit inside the subtree whom the caller cannot read';
1861+
const READABLE_INSIDE = 'a user with a business unit inside the subtree whom the caller reads';
1862+
type TargetState = (typeof STATES)[number] | typeof UNREADABLE_INSIDE | typeof READABLE_INSIDE;
1863+
1864+
/**
1865+
* A fresh harness with `TARGET` in `state`, over the engine the gate meets in
1866+
* a real composition: `sys_user` registered and the grant's `user_id` a
1867+
* lookup to it (the conditions under which the engine's reference check asks
1868+
* the caller's read door), and a caller read of `sys_user` that returns no
1869+
* row for a user the caller cannot read. A system read returns every row.
1870+
*/
1871+
function harnessWith(state: TargetState): ReturnType<typeof makeHarness> {
1872+
const harness = makeHarness();
1873+
const unreadable = new Set<string>();
1874+
const { find, findOne } = harness.ql;
1875+
const served = (object: string, opts: any, row: any) =>
1876+
!(object === 'sys_user' && !opts?.context?.isSystem && unreadable.has(row?.id));
1877+
harness.ql.find = async (object: string, opts: any) =>
1878+
(await find.call(harness.ql, object, opts)).filter((row: any) => served(object, opts, row));
1879+
harness.ql.findOne = async (object: string, opts: any) => {
1880+
const row = await findOne.call(harness.ql, object, opts);
1881+
return row && served(object, opts, row) ? row : null;
1882+
};
1883+
const SCHEMAS: Record<string, unknown> = {
1884+
sys_user: { name: 'sys_user', fields: {} },
1885+
sys_user_permission_set: { name: 'sys_user_permission_set', fields: { user_id: { type: 'lookup', reference: 'sys_user' } } },
1886+
};
1887+
harness.ql.getSchema = (name: string) => SCHEMAS[name];
1888+
if (state === 'an id naming no user') return harness;
1889+
harness.tables.sys_user.push({ id: TARGET });
1890+
if (state === 'a user whose business unit is outside the subtree') {
1891+
harness.tables.sys_business_unit_member.push({ id: 'm_target', business_unit_id: 'bu_west', user_id: TARGET });
1892+
}
1893+
if (state === UNREADABLE_INSIDE || state === READABLE_INSIDE) {
1894+
harness.tables.sys_business_unit_member.push({ id: 'm_target', business_unit_id: 'bu_es', user_id: TARGET });
1895+
}
1896+
if (state === UNREADABLE_INSIDE) unreadable.add(TARGET);
1897+
return harness;
1898+
}
1899+
1900+
/**
1901+
* Every write shape that judges a target user, as `[operation, write, names]`:
1902+
* a new grant and a stored grant pointed at the target NAME it; a stored
1903+
* grant of the target removed holds it as stored. Each write is the gate's
1904+
* own `assert`, never an engine double.
1905+
*/
1906+
const WRITES: ReadonlyArray<readonly [string, (harness: ReturnType<typeof makeHarness>) => Promise<void>, boolean]> = [
1907+
['insert', (harness) => harness.gate.assert({
1908+
object: 'sys_user_permission_set', operation: 'insert',
1909+
data: { user_id: TARGET, permission_set: 'sales_user' }, context: harness.ctxOf('delegate'),
1910+
}), true],
1911+
['update', (harness) => {
1912+
harness.tables.sys_user_permission_set = [{ id: 'g_east', user_id: 'u_east_1', permission_set: 'sales_user' }];
1913+
return harness.gate.assert({
1914+
object: 'sys_user_permission_set', operation: 'update',
1915+
data: { id: 'g_east', user_id: TARGET }, context: harness.ctxOf('delegate'),
1916+
});
1917+
}, true],
1918+
['delete', (harness) => {
1919+
harness.tables.sys_user_permission_set = [{ id: 'g_target', user_id: TARGET, permission_set: 'sales_user' }];
1920+
return harness.gate.assert({
1921+
object: 'sys_user_permission_set', operation: 'delete',
1922+
options: { where: { id: 'g_target' } }, context: harness.ctxOf('delegate'),
1923+
});
1924+
}, false],
1925+
];
1926+
1927+
/** Everything a caller is told by a refusal: the thrown value and the REST door's envelope. */
1928+
const answerOf = (err: any) => ({
1929+
class: err?.constructor?.name,
1930+
name: err?.name,
1931+
code: err?.code,
1932+
status: err?.status,
1933+
statusCode: err?.statusCode,
1934+
message: err?.message,
1935+
details: err?.details,
1936+
wire: mapDataError(err, 'sys_user_permission_set'),
1937+
});
1938+
1939+
for (const [shape, write, names] of WRITES) {
1940+
const states: TargetState[] = names ? [...STATES, UNREADABLE_INSIDE] : [...STATES];
1941+
it(`${shape}: the ${states.length} target states answer the same scope-violation refusal`, async () => {
1942+
const answers: Array<ReturnType<typeof answerOf>> = [];
1943+
for (const state of states) answers.push(answerOf(await refusalOf(() => write(harnessWith(state)))));
1944+
for (const [i, answer] of answers.entries()) expect(answer, states[i]).toEqual(answers[0]);
1945+
// The one answer is the caller's own scope: the target outside the subtree.
1946+
const err = await refusalOf(() => write(harnessWith('an id naming no user')));
1947+
expectScopeViolation(err, 'sys_user_permission_set',
1948+
/^\[Security\] Outside your delegated scope: delegated '\w+' on sys_user_permission_set rejected — target user 'u_target' is outside the delegated subtree \(scope from 'sub_admin'\)\.$/);
1949+
expect(err.details).toEqual({ operation: shape, object: 'sys_user_permission_set', permissionSet: 'sales_user' });
1950+
});
1951+
}
1952+
1953+
it('CONTROL: the same target with a business unit inside the subtree, read by the caller, is admitted on every write shape', async () => {
1954+
for (const [shape, write] of WRITES) {
1955+
await expect(write(harnessWith(READABLE_INSIDE)), shape).resolves.toBeUndefined();
1956+
}
1957+
});
1958+
});

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

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,45 @@ function mayName(t: DelegatedTarget, side: 'next' | 'prev', column: string): boo
543543
return t.reads.has(column);
544544
}
545545

546+
/** The direct-grant holder column, and the object it references. */
547+
const GRANT_HOLDER_FIELD = 'user_id';
548+
const GRANT_HOLDER_OBJECT = 'sys_user';
549+
550+
/**
551+
* Does the write NAME the grant's holder — an insert's row, or an update whose
552+
* payload points the grant at a different user than the row stores? A holder
553+
* the row already stores is not named: the engine's reference check forgives
554+
* a value the row stores, and the grant-holder membership hook judges it as
555+
* stored (`grant-holder-membership-refusal.ts`, `onUpdate`).
556+
*/
557+
function namesTargetUser(t: DelegatedTarget): boolean {
558+
if (!t.next) return false;
559+
if (!t.prev) return true;
560+
const norm = (v: unknown) => (v === undefined || v === null || v === '' ? null : String(v));
561+
return norm(t.next[GRANT_HOLDER_FIELD]) !== norm(t.prev[GRANT_HOLDER_FIELD]);
562+
}
563+
564+
/**
565+
* Does the engine's reference check ask the caller's own read door about the
566+
* holder `object`'s write names, and refuse one it does not return? It asks
567+
* when the live schema's `user_id` is a non-`readonly` `lookup` / `user`
568+
* reference to `sys_user` and `sys_user` is registered
569+
* (`assertReferencesResolve`, `@objectstack/objectql`; the grant-holder
570+
* membership hook's `referenceCheckAsksCaller` steps aside on the same test).
571+
* The direct-grant object declares that reference itself
572+
* (`sys-user-permission-set.object.ts`), so its target is not resolved again
573+
* here: the test reads only what a composition can still change, whether
574+
* `sys_user` is registered and whether the live field is still a writable
575+
* lookup. Anywhere else, an engine that hands over no schema included, it
576+
* answers no, and the holder is judged on its memberships alone: the gate
577+
* refuses no named holder the write would not be refused for anyway.
578+
*/
579+
function referenceCheckAsksCallerAboutUser(ql: any, object: string): boolean {
580+
if (typeof ql?.getSchema !== 'function' || !ql.getSchema(GRANT_HOLDER_OBJECT)) return false;
581+
const def = ql.getSchema(object)?.fields?.[GRANT_HOLDER_FIELD];
582+
return Boolean(def) && def.readonly !== true && (def.type === 'lookup' || def.type === 'user');
583+
}
584+
546585
export class DelegatedAdminGate {
547586
constructor(private readonly deps: DelegatedAdminGateDeps) {}
548587

@@ -1075,6 +1114,35 @@ export class DelegatedAdminGate {
10751114
return row && typeof row === 'object' ? row : null;
10761115
}
10771116

1117+
/**
1118+
* Does the caller's own read door return user `userId`? The question the
1119+
* engine's reference check asks of a `user_id` a write names
1120+
* (`masterDetailHeadersCallerReads`, `@objectstack/objectql`) and the
1121+
* grant-holder membership hook asks before it
1122+
* (`grant-holder-membership-refusal.ts`, `callerReadsHolder`): one `sys_user`
1123+
* read WHERE `id` equals the value, projected to `id`, under the caller's
1124+
* own context, so every read visibility and wall answers it, and only a row
1125+
* whose id IS the value counts. A user it does not return is a user the
1126+
* caller cannot read, answered as a user that does not exist (the
1127+
* platform's rule for every user a non-system write names). A read the read
1128+
* door REFUSES (a declared 4xx) returns no user either; a store fault
1129+
* propagates as raised, as for the gate's other reads: an outage is neither
1130+
* an answer nor a refusal.
1131+
*/
1132+
private async callerReadsUser(opCtx: any, userId: string): Promise<boolean> {
1133+
const ql = this.deps.ql;
1134+
if (!ql?.find) return false;
1135+
let rows: unknown;
1136+
try {
1137+
rows = await ql.find(GRANT_HOLDER_OBJECT, { where: { id: userId }, fields: ['id'], context: opCtx.context });
1138+
} catch (e) {
1139+
const status = declaredHttpStatus(e);
1140+
if (status !== undefined && status < 500) return false;
1141+
throw e;
1142+
}
1143+
return (Array.isArray(rows) ? rows : []).some((r: any) => r?.id != null && String(r.id) === userId);
1144+
}
1145+
10781146
/**
10791147
* Is position `positionName` inside the organization boundary? `null` when
10801148
* every set it carries is; otherwise the first failure, naming the set.
@@ -1465,7 +1533,16 @@ export class DelegatedAdminGate {
14651533
const row = t.next ?? t.prev ?? {};
14661534
const { setRow, setName, unresolved, setNamed } = await this.directGrantSet(t, row);
14671535
const targetUserId = row.user_id ? String(row.user_id) : null;
1468-
const userBUs = targetUserId ? await this.businessUnitsOfUser(targetUserId) : new Set<string>();
1536+
// A target user the write NAMES, where the engine's reference check asks
1537+
// the caller's read door about it, is first asked of that door
1538+
// ({@link callerReadsUser}): a user it does not return is answered as a
1539+
// user that does not exist, which holds no business unit the subtree can
1540+
// cover. So an unreadable target is outside the subtree, whatever it is.
1541+
const userBUs = targetUserId
1542+
&& (!namesTargetUser(t) || !referenceCheckAsksCallerAboutUser(this.deps.ql, opCtx.object)
1543+
|| (await this.callerReadsUser(opCtx, targetUserId)))
1544+
? await this.businessUnitsOfUser(targetUserId)
1545+
: new Set<string>();
14691546
// [ruling of record 6649064874] Named only when the caller
14701547
// wrote or reads it ({@link mayName}); the verdict is the stored row's.
14711548
const userNamed = mayName(t, side, 'user_id');
@@ -1479,12 +1556,15 @@ export class DelegatedAdminGate {
14791556
: "the grant's permission set is not in the scope's allowlist");
14801557
}
14811558
if (!targetUserId) return 'the grant names no target user';
1482-
if (userBUs.size === 0) {
1483-
return `${userNamed ? `target user '${targetUserId}'` : "the grant's target user"} has no business-unit membership — only a tenant admin may grant outside the tree`;
1484-
}
1485-
let inSubtree = false;
1486-
for (const bu of userBUs) if (s.subtree.has(bu)) { inSubtree = true; break; }
1487-
if (!inSubtree) {
1559+
// [ADR-0090 D12; ruling on #26059, Q3] A target user is inside the
1560+
// subtree only through a business unit of the subtree. A user holding
1561+
// no business-unit membership has none, and neither has an id naming
1562+
// no user, nor a named user the caller cannot read (above): each is
1563+
// outside the subtree, exactly as a user whose units all lie elsewhere
1564+
// is. So the refusal is one reason, one class, one sentence and one set
1565+
// of details for all of them, whether or not the target exists or
1566+
// holds a membership.
1567+
if (![...userBUs].some((bu) => s.subtree.has(bu))) {
14881568
return outsideScope(userNamed
14891569
? `target user '${targetUserId}' is outside the delegated subtree`
14901570
: "the grant's target user is outside the delegated subtree");

0 commit comments

Comments
 (0)