Skip to content

Commit 495fa07

Browse files
feat(plugin-security,spec,plugin-auth)!: a delegated admin's write outside their own scope answers 403 DELEGATION_SCOPE_VIOLATION (#26320)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XKbZrFQoGL2ugEVaKwGYgBq)_ Fixes #26189 Clause-②: yes (widening: the ADR-0112 ledger registers `DELEGATION_SCOPE_VIOLATION` under `@objectstack/plugin-security`) Metadata-protocol: none Ruling-ref: #26059 ruling Q1 是 / Q2 不做 / Q3 分拆 / Q4 框架 (`6416233738`), this card is Q3 child 3; claim `6423979372` ## Summary When a delegated administrator's write fell outside their own `adminScope`, the delegated-administration gate knew the reason ("business unit … is outside the delegated subtree") but threw it as a plain `403 PERMISSION_DENIED`. A client could not tell it from a denial whose reason must stay hidden. Per the ruling, a refusal that describes the caller's own scope now answers its own code; everything else is unchanged. - **`plugin-security/src/errors.ts`**: new `DelegationScopeViolationError`, status and statusCode `403`, code `DELEGATION_SCOPE_VIOLATION`. A sibling of `PermissionDeniedError`, not a subclass. - **`plugin-security/src/delegated-admin-gate.ts`**: the per-scope checks mark the three scope reasons with `outsideScope(…)`; `firstApprovalFailure` keeps that mark on the reason it surfaces; `refusalFor` picks the class. The message is `[Security] Outside your delegated scope: delegated 'OP' on OBJECT rejected — REASON (scope from 'SET').` - **Why not `[Security] Access denied`**: that prefix is the `PERMISSION_DENIED` matcher in `mapDataError`, `isPermissionDeniedError` and `rest/src/security-routes.ts`. Kept, it re-flattens the code at the REST door; the ablation below shows exactly that. Same precedent as `NOT_OVERRIDABLE`'s `[Security] Permission set …`. - **`plugin-auth/src/auth-manager.ts`** (`isInvitationPlacementGateRefusal`): invitation placement dry-runs the same gate, and this predicate recognised its verdict only by `PERMISSION_DENIED`. Without the one-line addition, an invitation placed outside the issuer's scope would answer the `503` read-fault instead of `403 INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE` (pinned red-first below). - **`spec/src/api/error-code-ledger.zod.ts`**: one row under `@objectstack/plugin-security`, appended. It is its own meaning, `standardSynonymOf` answers `undefined`, and no waiver is needed. `#26250`'s `DUPLICATE_MATCH_KEY` row landed first and both are kept (main merged). - **`content/docs/references/api/*.mdx`**: regenerated by `gen:docs` (the ledger lists), on the merged tree. ### The split, one line per throw site (`delegated-admin-gate.ts` on this head) | Throw site | Reason | Answer | |---|---|---| | `authorize`, anchor invariant | the `everyone` / `guest` audience anchor is implicit | `PERMISSION_DENIED` (unchanged) | | `authorize`, principal-less | no `userId` | `PERMISSION_DENIED` (unchanged) | | `authorize`, `sys_member` | organization membership is tenant-only | `PERMISSION_DENIED` (unchanged) | | `authorize`, organization boundary | organization administrator outside the boundary, no scope held | `PERMISSION_DENIED` (unchanged) | | `authorize`, plain CRUD | no tenant administration, no `adminScope` | `PERMISSION_DENIED` (unchanged) | | `authorize`, filter write | a delegate must target single rows by id | `PERMISSION_DENIED` (unchanged) | | `assertSelfDelegation` `deny` | delegation of duty (ADR-0091 D3), every rule | `PERMISSION_DENIED` (unchanged) | | `assertAssignmentWrite` | business unit outside the delegated subtree (new row) | **`DELEGATION_SCOPE_VIOLATION`** | | `assertAssignmentWrite` | the existing assignment's business unit outside the subtree (pre-image) | **`DELEGATION_SCOPE_VIOLATION`** | | `assertAssignmentWrite` | the position distributes a set not in the scope's allowlist | **`DELEGATION_SCOPE_VIOLATION`** | | `assertAssignmentWrite` | scope without `manageAssignments`; unanchored row (new or existing); adminScope containment | `PERMISSION_DENIED` (unchanged) | | `assertDirectGrantWrite` | permission set not in the scope's allowlist | **`DELEGATION_SCOPE_VIOLATION`** | | `assertDirectGrantWrite` | target user outside the delegated subtree | **`DELEGATION_SCOPE_VIOLATION`** | | `assertDirectGrantWrite` | scope without `manageAssignments`; stored grant whose set is unnamed or not in the catalog; no target user; target user with no business unit; adminScope containment | `PERMISSION_DENIED` (unchanged) | With several held scopes the gate surfaces the last scope's first reason, as before, and the code follows that reason. `manageAssignments` and containment are read literally against the ruling ("every other refusal keeps `PERMISSION_DENIED`"), though both concern the caller's scope; see Acceptance notes. No `explain-engine.ts`, no `ui/`. Ruling #24914 B′ is unchanged: the console's `isPermissionError` (`ui/packages/react/src/utils/error-message.ts:202`) still treats any `403` as the fixed line until child 4 (#26190) lands. ## Tests Red first, on the unfixed code (`e05e0de8` + tests): - `delegated-admin-gate.test.ts` `[#26189]`: 5 scope cells red (`expected [ 'PERMISSION_DENIED', 403, 403 ] to deeply equal [ 'DELEGATION_SCOPE_VIOLATION', …(2) ]`), 10 non-scope controls green. - `auth-manager.test.ts` (scope-violation stand-in): red, `expected 503 to be 403`. - `error-code-ledger.test.ts` (the new code): red, `ZodError … invalid_value`. New and changed pins: - `delegated-admin-gate.test.ts` `[#26189]`: each scope cell asserts `code`, `status`, `statusCode`, the reason, `name`, the message not opening with the matcher prefix, `isPermissionDeniedError` false, and `mapDataError` answering `403 DELEGATION_SCOPE_VIOLATION` with the message. Each non-scope cell asserts `PERMISSION_DENIED` on the throw and through `mapDataError`. - Door pin, over HTTP (`showcase-permission-zoo.dogfood.test.ts`): the delegate's out-of-subtree `POST /data/sys_user_position` and off-allowlist `POST /data/sys_user_permission_set` now assert `403 DELEGATION_SCOPE_VIOLATION` and the reason (they asserted only "not 2xx"); a control, an unanchored assignment, answers `403 PERMISSION_DENIED`. - The dispatcher door: `delegable-scope-no-identity-positions.posture.test.ts` reads `resolveThrownHttpError` and now expects `403 DELEGATION_SCOPE_VIOLATION` for the gate's allowlist refusal. - Flipped from `PERMISSION_DENIED` (each is an allowlist reason): `delegated-admin-gate.test.ts` own-name CONTROL, `delegated-admin-own-name-set.posture.test.ts` CONTROL (three postures), `delegated-admin-gate-catalog.test.ts`, the posture test above. - `errors.test.ts`: the new class joins the floor (both status spellings). - `auth-manager.test.ts`: the scope-violation stand-in answers `403 INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`. - `error-code-ledger.test.ts`: parses, is listed under plugin-security, `standardSynonymOf` is `undefined`, no waiver. ### Ablation (`scripts/ablation-replace.mjs`, restored to `blob == HEAD`) | Mutation | Proof it reached `dist/` | Result | |---|---|---| | The new class's message opens with `[Security] Access denied:` again | `ablation-dist-preflight` exit 0 | `showcase-permission-zoo.dogfood.test.ts`: 2 failed / 9 passed, both door pins (`expected [ 403, 'PERMISSION_DENIED' ] …`); the control stays green | Restore: `git diff HEAD` empty, plugin-security rebuilt, `--absent` preflight exit 0. A first attempt (a `typeof` swap) failed the DTS build, so it measured nothing and was replaced. ## Readings All at head `b563cd4c4` (main `96635f577` merged), dependency closure rebuilt first: - `@objectstack/plugin-security`: 227 files, 4941 passed, 45 skipped, 0 failed. - `@objectstack/plugin-auth`: 198 files, 3780 passed, 10 skipped, 0 failed. - `error-code-ledger.test.ts`: 24 passed. - Dogfood, the two door files (`showcase-permission-zoo`, `delegated-admin-invite`): 17 passed. - Full dogfood (the title carries `!`): 297 files passed, 3 skipped; 2528 tests passed, 18 skipped, 0 failed. - Typecheck (`plugin-security`, `plugin-auth`, `spec`, `dogfood`, test layer included): exit 0. eslint `--no-inline-config` over the touched sources: exit 0. - `dispatch-gates --commands`: 119 derived, each run with its exit code captured. `dispatch-gates --ran`: `119 derived, 119 run, 0 NOT-MEASURED`. Two needed a container step first: `check-plugin-teardown-shape --self-test` (fetch of its pinned fixture commit) and `check:type-check-debt` (re-run under the lock after the dogfood run released it); both exit 0. - `pnpm --filter @objectstack/spec check:generated`: the one stale artifact (`content/docs/references/**`) regenerated with `--fix`, on the merged tree. ## Acceptance notes - The claim's `Clause-②: no` was measured as `yes (widening)`: the ledger header says registering a code widens the published face, door or no door. Status and acceptance are unchanged, so it is not narrowing. The changeset carries the **BREAKING** (wire-answer) banner and an ADR-0087 `not-required (no-migration-prescription)` marker, the shape used for the same class of change before. - Two refusals concern the caller's scope but stay `PERMISSION_DENIED` under the ruling's literal list: a scope without `manageAssignments`, and adminScope containment ("would hand out an adminScope not strictly contained by yours"). Moving either is a ruling question, not taken here. - In-repo readers that branch on `code === 'PERMISSION_DENIED'` were measured: only plugin-auth's invitation predicate can receive this gate's throw (fixed here). The explain route's catch never sees it: explain swallows the gate's throw (`security-plugin-explain-access.ts`, `.catch(() => false)`). - `DelegationScopeViolationError` is not exported from the plugin's index; clients read the code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MZUz5qaFxUqiNqA3LqEVzd --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bdfad1f commit 495fa07

