Repository navigation
Commit cd6618e
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
- .changeset
- packages/plugins/plugin-security/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1818 | 1818 | | |
1819 | 1819 | | |
1820 | 1820 | | |
1821 | | - | |
| 1821 | + | |
1822 | 1822 | | |
1823 | | - | |
| 1823 | + | |
| 1824 | + | |
| 1825 | + | |
1824 | 1826 | | |
1825 | 1827 | | |
1826 | 1828 | | |
1827 | 1829 | | |
1828 | 1830 | | |
1829 | | - | |
1830 | 1831 | | |
1831 | 1832 | | |
1832 | 1833 | | |
| |||
1836 | 1837 | | |
1837 | 1838 | | |
1838 | 1839 | | |
| 1840 | + | |
| 1841 | + | |
| 1842 | + | |
| 1843 | + | |
| 1844 | + | |
| 1845 | + | |
| 1846 | + | |
| 1847 | + | |
| 1848 | + | |
| 1849 | + | |
| 1850 | + | |
| 1851 | + | |
| 1852 | + | |
| 1853 | + | |
| 1854 | + | |
| 1855 | + | |
| 1856 | + | |
| 1857 | + | |
| 1858 | + | |
| 1859 | + | |
| 1860 | + | |
| 1861 | + | |
| 1862 | + | |
| 1863 | + | |
| 1864 | + | |
| 1865 | + | |
| 1866 | + | |
| 1867 | + | |
| 1868 | + | |
| 1869 | + | |
| 1870 | + | |
| 1871 | + | |
| 1872 | + | |
| 1873 | + | |
| 1874 | + | |
| 1875 | + | |
| 1876 | + | |
| 1877 | + | |
| 1878 | + | |
| 1879 | + | |
| 1880 | + | |
| 1881 | + | |
| 1882 | + | |
| 1883 | + | |
| 1884 | + | |
| 1885 | + | |
| 1886 | + | |
| 1887 | + | |
| 1888 | + | |
| 1889 | + | |
| 1890 | + | |
| 1891 | + | |
| 1892 | + | |
| 1893 | + | |
| 1894 | + | |
| 1895 | + | |
| 1896 | + | |
| 1897 | + | |
| 1898 | + | |
| 1899 | + | |
| 1900 | + | |
| 1901 | + | |
| 1902 | + | |
| 1903 | + | |
| 1904 | + | |
| 1905 | + | |
| 1906 | + | |
| 1907 | + | |
| 1908 | + | |
| 1909 | + | |
| 1910 | + | |
| 1911 | + | |
| 1912 | + | |
| 1913 | + | |
| 1914 | + | |
| 1915 | + | |
| 1916 | + | |
| 1917 | + | |
| 1918 | + | |
| 1919 | + | |
| 1920 | + | |
| 1921 | + | |
| 1922 | + | |
| 1923 | + | |
| 1924 | + | |
| 1925 | + | |
| 1926 | + | |
| 1927 | + | |
| 1928 | + | |
| 1929 | + | |
| 1930 | + | |
| 1931 | + | |
| 1932 | + | |
| 1933 | + | |
| 1934 | + | |
| 1935 | + | |
| 1936 | + | |
| 1937 | + | |
| 1938 | + | |
| 1939 | + | |
| 1940 | + | |
| 1941 | + | |
| 1942 | + | |
| 1943 | + | |
| 1944 | + | |
| 1945 | + | |
| 1946 | + | |
| 1947 | + | |
| 1948 | + | |
| 1949 | + | |
| 1950 | + | |
| 1951 | + | |
| 1952 | + | |
| 1953 | + | |
| 1954 | + | |
| 1955 | + | |
| 1956 | + | |
| 1957 | + | |
| 1958 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
543 | 543 | | |
544 | 544 | | |
545 | 545 | | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
546 | 585 | | |
547 | 586 | | |
548 | 587 | | |
| |||
1075 | 1114 | | |
1076 | 1115 | | |
1077 | 1116 | | |
| 1117 | + | |
| 1118 | + | |
| 1119 | + | |
| 1120 | + | |
| 1121 | + | |
| 1122 | + | |
| 1123 | + | |
| 1124 | + | |
| 1125 | + | |
| 1126 | + | |
| 1127 | + | |
| 1128 | + | |
| 1129 | + | |
| 1130 | + | |
| 1131 | + | |
| 1132 | + | |
| 1133 | + | |
| 1134 | + | |
| 1135 | + | |
| 1136 | + | |
| 1137 | + | |
| 1138 | + | |
| 1139 | + | |
| 1140 | + | |
| 1141 | + | |
| 1142 | + | |
| 1143 | + | |
| 1144 | + | |
| 1145 | + | |
1078 | 1146 | | |
1079 | 1147 | | |
1080 | 1148 | | |
| |||
1465 | 1533 | | |
1466 | 1534 | | |
1467 | 1535 | | |
1468 | | - | |
| 1536 | + | |
| 1537 | + | |
| 1538 | + | |
| 1539 | + | |
| 1540 | + | |
| 1541 | + | |
| 1542 | + | |
| 1543 | + | |
| 1544 | + | |
| 1545 | + | |
1469 | 1546 | | |
1470 | 1547 | | |
1471 | 1548 | | |
| |||
1479 | 1556 | | |
1480 | 1557 | | |
1481 | 1558 | | |
1482 | | - | |
1483 | | - | |
1484 | | - | |
1485 | | - | |
1486 | | - | |
1487 | | - | |
| 1559 | + | |
| 1560 | + | |
| 1561 | + | |
| 1562 | + | |
| 1563 | + | |
| 1564 | + | |
| 1565 | + | |
| 1566 | + | |
| 1567 | + | |
1488 | 1568 | | |
1489 | 1569 | | |
1490 | 1570 | | |
| |||
0 commit comments