Repository navigation
Commit c86d3c9
fix(plugin-security)!: a delegated scope held in a context that names no organization covers nothing (#31500)
Clause-②: no (narrowing) — the delegated-administration gate resolves a
delegated scope only inside the caller's active organization, so a
context that names no organization holds no subtree: it covers no
business unit and no user, the delegable-scope report lists no scope and
no unit placeable through one, and a write under the scope is refused
naming the missing active organization. Nothing is widened: no export,
error code or status is added.
Metadata-protocol: none
Skills-docs: none — a runtime authorization bound inside
`@objectstack/plugin-security`; no authorable key, value or metadata
type changes, and no `skills/**` or docs page teaches how a delegated
scope resolves for a context with no active organization.
## 维护者速读
-
改了什么:调用方上下文未指明组织时,委派管理(adminScope)不覆盖任何业务单元——覆盖单元集合为空,可委派范围报告无作用域、无可安置单元、无可分配岗位,不覆盖任何用户;以该作用域发起的写入一律
403 拒绝,理由指明缺少活动组织(与组织管理员拒绝同一状态时的措辞、错误类一致)。
- 为什么改:落实规则「上下文未指明组织时持有的委派作用域不覆盖任何东西」(维护者 2026-11-08 裁决「都同意」,选项
A;安全加固,细节归 security#1 席)。
- 风险与回滚:上下文指明组织的所有答案不变,租户级管理员、组织管理员、职务代理的判定不变;仅无活动组织的委派管理者受影响。按先例
c1e08fe / 669a8aa 声明为收窄(BREAKING,patch 级)。回滚 = revert 本 PR。
- 先看:`packages/plugins/plugin-security/src/delegated-admin-gate.ts` 的
`resolveHeldScopes`(无组织即返回空)与 `authorize` 中新增的无组织拒绝;再看
`delegated-admin-own-name-set.posture.test.ts` 里三种部署形态的无组织各腿。
- 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2 src/delegated-admin-own-name-set.posture.test.ts
src/delegated-admin-gate-cross-organization.test.ts`,预期 `Test Files 2
passed`、`Tests 102 passed`。
- 需要你做的:无,席位落地。
## What changes
**Rule.** A delegated scope held in a context that names no organization
covers nothing.
For a context that names no organization (ADR-0123 D1's authenticated
session with no active organization), the gate resolves no delegated
scope and reads no business unit:
- `scopedBusinessUnitIds` answers an empty set, so the delegated
business-unit read admits nothing — the read door answers it as it
answers a caller holding no scope (`403 PERMISSION_DENIED`), and
`canReadObject`, `getReadFilter` and `security/explain` follow the same
judgment;
- `describeDelegableScope` lists no scope, no placeable unit and no
position assignable through a scope;
- `scopesCoverUser` answers false;
- an assignment or direct grant written under the scope is refused `403
PERMISSION_DENIED` with the reason "the request carries no active
organization to administer" — the wording, and the error class, the
organization-administrator branch already gives for the same state (one
shared constant now).
A context that names its organization gets every answer it got before. A
tenant-level administrator's writes, an organization administrator and a
self-delegation of duty are judged as before; a tenant-level
administrator's report is unchanged for a context naming its
organization.
- `describeDelegableScope`'s tenant-level branch offers a context that
names no organization no placeable unit; `organizationBusinessUnitIds`
now requires the organization.
- Docs: `content/docs/permissions/delegated-administration.mdx` states
that a tenant-level admin's report enumerates its active organization's
units, and that a context naming no organization is offered none.
**Structure.** The one choke point is `resolveHeldScopes`: with no
organization it returns no scope before any read. `resolveSubtree` now
takes the organization as a required argument. The scope parsing moved
into a module function (`adminScopesCarriedBy`) shared by
`resolveHeldScopes` and the write gate's refusal.
`organization-scope.ts` is untouched.
**Docblocks.** The stale ones were rewritten: `callerOrganizationId`,
`scopesCoverUser`, `describeDelegableScope`,
`organizationBusinessUnitIds`, the tenant-level branch comment,
`resolveHeldScopes` and `resolveSubtree` no longer describe an
organization-less resolution. Every ruling-of-record docblock in the
source is kept as it was, 6663849489 included. One test docblock citing
a ruling of record gained a paragraph and kept its text — the posture
describe's, which reads: "[ADR-0090 D12, ruling of record 6663849489]
The subtree a delegated scope covers is ONE organization's, whether or
not the caller's context names an organization: a unit stamped with
another organization is outside it even where its parent link names a
unit inside the scope, and so is everything below it."
### Governing text (read on `origin/main`)
- Maintainer ruling, 2026-11-08: 「都同意」 — option A: "fail closed — with
no organization on the context, resolveHeldScopes resolves no subtree:
scopedBusinessUnitIds returns an empty set, describeDelegableScope
returns no scopes and no placeable units, scopesCoverUser returns false,
and the write gate refuses with a no-active-organization reason, as
organizationAdminFailure already does."
- ADR-0123 D1: "\"Authenticated, with no active organization\" is a
declared session state, not an illegal intermediate to be eliminated at
the mint point."
- ADR-0123 D2: "Tenant-scoped reads resolve to nothing; tenant-scoped
writes are refused loudly." — "*Writes that place or move a row* —
`insert` and `update` — are **refused**, with `PERMISSION_DENIED` / HTTP
403 and a message that **names the missing active organization**."
- `packages/core/src/security/resolve-authz-context.ts`
(`grantAppliesInTenant`): "⛔ There is no \"every organization\" mode: no
option selects one and nothing falls back to one. A caller that has an
organization passes it; a caller that has none gets no stored grant at
all."
- ADR-0131 C1: "Default Organization load-bearing under `single` and
created before application seed datasets load"; D11: "`single` is never
a reason for a row to be org-less."
- Related, not the decider: ADR-0105 D6 red line 2 (every business-unit
mechanism operates within one organization); the precedents c1e08fe
(#31295) and 669a8aa (#31442), which bound the same gate's walk.
## Tests
Pins flipped, each with a one-line reason in the test ("a context that
names no organization holds nothing"):
- `delegated-admin-gate-cross-organization.test.ts` — the
no-organization control now asserts an empty report and the `403
PERMISSION_DENIED` refusal naming the missing active organization, with
the org-naming write as its control.
- `delegated-admin-own-name-set.posture.test.ts` (single, group,
isolated) — the gate's answers for a context naming no organization are
empty (covered units, placeable units, a member inside the scope not
covered); the read door refuses it as it refuses a caller holding no
scope; a new leg per posture refuses every write of that context, inside
the scope too, naming the missing active organization; the org-naming
context keeps every answer. The `single` Default-Organization door leg
now stores nothing for the no-organization context and stores the
in-scope assignment for the org-naming one.
- `delegated-admin-gate.test.ts` — the no-organization describe from
c1e08fe now pins that a context naming no organization holds nothing
over a stamped and an organization-less root; the anchor-by-organization
leg's no-organization report is empty.
- `delegated-admin-own-name-set.posture.test.ts` (single, group,
isolated) — a tenant-level administrator's report through the registered
security service offers its own organization's units only, and no unit
to a context naming no organization; beside the WRITER control, the
door, `canReadObject`, `getReadFilter` and explain answer a
no-organization context's delegated read as they answer a caller holding
no scope.
The no-organization tenant pin also asserts that `assignablePositions`
equals the org-naming report's (non-empty) list.
Fixtures that reached the gate with a context naming no organization
only incidentally now name the organization their tree belongs to; no
assertion changed: `delegated-admin-gate.test.ts` (two harnesses),
`delegated-admin-gate-catalog.test.ts`,
`granted-by-writer-provenance.test.ts` (the delegate's session carries
the Default Organization, and the target is its member),
`objects/reserved-identity-names.test.ts`, `security-plugin.test.ts`
(the explain harness).
## Acceptance notes
- The read door's answer to a context that names no organization is `403
PERMISSION_DENIED`, the answer a caller holding no scope gets, not an
empty list: with no covered unit the delegated admission is null and the
caller's own sets decide. Measured in all three postures.
- `resolveSubtreeById` keeps its `null` organization bound and its
ADR-0105 D6 docblock as written; no caller of it in the gate passes
`null` any more.
- Three open drafts touch `packages/plugins/plugin-security` (#31443,
#31438, #31466). None touches a line this diff changes; a local
merge-tree of each onto this branch is clean.
## Verification
Rule plus counts, on head `e5d0a791b8` for round 1. Round 3, head
`304ed565eb` (one docs page and one assertion): the two-minute check
`Tests 102 passed (102)`; the derived union 97 of 97 exit 0, docs
families included. Round 2, head `5068ea53fa` (origin/main merged in):
plugin-security suite `Test Files 249 passed (249)`, `Tests 5895 passed
| 45 skipped (5940)`, typecheck exit 0; ablation of the two tenant-level
guards turned exactly the 3 tenant-level legs red (3 failed, 267 passed
of 270), then restored; `dispatch-gates --commands` 66 derived, 66 run;
dogfood battery `VERDICT command-exit 0`, `Tests 3629 passed | 41
skipped (3670)`.
<details><summary>plugin-security suite and typecheck</summary>
- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2` — `Test Files 249 passed (249)`, `Tests 5889 passed | 45
skipped (5934)`; the verify lock's `VERDICT command-exit 0`.
- `pnpm --filter @objectstack/plugin-security run typecheck` — exit 0
(`tsc --noEmit` ×2 and `check:test-typecheck`: 0 files / 0 errors held
in the debt ledger).
</details>
<details><summary>Ablation — the three guards reverted on disk, then
restored</summary>
On the gate file as committed before its last, comment-only commit (blob
5d1c943), with the guard in `resolveHeldScopes`, the write gate's
refusal and the required organization of `resolveSubtree` reverted on
disk (each through `scripts/ablation-replace.mjs`, anchors hit 1/1, blob
moved each time), the three pinning files ran 14 failed / 250 passed
(264): every red test is a no-organization leg, and every org-naming
control stayed green. Restored: blob equals `HEAD`, `git diff HEAD`
empty, guard counts back to 1 / 1. A first attempt was refused by the
tool on its own evidence before any test ran (the replacement was a
substring of the anchor) and restored; it is not counted.
</details>
<details><summary>Gate families</summary>
- `node scripts/pm/dispatch-gates.mjs --commands` (no paths; the change
set read from the merge base) derived 66 families at e5d0a79. Each
ran once, its exit code captured before any pipe: 66 × exit 0.
- `--ran` reconciliation: `66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN`.
- `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET
(packages outside this closure had no `dist/`); after every package was
built it answered `122 published require entry point(s) across 72
package(s) load`, exit 0, and that run is the one counted.
- `check:adr-0087-registration` reads the changeset as
`[BREAKING+bang+clause-②-narrowing] not-required
(no-migration-prescription)`.
- The families CI runs over the whole tree (`Lint & Repo Gates`,
`TypeScript Type Check`, `Test Core`, …) are read from this PR's
check-runs, not re-run here.
</details>
<details><summary>Dogfood battery</summary>
Head e5d0a79, `pnpm --filter @objectstack/dogfood test` under the
verify lock with no shard and no path: `VERDICT command-exit 0`, `Test
Files 439 passed | 3 skipped (442)`, `Tests 3629 passed | 41 skipped
(3670)`, no failure.
</details>
<!-- signed: security#1 · sunhaolin · session_017xRBcT1w3zpekijraHJE4f
-->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 4cd48e2 commit c86d3c9
10 files changed
Lines changed: 343 additions & 137 deletions
File tree
- .changeset
- content/docs/permissions
- packages/plugins/plugin-security/src
- objects
| 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 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
134 | 134 | | |
135 | 135 | | |
136 | 136 | | |
137 | | - | |
| 137 | + | |
138 | 138 | | |
139 | 139 | | |
140 | 140 | | |
| |||
145 | 145 | | |
146 | 146 | | |
147 | 147 | | |
148 | | - | |
149 | | - | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
150 | 155 | | |
151 | 156 | | |
152 | 157 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
36 | 42 | | |
37 | 43 | | |
38 | 44 | | |
| |||
55 | 61 | | |
56 | 62 | | |
57 | 63 | | |
58 | | - | |
59 | | - | |
60 | | - | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
61 | 67 | | |
62 | 68 | | |
63 | 69 | | |
| |||
102 | 108 | | |
103 | 109 | | |
104 | 110 | | |
105 | | - | |
| 111 | + | |
106 | 112 | | |
107 | 113 | | |
108 | 114 | | |
| |||
148 | 154 | | |
149 | 155 | | |
150 | 156 | | |
151 | | - | |
| 157 | + | |
152 | 158 | | |
153 | 159 | | |
154 | 160 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
| 44 | + | |
44 | 45 | | |
45 | 46 | | |
46 | 47 | | |
| |||
237 | 238 | | |
238 | 239 | | |
239 | 240 | | |
240 | | - | |
241 | | - | |
| 241 | + | |
| 242 | + | |
242 | 243 | | |
243 | 244 | | |
244 | 245 | | |
| |||
265 | 266 | | |
266 | 267 | | |
267 | 268 | | |
268 | | - | |
| 269 | + | |
269 | 270 | | |
| 271 | + | |
270 | 272 | | |
271 | 273 | | |
272 | | - | |
273 | | - | |
274 | | - | |
275 | | - | |
276 | | - | |
277 | | - | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
278 | 293 | | |
279 | 294 | | |
0 commit comments