16 files changed

Lines changed: 357 additions & 29 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-security': patch
4+
'@objectstack/plugin-auth': patch
5+
---
6+
7+
feat(plugin-security,spec,plugin-auth)!: a delegated admin's write outside their own scope answers `403 DELEGATION_SCOPE_VIOLATION` with the gate's reason
8+
9+
When a delegated administrator's write on `sys_user_position` or `sys_user_permission_set` fell outside their own `adminScope`, the delegated-administration gate knew exactly why ("business unit … is outside the delegated subtree", "… is not in the scope's allowlist"), but threw it as a plain `403 PERMISSION_DENIED`. A client could not tell it from a denial whose reason must stay hidden, so the console showed only its fixed "no permission" line.
10+
11+
Clause-②: yes (widening: the ADR-0112 ledger registers `DELEGATION_SCOPE_VIOLATION` under `@objectstack/plugin-security`)
12+
Metadata-protocol: none
13+
14+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the error code three of the gate's refusal reasons answer with: every write that was refused is still refused, with the same 403 status and the same reason, and every write that was admitted is still admitted. The remedy is on the consumer's side, reading `code`; never a rewrite of a package source or a stored row. The other categories are closed on facts: all three packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
15+
16+
**BREAKING** (a wire-answer change), shipped as `patch`: no metadata protocol kind moves.
17+
18+
- **`@objectstack/plugin-security`.** Three reasons describe the caller's own delegated scope: a business unit outside the delegated subtree (the new row's or the existing row's), a permission set not in the scope's allowlist (one the position distributes, or one granted directly), and a target user outside the subtree. They now throw `DelegationScopeViolationError`: status `403`, code `DELEGATION_SCOPE_VIOLATION`, and the message `[Security] Outside your delegated scope: delegated 'OP' on OBJECT rejected — REASON (scope from 'SET').` Every other refusal of the gate keeps `403 PERMISSION_DENIED`, unchanged: the audience anchor, principal-less writes, `sys_member` being tenant-only, the organization boundary, plain CRUD with no scope, filter writes, delegation of duty, a scope without `manageAssignments`, an unanchored assignment, adminScope containment, a stored grant whose set is unnamed or not in the catalog, a grant naming no target user, and a target user with no business unit.
19+
- The message does not open with `[Security] Access denied`, which `mapDataError`, `isPermissionDeniedError` and the explain route read as `PERMISSION_DENIED`. The REST door (`mapDataError`) and the runtime dispatcher (`resolveThrownHttpError`) carry the new code as it was thrown.
20+
- **`@objectstack/spec`.** One ADR-0112 ledger row, `DELEGATION_SCOPE_VIOLATION`, registered as its own meaning. It is not a synonym of `PERMISSION_DENIED`, and no waiver is needed.
21+
- **`@objectstack/plugin-auth`.** The invitation door reads the new code as the gate's verdict too, so an invitation placed outside the issuer's scope still answers `403 INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`, not the `503` read-fault answer.
22+
- For a client: a `403` with code `DELEGATION_SCOPE_VIOLATION` carries a message about the caller's own scope, which is safe to show. A `403 PERMISSION_DENIED` stays opaque. A client that matched `PERMISSION_DENIED` to catch these three refusals should also match the new code.

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ const result = AutomationApiErrorCode.parse(data);
354354

