Skip to content

Commit c86d3c9

Browse files
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
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security)!: a delegated scope held in a context that names no organization covers nothing
6+
7+
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 placeable unit — a tenant-level administrator's report included — and a write under the scope is refused naming the missing active organization. Nothing is widened: no export, error code or status is added.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export of the package entry changes: an adminScope keeps its shape and meaning, and the subtree it names resolves in the caller's active organization only. There is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing in the delegated-administration gate (`@objectstack/plugin-security`), only for a context that names no organization.
13+
14+
- **Rule.** A delegated scope held in a context that names no organization covers nothing (ADR-0123 D2: "Tenant-scoped reads resolve to nothing; tenant-scoped writes are refused loudly"). For such a context every answer that reads the scope's subtree is empty: the delegated business-unit read admits nothing, so the read door refuses it `403 PERMISSION_DENIED` as it does for a caller holding no scope; the delegable-scope report lists no scope, no placeable unit and no position assignable through a scope; the explain coverage of a user is false. A tenant-level administrator's delegable-scope report offers such a context no placeable unit either: the units it offers are its active organization's, and a context that names none has none. An assignment or direct grant written under the scope is refused `403 PERMISSION_DENIED` with a message naming the missing active organization, the wording an organization administrator's refusal of the same state already carries.
15+
- **Unchanged.** A context that names its organization gets every answer it got before, a tenant-level administrator's report included. The writes of a tenant-level administrator, an organization administrator's writes and reports, and a self-delegation of duty are judged as before, and a tenant-level administrator's report still lists the positions it may assign.
16+
- **Who moves.** Nobody whose sessions carry an active organization: every session on a stock `single` deployment carries the Default Organization (ADR-0131 C1). A caller that reaches the gate with no active organization selects one first.

