You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 0b740c6
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-security): a write refused for a caller who cannot read the object answers the same for a hidden row and a missing one (#29845)
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:权限分配表(`sys_user_permission_set`、`sys_user_position`)上的 ADR-0090
D12 管理门,拒绝"按 id 更新/删除"时,若调用者连这个对象都读不了,改为回答该调用者读门的拒绝(与他按 id
读这行得到的答复完全相同);读得到这个对象的调用者(含组织管理员)仍看到 D12 原句,平台管理员照常写入。
- 为什么改:安全加固(细节按披露裁决不公开)。规则:被拒的写,对看不见该行的调用者,隐藏行与不存在的 id 答复相同。
- 风险与回滚:风险低。只在管理门已经拒绝时多做一次调用者自己的按 id 读;答复仍是拒绝,状态与 code 不变(403
PERMISSION_DENIED),只换句子;读本身故障则原样抛出。回滚 = revert 本 PR。
-
先看:`packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts`(`readDoorAnswerForGateRefusal`
与 D12 调用点的
try/catch);`packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts`(文件末尾新
describe);`packages/qa/dogfood/test/update-by-id-refusal-order.dogfood.test.ts`(带令牌整答复比较
+ 组织管理员对照)
- 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2 src/by-id-write-unreadable-not-found.test.ts`,预期 23
个用例全绿。
- 需要你做的:无,席位落地
## What changes
The rule: **a write refused for a caller who cannot read the object
answers the same for a hidden row and a missing one** — the same status,
code and sentence, with a version token (`If-Match`, `expectedVersion`)
and without one.
- **`security-plugin-authz-middleware.ts`** — the ADR-0090 D12
delegated-administration gate refuses a write to the
permission-assignment objects on the caller and the object alone, before
any grant is read, in words written for the write. Its call is now
wrapped: when it refuses (a declared 4xx) the by-id update or delete the
caller addressed (`addressedByIdWriteId` — not a nested write, not the
referential clear, routed by id by the engine; never the record
field-access answer's synthetic update, whose verdict is admitted or
refused alone), the read door's own question is asked of that id
(`getCallerPreImage`, the caller's by-id read through the engine — no
second evaluator):
- the read is **refused** → that refusal is the answer (it is the read
door's answer to this caller for every id);
- the read **answers** (a row, or none) → the caller reads the object,
and the gate's own refusal stands, its sentence unchanged;
- the read **faults** → the fault propagates as raised (#7505).
- **Docs** — `content/docs/protocol/kernel/http-protocol.mdx`, the
update refusal section: one sentence saying the permission-assignment
writes answer a caller refused reading the object with that read
refusal, and a caller who reads it with the administration refusal.
- **Changeset** — `@objectstack/plugin-security` patch.
**Caller classes** (by-id update/delete on an assignment object):
| Caller | Before | After |
|:--|:--|:--|
| reads and writes the object, not an administrator | gate's sentence |
unchanged |
| reads only | gate's sentence | unchanged |
| writes without reading | gate's sentence without a token | the read
refusal, every id, with or without a token |
| neither reads nor writes | gate's sentence without a token | the read
refusal, every id, with or without a token |
| create-only | insert: gate's sentence | insert unchanged (names no
row); by-id update/delete: the read refusal |
| tenant-level administrator | passes the gate | unchanged |
| organization administrator (reads the object) | gate's sentence
("never edited in place") | unchanged |
| system context | bypasses the middleware | unchanged |
| another organization's caller | as its read of the object decides |
the same two arms: refused read → read refusal; readable object → gate's
sentence |
**Clause-② is `no`, patch** — a refusal sentence swapped for another for
one caller class (as #28615). Every changed answer was and stays `403
PERMISSION_DENIED`; nothing refused is admitted and nothing admitted is
refused, so no accept set narrows. **Metadata-protocol: none.**
## Tests
- **Plugin, real engine** (`by-id-write-unreadable-not-found.test.ts`, a
new top-level describe in the file that pins "a row the caller cannot
read answers what a nonexistent id answers", with its own rig: both
assignment objects, a real `ObjectQL` over SQL, the real
`SecurityPlugin`). Per object × verb, for a caller with no grant and one
with write-without-read: the caller's by-id read is refused
(precondition), and the write of the row and of an id naming none both
equal that refusal — code, status, message and name; the row is
untouched. Controls: a caller who reads the object keeps the gate's
sentence for the row and for no row; a tenant-level administrator
updates a row and gets `404 RECORD_NOT_FOUND` for no row; an insert
keeps the gate's sentence.
- **Showcase, HTTP door**
(`update-by-id-refusal-order.dogfood.test.ts`): the stale-token families
now compare **status, code and sentence** for every family, the two
objects the member may neither read nor write included (they compared
status and code only). New control: an organization administrator, who
reads `sys_user_permission_set`, keeps the gate's "never edited in
place" sentence on `PATCH` for a row and for no row.
- **Ablation**, once (below): the helper made to always keep the gate's
refusal → the 8 no-read plugin cases and the dogfood stale-token case go
red; restored → green.
## Acceptance notes
- The administration gate's row-independent sentence is kept for every
caller who reads the object, so the guidance an administrator or a
holder of plain grants sees does not change.
- Only the by-id update and delete the caller addressed are affected: an
insert, a predicate write and a write the platform issues under the
caller's context name no row of the caller's and keep the gate's
refusal.
## Verification
Head `7e516c3dcd` on merge base `b3d141505` (`origin/main`); the builds,
suites, pins, ablation and dogfood battery ran at `04e42a7b06`, and
`7e516c3dcd` changes only the changeset's title line (`git diff --stat
04e42a77e516c3`: `.changeset/write-refusal-read-door-parity.md |
2 +-`). The gate union ran at `7e516c3dcd`. Exit codes captured before
any pipe. Locked runs through `scripts/pm/os-verify-lock.sh` on a shared
box.
<details>
<summary>Before the fix (main at b3d1415): the plugin pin is
red</summary>
`pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2 src/by-id-write-unreadable-not-found.test.ts` with the
new describe and the unfixed middleware: `Tests 8 failed | 15 passed
(23)` — the 8 failures are exactly the no-read caller cases; the reader,
administrator and insert controls pass.
</details>
<details>
<summary>Build, plugin suite, typecheck, pins</summary>
- `pnpm --filter @objectstack/plugin-security build` → exit 0
- `pnpm --filter @objectstack/plugin-security test` → exit 0 · `Test
Files 241 passed (241)` · `Tests 5220 passed | 45 skipped (5265)`
- `pnpm --filter @objectstack/plugin-security typecheck` → exit 0
- `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2
test/update-by-id-refusal-order.dogfood.test.ts` → exit 0 · `Tests 19
passed (19)`
</details>
<details>
<summary>Ablation (one shot, dist-mediated)</summary>
`node scripts/ablation-replace.mjs` on
`security-plugin-authz-middleware.ts`, anchor `if (addressedId ===
undefined) return refusal;` (hits 1 → 0, blob `5b65837cde22` →
`20c73d8e1e31`) replaced by a comparison against the planted literal
`ABLATION_SECF_MARK`, so the helper always keeps the gate's refusal:
- rebuild → exit 0; `ablation-dist-preflight.mjs
@objectstack/plugin-security 'ABLATION_SECF_MARK'` → exit 0 (marker in
dist)
- plugin pin → exit 1 · `Tests 8 failed | 15 passed (23)` (the 8 no-read
cases)
- dogfood pin → exit 1 · `Tests 1 failed | 18 passed (19)` (the
stale-token test)
- restored by the tool's trap: blob `5b65837cde22` == HEAD, `git diff
HEAD` empty (0 bytes); rebuild → exit 0; `--absent` preflight → exit 0
</details>
<details>
<summary>Showcase dogfood battery (final head)</summary>
`pnpm --filter @objectstack/dogfood test` (no shard, no path), under the
lock: `os-verify-lock: VERDICT command-exit 0` · `Test Files 404 passed
| 3 skipped (407)` · `Tests 3327 passed | 28 skipped (3355)` · no
failures.
</details>
<details>
<summary>Derived gates (`dispatch-gates.mjs --commands`, 90
families)</summary>
All 90 run at `7e516c3dcd`, each exit captured before any pipe: **90 ×
exit 0**. Two (`pnpm --filter @objectstack/spec run
check:skill-examples`, `pnpm check:dual-build-cjs-loads`) first refused
with `PREREQUISITE NOT MET` (exit 3: seven packages outside the dogfood
closure had no `dist/`); after building those seven under the lock
(turbo, `44 cached, 44 total`) both re-ran → exit 0 (`283 prose examples
type-check across 3 surface(s)`; `115 published require entry point(s)
across 70 package(s) load`). `pnpm check:type-check-debt` → exit 0 (`0
ledger entr(ies) re-measured … none above its recorded number`).
Reconciliation: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --ran RECORD` → `90 derived, 90 run, 0
NOT-MEASURED, 0 UNRUN`.
Not run locally, owned by CI: `test:repo` (the vitest `repo` project; no
file of it is touched) and the repo-wide lint scan; `eslint
--no-inline-config` over the three changed TypeScript files → exit 0.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-security): a write refused for a caller who cannot read the object answers the same for a hidden row and a missing one
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
The administration gate on the permission-assignment objects (`sys_user_permission_set`, `sys_user_position`, ADR-0090 D12) refuses a write before the object's grants are read, in words written for the write. For a by-id update or delete, a caller whose read of the object is refused now gets that read refusal from it instead — the answer its by-id read gives for every id — so a row and an id naming none answer the same status, code and sentence, with a version token (`If-Match`, `expectedVersion`) and without one.
11
+
12
+
-**Unchanged:** a caller who reads the object keeps the gate's own sentence (an organization administrator's "never edited in place", the administration hint for a holder of plain grants); a tenant-level administrator writes as before; an insert, a predicate write and a write the platform issues under the caller's context keep the gate's own refusal. Every answer is still a refusal: nothing that was refused is admitted, and nothing admitted is refused.
0 commit comments