355355
| Property | Type | Required | Description |
356356
| :--- | :--- | :--- | :--- |
357-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +345 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
357+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +346 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
358358
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
359359

360360
### Allowed Values: `FlowInnerRefusal.code`
@@ -460,6 +460,7 @@ const result = AutomationApiErrorCode.parse(data);
460460
* `DATASOURCE_ADMIN_ERROR`
461461
* `DEFAULT_RECORD_PAGE_CONFLICT`
462462
* `DELEGABLE_SCOPE_FAILED`
463+
* `DELEGATION_SCOPE_VIOLATION`
463464
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
464465
* `DELIVERY_NEVER_SENT`
465466
* `DELIVERY_NOT_ELIGIBLE`

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +345 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +346 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -141,6 +141,7 @@ const result = ApiErrorSchema.parse(data);
141141
* `DATASOURCE_ADMIN_ERROR`
142142
* `DEFAULT_RECORD_PAGE_CONFLICT`
143143
* `DELEGABLE_SCOPE_FAILED`
144+
* `DELEGATION_SCOPE_VIOLATION`
144145
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
145146
* `DELIVERY_NEVER_SENT`
146147
* `DELIVERY_NOT_ELIGIBLE`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ const result = ErrorCode.parse(data);
308308
* `DATASOURCE_ADMIN_ERROR`
309309
* `DEFAULT_RECORD_PAGE_CONFLICT`
310310
* `DELEGABLE_SCOPE_FAILED`
311+
* `DELEGATION_SCOPE_VIOLATION`
311312
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
312313
* `DELIVERY_NEVER_SENT`
313314
* `DELIVERY_NOT_ELIGIBLE`