‎content/docs/permissions/delegated-administration.mdx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ const scope = await client.security.describeDelegableScope();
134134
}
135135
```
136136

137-
Three properties worth knowing:
137+
Four properties worth knowing:
138138

139139
- **It narrows; it does not decide.** The lists are computed by the same
140140
helpers the write gate enforces with, so an option here is one the gate
@@ -145,8 +145,13 @@ Three properties worth knowing:
145145
withheld, exactly as the write gate would refuse it.
146146
- **Strictly self-scoped** — there is no target-user parameter, so it
147147
discloses nothing beyond the authority you already hold. A tenant-level
148-
admin comes back `isTenantAdmin: true` with everything enumerated, so
149-
consumers render one uniform picker rather than special-casing.
148+
admin comes back `isTenantAdmin: true` with every unit of its active
149+
organization enumerated, so consumers render one uniform picker rather than
150+
special-casing.
151+
- **No active organization, no units.** Business units belong to one
152+
organization, so a caller whose context names no organization is offered
153+
no placeable unit — a tenant-level admin included — and a delegated scope
154+
it holds covers nothing.
150155

151156
Scoped invitations (ADR-0105 D8) are the first consumer: the invitation form
152157
narrows its unit/position pickers with this, and the invitation's placement

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ const EAST_SCOPE = {
3333
assignablePermissionSets: ['sales_user'],
3434
};
3535

36+
/**
37+
* [ADR-0123 D2] The organization the fixture tree belongs to and the delegate's
38+
* context names: a delegated scope covers units of the active organization only.
39+
*/
40+
const ORG = 'org_1';
41+
3642
const SETS: Record<string, any[]> = {
3743
tenant_admin: [{ name: 'admin_full', objects: { '*': { allowRead: true, modifyAllRecords: true } } }],
3844
delegate: [{ name: 'east_admin', objects: {}, adminScope: EAST_SCOPE }],
@@ -55,9 +61,9 @@ const CATALOG = {
5561
function makeQl(tables: Record<string, any[]> = {}) {
5662
const all: Record<string, any[]> = {
5763
sys_business_unit: [
58-
{ id: 'bu_hq', name: 'hq', parent_business_unit_id: null },
59-
{ id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq' },
60-
{ id: 'bu_west', name: 'west', parent_business_unit_id: 'bu_hq' },
64+
{ id: 'bu_hq', name: 'hq', parent_business_unit_id: null, organization_id: ORG },
65+
{ id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq', organization_id: ORG },
66+
{ id: 'bu_west', name: 'west', parent_business_unit_id: 'bu_hq', organization_id: ORG },
6167
],
6268
// Rows say something else on purpose: `rep` is bound to the set NOT on
6369
// the allowlist, and `mixed` to nothing at all.
@@ -102,7 +108,7 @@ const assign = (gate: DelegatedAdminGate, position: string) =>
102108
object: 'sys_user_position',
103109
operation: 'insert',
104110
data: { user_id: 'u_east_1', position, business_unit_id: 'bu_east' },
105-
context: { principal: 'delegate', userId: 'u_delegate' },
111+
context: { principal: 'delegate', userId: 'u_delegate', organizationId: ORG },
106112
});
107113

108114
async function refusal(p: Promise<unknown>): Promise<any> {
@@ -148,7 +154,7 @@ describe('DelegatedAdminGate — a position distributes what its definition decl
148154
describe('DelegatedAdminGate — describeDelegableScope lists the catalog positions', () => {
149155
it('a delegate is offered exactly the positions whose declared sets are all allowlisted (anchors excluded)', async () => {
150156
const gate = gateOver(bindTestSecurityCatalog(makeQl(), CATALOG));
151-
const report = await gate.describeDelegableScope(SETS.delegate, { userId: 'u_delegate' });
157+
const report = await gate.describeDelegableScope(SETS.delegate, { userId: 'u_delegate', organizationId: ORG });
152158
expect(report.assignablePositions.sort()).toEqual(['registry_only', 'rep']);
153159
});
154160

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

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { describe, it, expect, afterEach } from 'vitest';
4141
import { ObjectQL } from '@objectstack/objectql';
4242
import { SqlDriver } from '@objectstack/driver-sql';
4343
import type { PermissionSet } from '@objectstack/spec/security';
44+
import { resolveThrownHttpError } from '@objectstack/types';
4445
import { DelegatedAdminGate } from './delegated-admin-gate.js';
4546

4647
const ORG_A = 'org_a_acme';
@@ -237,8 +238,8 @@ describe('DelegatedAdminGate — a scope anchor never crosses an organization',
237238
expect(report.placeableBusinessUnitIds).not.toContain(b.sales);
238239
});
239240

240-
// ── Controls: the assertions above must be able to fail, and the
241-
// organization-less surface must be untouched. ───────────────────────
241+
// ── Controls: the assertions above must be able to fail, and a context
242+
// that names no organization holds nothing. ──────────────────────────
242243
it('control (dark) — one organization holding `sales` resolves it, and the in-org write is approved', async () => {
243244
const engine = await bootEngine();
244245
const a = await seedOrganization(engine, ORG_A, 'a');
@@ -265,15 +266,29 @@ describe('DelegatedAdminGate — a scope anchor never crosses an organization',
265266
);
266267
});
267268

268-
it('a `single`-posture caller (no organization in context) keeps the by-name answer', async () => {
269+
it('a context that names no organization holds nothing: no scope, no placeable unit, and its write is refused naming the missing active organization', async () => {
269270
const engine = await bootEngine();
271+
const b = await seedOrganization(engine, ORG_B, '0');
270272
const a = await seedOrganization(engine, ORG_A, 'a');
271273
const gate = gateOn(engine);
272-
273-
const report = await gate.describeDelegableScope(DELEGATE_SETS, {
274-
principal: 'delegate',
275-
userId: 'usr_delegate',
276-
});
277-
expect([...report.placeableBusinessUnitIds].sort()).toEqual([a.sales, a.salesEast].sort());
274+
const noOrganization = { principal: 'delegate', userId: 'usr_delegate', positions: ['sales_admin'] };
275+
276+
// A context that names no organization holds nothing (ADR-0123 D2).
277+
const report = await gate.describeDelegableScope(DELEGATE_SETS, noOrganization);
278+
expect(report).toEqual({ isTenantAdmin: false, scopes: [], placeableBusinessUnitIds: [], assignablePositions: [] });
279+
for (const [unit, user] of [[a.salesEast, a.user], [b.salesEast, b.user]]) {
280+
const refusal: any = await gate.assert({
281+
object: 'sys_user_position',
282+
operation: 'insert',
283+
data: { user_id: user, position: 'sales_rep', business_unit_id: unit },
284+
context: noOrganization,
285+
}).then(() => null, (e) => e);
286+
expect(refusal, `${unit}: expected a refusal`).not.toBeNull();
287+
const r = resolveThrownHttpError(refusal);
288+
expect([r.status, r.code], `${unit}: ${refusal.message}`).toEqual([403, 'PERMISSION_DENIED']);
289+
expect(String(refusal.message)).toMatch(/no active organization/);
290+
}
291+
// CONTROL: the same delegate, naming its organization, keeps its own subtree.
292+
await expect(assignInto(gate, ORG_A, a.salesEast, a.user)).resolves.toBeUndefined();
278293
});
279294
});

0 commit comments

Comments
 (0)