Repository navigation
Commit 42e1211
fix(plugin-security): a delegated administrator's refused assignment write answers the same for a row it cannot read as for a missing one (#29950)
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:委派管理员(持有 `adminScope` 的调用者,包括同时持有 `adminScope` 的组织管理员)按 id
修改或删除授权行(`sys_user_position`、`sys_user_permission_set`)被拒时,对它读不到的行和根本不存在的
id 给出完全相同的回答(状态码、错误码、文案、details 全同);委派管理闸门先以调用者自己的读取取得这一行,读得到的行仍按存储行判定。
- 为什么改:落实 ruling 5985885287(调用者读不到的行与不存在的 id 回答一致,对所有主体类别都适用);组织管理员分支已由
#29932 落地,本 PR 把这条规则落到委派分支,以及“组织管理员兼持 adminScope”这一类调用者。
-
风险与回滚:只改委派分支判断“有没有这一行”的读取来源;读得到的行判定与提示不变,原本放行的写入仍放行(含对该表无读权限的委派者),插入、租户级管理员、不持
adminScope 的组织管理员都不经此改动;唯一可见变化是不存在的 id 的删除由引擎的 404 改为闸门的
403(一种拒绝换成另一种)。回滚即撤销本 PR。
- 先看:`packages/plugins/plugin-security/src/delegated-admin-gate.ts` 的
`delegatedTargets` 与 `hiddenFromCaller`(风险
hunk:读取被拒绝不算“读不到”,仍按存储行判);`security-plugin.ts` 中 `readAsCaller`
的接线;`org-admin-assignment-boundary.posture.test.ts` 末尾新增的 describe。
- 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run
src/org-admin-assignment-boundary.posture.test.ts`,预期 70/70 通过(含 `group`
/ `isolated` 下三类委派调用者的新用例与 `single` 对照)。
- 需要你做的:无,席位落地。
## What changes
- **The delegated walk asks the caller first.** `DelegatedAdminGate`
(ADR-0090 D12) judges a delegate's by-id update or delete of an
assignment row (`sys_user_position`, `sys_user_permission_set`) only
once the caller's own by-id read of that row — the read door's answer to
this caller, every read visibility and wall included — has returned it.
The read is #29932's organization-branch read, now one method
(`callerRead`) both branches use.
- **No row is one answer, given at the gate.** When the caller's read
returns no row — the id names no row, or the caller cannot read it — the
write is refused there: `403 PERMISSION_DENIED`, "the assignment row it
addresses could not be read", details `{ operation, object }`. Neither
case reaches the engine, so a removal of an id that names no row now
answers that refusal instead of `404 RECORD_NOT_FOUND`.
- **A row the caller reads is judged on the stored row, as before**, so
its verdict and its guidance are unchanged. The caller's own read is
served through its field-level security, and a judgement made on what a
field rule hid would be made on less than the row; the posture rig pins
this.
- **A read the read door refuses outright (a declared 4xx) hides no
row.** The stored row is judged as before, and a refusal the gate then
answers for the by-id write the caller addressed is answered with that
read refusal by the security middleware
(`readDoorAnswerForGateRefusal`), for every id alike — the same reading
the by-id write door gives such a caller. A store fault in either read
propagates as raised.
- **One caller read per operation.** The security plugin passes the gate
its per-operation pre-image read (`readAsCaller` → `getCallerPreImage`),
so the organization branch, the delegated walk and the read-door
questions after them share one read of the row.
- Changeset: `@objectstack/plugin-security` patch.
## Tests
- **Engine level, real `ObjectQL` + SQL driver + `SecurityPlugin`**
(`org-admin-assignment-boundary.posture.test.ts`, new top-level
describe; the harness now also registers the two business-unit objects):
- under `group` and `isolated`, for three callers — a delegate, an
organization administrator who also holds the scope, and a delegate
refused the read of both tables — and both tables, the update and the
delete of a row the caller cannot read answer exactly what the same
write answers for an id that names no row, compared whole (status, code,
sentence, details); the no-row answer is the gate's 403, never the
engine's; the row stays;
- control: a row inside the scope that the caller reads is admitted; a
row of its own organization outside the scope keeps the guidance naming
its unit or its holder and is not answered as no row, after one read of
the row as the caller; an assignment whose position a field rule hides
from the caller is still held to the allowlist;
- control, `single`: a row the delegate reads keeps its guidance, and
every in-scope update and delete lands — the delegate refused the read
included — reading the row as the caller once.
- **HTTP door**
(`packages/qa/dogfood/test/delegated-admin-assignment-write-unreadable-row.dogfood.test.ts`,
showcase behind a real `isolated` wall, the showcase's own
`showcase_field_ops_delegate` set): for a delegate and for an
organization owner who also holds the set, `PATCH` and `DELETE` of an
assignment row the caller cannot read answer exactly what an id naming
no row answers; the row stays. Controls: the wall is real, each caller
reads no such row; a row inside the scope that the delegate wrote is
admitted; a row outside it keeps the guidance naming its unit.
- **One-shot ablations**, not kept (each restored blob == HEAD blob,
`git diff HEAD` empty):
- engine, the caller-visibility question removed (`node
scripts/ablation-replace.mjs`, anchor hit 1 time, blob `78e3ddaf` →
`47eb74de`): posture rig 2 failed | 68 passed — the two walled "answers
exactly what an id naming no row answers" cases; every control green;
- engine, the shared-read wiring removed (blob `4e1045f1` → `e13d4015`):
3 failed | 67 passed — the three cases that count the caller's reads of
the row (2, not 1);
- HTTP, the visibility question made to answer "readable" always (blob
`78e3ddaf` → `35c1210c`), plugin-security rebuilt, `node
scripts/ablation-dist-preflight.mjs @objectstack/plugin-security
PLANTED_TEXT` exit 0 (hit `dist/index.js`, `dist/index.mjs`): dogfood
pin 1 failed | 2 passed — the unreadable-row case; restored, rebuilt,
preflight `--absent` exit 0, 3/3.
## Acceptance notes
- **Why `Clause-②: no`.** The writes this newly refuses are by-id
updates and deletes whose row the caller's read does not return; each
was refused before too. An id naming no row was refused by the gate or
answered `404` by the engine. A row the caller cannot read was refused
by the gate or answered `404 RECORD_NOT_FOUND` by the by-id write door's
own read question — measured on the real engine under `group` and
`isolated` for rows whose stored image is inside the scope (before: 404,
row untouched; after: the gate's 403, row untouched). Every admission
stays: in-scope rows the caller reads are admitted under all three
postures, and a delegate refused the read keeps its in-scope writes
under `single` (pinned).
- The posture rig's `test-budget: 20` header was written at 60 cases;
the file now holds 70, and its tests took 8.9 s here on a shared box,
inside the budget.
- `delegated-admin-gate.test.ts`'s harness reads through a fake `ql`
that ignores context, so its by-id cases stand for a caller who reads
the row; their expectations are unchanged.
## Verification
<details><summary>Final head d57b423 (main merged at 098261a,
clean)</summary>
- Every command `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives (5 paths vs merge base 21510bb):
65 commands, each exit 0, captured before any pipe; `--ran`
reconciliation: 65 derived, 65 run, 0 NOT-MEASURED (derived from the
recorded exit codes). `check:dual-build-cjs-loads` and
`check:type-check-debt` ran with the lock held and the dist they read
built.
- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2 src/org-admin-assignment-boundary.posture.test.ts`:
70/70.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 (tsc,
scripts tsc, test layer 0 errors).
- `pnpm --filter @objectstack/dogfood test` (full battery, once, under
the verify lock): 415 files passed, 3 skipped; 3430 tests passed, 28
skipped; command-exit 0.
</details>
<details><summary>At 098261a (the main merge; the one later commit
types two test reads)</summary>
- `pnpm turbo run build --filter=@objectstack/dogfood --concurrency=2`:
exit 0, 68 tasks.
- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2` (full suite): 242 files, 5266 passed, 45 skipped.
- `pnpm --filter @objectstack/plugin-security typecheck`, `pnpm --filter
@objectstack/dogfood typecheck`: exit 0 each.
- `pnpm --filter @objectstack/dogfood exec vitest run --project
isolated` on this PR's pin and the organization-branch pin beside it: 2
files, 6/6.
- First gate pass here: `check:query-options-erasure` exit 1 (test
surface 236 to 238, the two new reads cast to any; typed in d57b423,
then exit 0) and `check:dual-build-cjs-loads` exit 3 (prerequisite:
seven packages outside the dogfood closure unbuilt; built, then exit 0).
The whole derived set was then re-run on d57b423 (above).
</details>
Authored in session `session_01QSeKYn5CmmdShCJifm6WYj` (security#1
dispatch, no card).
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 82f60eb commit 42e1211
5 files changed
Lines changed: 513 additions & 21 deletions
File tree
- .changeset
- packages
- plugins/plugin-security/src
- qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
56 | 56 | | |
57 | 57 | | |
58 | 58 | | |
| 59 | + | |
59 | 60 | | |
60 | 61 | | |
61 | 62 | | |
| |||
224 | 225 | | |
225 | 226 | | |
226 | 227 | | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
227 | 236 | | |
228 | 237 | | |
229 | 238 | | |
| |||
843 | 852 | | |
844 | 853 | | |
845 | 854 | | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
846 | 868 | | |
847 | | - | |
848 | | - | |
849 | | - | |
850 | | - | |
| 869 | + | |
| 870 | + | |
| 871 | + | |
| 872 | + | |
| 873 | + | |
| 874 | + | |
851 | 875 | | |
852 | 876 | | |
853 | 877 | | |
| |||
1169 | 1193 | | |
1170 | 1194 | | |
1171 | 1195 | | |
1172 | | - | |
| 1196 | + | |
1173 | 1197 | | |
1174 | 1198 | | |
1175 | 1199 | | |
| |||
1213 | 1237 | | |
1214 | 1238 | | |
1215 | 1239 | | |
1216 | | - | |
| 1240 | + | |
1217 | 1241 | | |
1218 | 1242 | | |
1219 | 1243 | | |
| |||
1457 | 1481 | | |
1458 | 1482 | | |
1459 | 1483 | | |
1460 | | - | |
1461 | | - | |
| 1484 | + | |
| 1485 | + | |
| 1486 | + | |
| 1487 | + | |
| 1488 | + | |
| 1489 | + | |
| 1490 | + | |
| 1491 | + | |
| 1492 | + | |
| 1493 | + | |
| 1494 | + | |
| 1495 | + | |
| 1496 | + | |
| 1497 | + | |
| 1498 | + | |
| 1499 | + | |
| 1500 | + | |
| 1501 | + | |
| 1502 | + | |
| 1503 | + | |
| 1504 | + | |
| 1505 | + | |
| 1506 | + | |
| 1507 | + | |
| 1508 | + | |
1462 | 1509 | | |
1463 | 1510 | | |
1464 | 1511 | | |
1465 | 1512 | | |
1466 | | - | |
1467 | | - | |
1468 | | - | |
1469 | | - | |
1470 | | - | |
1471 | | - | |
| 1513 | + | |
| 1514 | + | |
| 1515 | + | |
| 1516 | + | |
| 1517 | + | |
| 1518 | + | |
| 1519 | + | |
1472 | 1520 | | |
1473 | 1521 | | |
1474 | 1522 | | |
1475 | 1523 | | |
1476 | 1524 | | |
1477 | | - | |
1478 | | - | |
| 1525 | + | |
1479 | 1526 | | |
1480 | 1527 | | |
1481 | 1528 | | |
1482 | 1529 | | |
1483 | 1530 | | |
| 1531 | + | |
| 1532 | + | |
| 1533 | + | |
| 1534 | + | |
| 1535 | + | |
| 1536 | + | |
| 1537 | + | |
| 1538 | + | |
| 1539 | + | |
| 1540 | + | |
| 1541 | + | |
| 1542 | + | |
| 1543 | + | |
| 1544 | + | |
| 1545 | + | |
| 1546 | + | |
| 1547 | + | |
| 1548 | + | |
| 1549 | + | |
| 1550 | + | |
| 1551 | + | |
| 1552 | + | |
| 1553 | + | |
1484 | 1554 | | |
1485 | 1555 | | |
1486 | 1556 | | |
| |||
0 commit comments