‎packages/plugins/plugin-auth/src/auth-manager.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,34 @@ describe('AuthManager', () => {
10521052
expect(String(err?.body?.message)).toMatch(/outside the delegated subtree/);
10531053
});
10541054

1055+
it('issuance: the gate\'s scope-violation refusal (DELEGATION_SCOPE_VIOLATION) is the same verdict — 403 INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE, not an outage', async () => {
1056+
// [#26189] A refusal that describes the issuer's own delegated scope (a
1057+
// unit outside the subtree, a set off the allowlist) now carries its own
1058+
// code. It is still the gate's verdict, so the invitation answers what
1059+
// it answered before. Structural stand-in, as above.
1060+
const assertIssuable = vi.fn(async () => {
1061+
throw Object.assign(new Error("[Security] Outside your delegated scope: business unit 'bu_plant_b' is outside the delegated subtree"), {
1062+
name: 'DelegationScopeViolationError',
1063+
code: 'DELEGATION_SCOPE_VIOLATION',
1064+
status: 403,
1065+
statusCode: 403,
1066+
});
1067+
});
1068+
const { hooks, warnSpy } = await bootWithPlacement({ assertIssuable, apply: vi.fn() });
1069+
warnSpy.mockRestore();
1070+
1071+
const err = await placementAnswer(
1072+
hooks.beforeCreateInvitation({
1073+
invitation: PLACEMENT_INVITE,
1074+
inviter: { id: 'u_issuer' },
1075+
organization: { id: 'org-42' },
1076+
}),
1077+
);
1078+
expect(err?.statusCode).toBe(403);
1079+
expect(err?.body?.code).toBe('INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE');
1080+
expect(String(err?.body?.message)).toMatch(/outside the delegated subtree/);
1081+
});
1082+
10551083
it('issuance: a store fault out of the placement check is an outage (503 SERVICE_UNAVAILABLE), not a scope refusal', async () => {
10561084
// [#24243] `assertIssuable` resolves the issuer's grants from the store
10571085
// BEFORE the gate runs; a store that cannot answer throws there, as the

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,12 +1391,17 @@ export const ORGANIZATION_SLUG_LOCKED_CODE: RegisteredErrorCode = 'ORGANIZATION_
13911391
* [#24243] Is this throw out of `assertIssuable` the delegated-administration
13921392
* gate's own refusal? The service's contract is that the gate refuses with its
13931393
* `PermissionDeniedError` (`invitation-placement.ts`), whose ADR-0112 code is
1394-
* `PERMISSION_DENIED`. Read by that code, structurally, because plugin-auth has
1395-
* no runtime dependency on plugin-security (see
1394+
* `PERMISSION_DENIED`, or, when the reason describes the issuer's own scope (a
1395+
* unit outside the subtree, a set off the allowlist), its
1396+
* `DelegationScopeViolationError`, code `DELEGATION_SCOPE_VIOLATION` (#26189).
1397+
* Both are the gate's verdict. Read by code, structurally, because plugin-auth
1398+
* has no runtime dependency on plugin-security (see
13961399
* {@link InvitationPlacementServiceLike}).
13971400
*/
13981401
export function isInvitationPlacementGateRefusal(err: unknown): boolean {
1399-
return !!err && typeof err === 'object' && (err as { code?: unknown }).code === 'PERMISSION_DENIED';
1402+
if (!err || typeof err !== 'object') return false;
1403+
const code = (err as { code?: unknown }).code;
1404+
return code === 'PERMISSION_DENIED' || code === 'DELEGATION_SCOPE_VIOLATION';
14001405
}
14011406

14021407
/**

‎packages/plugins/plugin-security/src/delegable-scope-no-identity-positions.posture.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,8 @@ describe('[ADR-0090 D12 / ADR-0105 D8 × ADR-0068 D2] every branch of the delega
195195
for (const name of RESERVED_IDENTITY_NAMES) {
196196
const r = await assign(h, context, name);
197197
const gatedFirst = branch === 'delegated' && name === BUILTIN_IDENTITY_ORG_EXTERNAL;
198-
expect([r.status, r.code], `${name}: ${r.message}`).toEqual(gatedFirst ? [403, 'PERMISSION_DENIED'] : [400, 'VALIDATION_FAILED']);
198+
// [#26189] The gate refuses it because its set is off the caller's allowlist: a scope violation, its own code.
199+
expect([r.status, r.code], `${name}: ${r.message}`).toEqual(gatedFirst ? [403, 'DELEGATION_SCOPE_VIOLATION'] : [400, 'VALIDATION_FAILED']);
199200
expect(await storedRows(h, name), `${name} stored nothing`).toHaveLength(0);
200201
}
201202

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ async function refusal(p: Promise<unknown>): Promise<any> {
114114
describe('DelegatedAdminGate — a position distributes what its definition declares', () => {
115115
it('a binding declared only on the definition is judged: a non-allowlisted set refuses the assignment', async () => {
116116
const err = await refusal(assign(gateOver(bindTestSecurityCatalog(makeQl(), CATALOG)), 'mixed'));
117-
expect(err).toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
117+
// [#26189] A set off the caller's allowlist is a scope violation: its own code.
118+
expect(err).toMatchObject({ code: 'DELEGATION_SCOPE_VIOLATION', statusCode: 403 });
118119
expect(String(err.message)).toMatch(/position 'mixed' distributes permission set 'finance_admin', which is not in the scope's allowlist/);
119120
});
120121

0 commit comments

Comments
 (0)