Skip to content

Commit 733b3c8

Browse files
fix(plugin-security)!: retire the position-name fold; a namesake set is reported, never granted (#24757) (#26064)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XAT2rj3G1muSjHR3CQ5LksT)_ Fixes #24757 Fixes #25799 Clause-②: yes (narrowing: a holder of position `P` is no longer granted the permission set named `P` unless `P`'s definition names it in `permissionSets`; widened: `@objectstack/core`'s `UserAuthzGrants` and `ResolvedAuthzContext` gain the additive field `directPermissions`) Metadata-protocol: none Ruling-ref: #13419 ruling B (`5472608110`); 「甲」 as amended (`6303865025`); order and unlock on #24757 (`6290781571`, `6392096931`); #25799 seat ruling A and S1 (claims `6414302166`, `6414315157`) ## Summary ADR-0131 D3/D4 says a position grants only the permission sets its definition's `permissionSets` names. The security plugin still folded every held position's name into the set request, so a set that shared a position's name was granted without anyone writing the binding. #13419 ruling B retires that channel. HotCRM declared its bindings first (objectstack-ai/hotcrm#2200, released in `v6.0.0`), and #24757's unlock (`6392096931`) clears this half. - **Resolver** (`security-plugin-permission-sets.ts`): requests only `context.permissions` plus the baseline. `dropDeactivatedFoldedSets`, the second activation read only the fold needed, is deleted. - **Warning**: `position_name_fold_grant` keeps its event name and fields, adds `granted: false`, and now says the same-named set is **not** granted and how to declare it. `reportNameFoldCollisions` became `reportUngrantedNamesakeSets`. It probes the held position names that the request did not already name, through the same evaluator and loaders, once per name per process. The probe result is never granted. - **`describePositionGrants`**: lists only the sets a position's definition names. It no longer serves `via: 'name'`. - **Delegated-admin gate** (`delegated-admin-gate.ts`): `setsCarriedByPosition` returns the definition's sets only. A set that only shares a position's name is no longer walked for the allowlist, containment or the organization boundary, and the `ownNameNote` refusal suffix is gone. - **Explain** (`explain-engine.ts`): `position:P` when `P`'s definition names the set (read from the security catalog), otherwise `direct grant`. When the definitions are unreadable it says `direct grant or position definition (the position definitions could not be read)` instead of guessing. This also fixes the decision's "另" note, where a definition-bound set showed as a direct grant, and that is #25799's case. - **`scripts/pm/dispatch-gates.mjs`** (docblock only): it cited the deleted census's `#selfTest` as a live anchor, which `check-scripts-symbol-anchors` flagged as unresolved. It now names the two files that remain and records the third as deleted. This is a bounded in-place fix forced by the deletion. - **Gate retirement**: deleted `scripts/check-position-name-fold-loaders.mjs`, its `.github/workflows/lint.yml` step, and `scripts/measure-position-name-fold-census.mjs` (its self-test was already failing on `main`). Removed the gate's block from `scripts/pm/dispatch-gates.self-test.mjs`. `scripts/pm/dispatch-gates.data.mjs` held no entry for it (measured: no match), so it is unchanged. ### #25799: a set held through both channels names both - **Core** (`resolve-authz-context.ts`): `UserAuthzGrants` and `ResolvedAuthzContext` gain `directPermissions: string[]`. These are the names the user's own `sys_user_permission_set` rows confer in this resolution, by §6's own rules: the row applies in the tenant (or anchors platform standing), and its set is held by the catalog and not switched off. A set a position definition also names is listed; a set only a definition names is not. The field is additive and grants nothing. Hand-built literals must now set it (`directPermissions: []`). - **Explain:** a set in both channels reads `direct grant + position:P`, so revoking the position is not read as removing the access. On the userId path, `buildContextForUser` carries the split. - **Self path** (`security-plugin-explain-access.ts`): the request envelope's field set is closed (`ExecutionContextSchema`), so it cannot carry the split without a spec change. A **human** caller is therefore resolved once more through `resolveUserAuthzGrants` (`bypassGrantsCache`, as `buildContextForUser` does), and only the split is attached. Agent, guest and system callers keep the context as it arrived. A failed re-resolve reads `position:P (whether a direct grant also names it could not be read)`. - **Goldens:** the core batch-equivalence golden JSON gains each fixture's `directPermissions`, generated from the current resolver; the captured envelope is otherwise unchanged. Two exact envelopes add it (`request-rounds`, `explain-engine` `buildContextForUser`). In `platform-admin-config`'s posture-invariance arm, the posture moves only the channel (a `single` legacy grant anchors; walled, the config confers the set), so it compares without the split. Three plugin-security grant goldens, recorded before the split existed, compare the grants without it. - `scripts/engine-double-contract.pinned.json` records the new self-explain double (`--write`, as the gate asks). ### Carried by #26022 (spec and UI half, `Blocked-by: #24757`) `PositionGrantVia`'s `'name'` member and its docblock (`packages/spec/src/contracts/security-service.ts`), the "(by name)" badge (`PositionGrantCells.tsx`) and the `managedEnvironmentUsers.tsx` reader are outside this lane's fence (no `packages/spec/**` or `ui/**` change here). **Until #26022 lands, `'name'` is a published value that nothing emits.** Explain's `via` is `z.string()`, so the attribution fix is runtime-only. ### Observations carried from the 18.x half (`6303645764`) 1. Multi-org dedupe of the warning: the security catalog is environment-level (`dbLoaderForContext` returns the same `catalogSetLoader` for every caller), so per-name dedupe is correct. Nothing to change. 2. Lazy warning: kept. The warning fires on the first resolution that holds the position, not at boot. This is documented on `reportUngrantedNamesakeSets`. ### BREAKING - **Before:** a holder of position `sales_manager` was granted the set `sales_manager` whether or not the position named it. - **Now:** only the sets the definition lists. The first such resolution logs `position_name_fold_grant` with `granted: false`. - **Fix:** `permissionSets: ['sales_manager']` on the position, or nothing if the shared name is a coincidence. - **Cloud ordering:** an environment running HotCRM must be on HotCRM `v6.0.0` or later before it takes this release. Changeset: `.changeset/24757-retire-position-name-fold.md`, `@objectstack/plugin-security` `patch`, with the ADR-0087 marker `not-required (no-migration-prescription)`. ## Tests Every test that granted through a shared name now pins the opposite, and each fixture that used the fold to hand out a set now declares the grant: - `position-name-fold-warning.test.ts`: MUST FIRE asserts `granted: false`, the set is not granted, the exact payload keys and the new text (no "next major", "deprecated" or "in force"). The MUST NOT FIRE cases are kept. - `orgless-position-name-fold.test.ts`, `permission-set-active.test.ts`, `guest-grants-channel.test.ts`, `describe-position-grants.test.ts`: a position name reaches no set. Each has a control showing the set is real and resolves when named. - `delegated-admin-gate.test.ts`, `delegated-admin-own-name-set.posture.test.ts` (three postures): the own-name set is not judged, with a `field_audit` teeth control that a listed set still is. - New `explain-position-set-attribution.test.ts`: definition-bound, direct, a direct namesake, and an unreadable catalog. - Fixtures moved from name-granting to declared grants: `security-plugin.test.ts` (D10), `controlled-by-parent-chain-middle-master.test.ts`, `permission-set-resolution-memo.test.ts`. ### Ablations (negative pins; `scripts/ablation-replace.mjs`, each restored to `blob == HEAD`) | Leg | Mutation (the fold put back) | Result | |---|---|---| | A1 | resolver: `const requested = [...positions, ...explicitPermissionSets];` | 5 files / 11 tests red (warning, orgless, permission-set-active, guest, posture) | | A2 | `setsCarriedByPosition` walks the own-name set again | 2 files / 17 tests red | | A3 | `describePositionGrants` requests the position's own name | 2 tests red (`names each set its definition grants … no set that only shares its name`, agreement with the resolver) | | A4 | explain: `positions.includes(name) ? name : …` | 2 tests red (`expected 'position:sales_manager' to be 'direct grant'`) | | A5 | silence `reportUngrantedNamesakeSets` | 5 MUST FIRE tests red | | B1 | explain: the dual case reads `position:P` again | 1 red (`expected 'position:sales_rep' to be 'direct grant + position:sales_rep'`) | | B2 | core: no push into `directPermissions` | 3 of 4 core pins red | | B3 | core: `applyingGrants` tenant-blind | 4 of 4 core pins red | | C1 | self path: the human guard is `false` | 2 red (dual case, failed-read wording) | | C2 | self path: re-resolve for every non-system caller | 1 red (the agent pin: `expected 'direct grant + position:sales_rep' to be 'position:sales_rep'`) | The dual pin was red before the change: `expected 'position:sales_rep' not to be 'position:sales_rep'`. The userId pin was red against a core built without the field: `expected undefined to deeply equal ['crm_sales_user','forecasting']`. ### Readings (head `32fe4f1b`) - At `32fe4f1b` (main merged a second time): `@objectstack/plugin-security` 226 files, 4747 passed, 45 skipped, 0 failed. `@objectstack/core` 128 files, 2865 passed, 0 failed. Typecheck of both (src, scripts, test layer) exit 0. `check:engine-double-contract`, `check:objectql-double-limit`, `check:where-matcher`, `check-scripts-symbol-anchors` and `check:authz-resolver` all exit 0. - At `621e6afe` + the ledger commit: full build exit 0. plugin-security 4705 passed, core 2855 passed. eslint over plugin-security and `core/src/security` exit 0. - `dispatch-gates --commands`: 113 derived, each run with its exit code captured. 112 were green; `check:engine-double-contract` asked for the pinned-ledger `--write`, which was committed and re-run at 0. - `dispatch-gates --ran`: `113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN`. - The grant-resolving tests of the other packages that call the resolver or `buildContextForUser` (cli, client, objectql, plugin-approvals, plugin-audit, plugin-auth, plugin-hono-server, plugin-sharing, http-conformance, rest, runtime, service-automation, service-datasource, service-storage, trigger-record-change) all passed. - Dogfood (full): 296 files passed, 3 skipped; 2523 tests passed, 18 skipped, 0 failed. - The first round's readings (`aa5c5b22`, 110/110 gates) stand in the history. The gate repairs made in that round, `dispatch-gates.mjs` docblock among them, are listed in the Summary. - Changed lines: 3524 (+985 / −2539), over the 3000 human-merge threshold. Most of that is the two deleted scripts (−1907). - Open PRs on `plugin-security/**`: only #26005 touches `security-plugin.ts` (CBP `ownerMayEdit`), in other hunks. A local `git merge-tree` of the two merges that file cleanly. ## Acceptance notes Stale prose left in place because it is outside this claim's file surface. Each describes the fold as live: - `packages/core/src/security/resolve-authz-context.ts:1124`, `:1307`, `:1544` (docblocks naming the position-name fold). - `packages/rest/src/meta-item-read-gate.ts:3376` (`via: 'definition' | 'name'` docblock; the `'name'` half goes with #26022). - `packages/qa/dogfood/test/guest-grants-channel.dogfood.test.ts:15`, `:109` (comments on "the position-name fold's only route"). - `scripts/check-workflow-step-name-quoting.mjs:388`, `:409`: inline fixtures that spell the deleted gate's command. These are fixture strings and are not executed. - `scripts/pm/bare-root-worklist.mjs`, `scripts/measure-reserved-identity-name-census.mjs`, `docs/audits/*`: historical mentions of the deleted scripts. - `plugin-security/src/permission-set-name-collision.ts` and `cli/src/utils/permission-set-name-collisions.ts`: measured. These are the cross-package set-name collision (#17516), not the fold, so they are unchanged. ## 维护者速读(草稿) **改了什么** 以前,只要一个权限集和某个职位同名,持有这个职位的人就会自动拿到这个权限集,哪怕职位定义里根本没写它。现在取消了这条"同名自动授权"的暗道:职位只授予它定义里 `permissionSets` 明确列出的权限集。委派管理员的检查、权限解释面板("这个权限从哪来")和职位列表,也都改成只认定义里写明的绑定。解释面板还能分辨"直接授予"和"通过职位授予":两条渠道都有时会同时写明(#25799),这样撤掉职位也不会被误读成撤掉了权限。为此 core 的授权结果增加了一个只读字段 `directPermissions`。原来专门守护这条暗道的一个 CI 检查脚本和一个统计脚本一并删除。 **为什么改** 按 ADR-0131 和 #13419 裁决 B,"绑定要写出来,不能靠猜"。同名授权是隐式的:改个名字就可能无声地多给或少给权限,审计时也看不出来。HotCRM 已在 v6.0.0 把所有绑定写明,这一步是计划好的收尾。 **风险与代价(含回滚)** - 这是收窄(BREAKING):如果某个环境靠同名拿权限、但职位定义里没写,升级后这些用户会失去该权限集。系统会在第一次遇到时打出一条明确警告(`position_name_fold_grant`,`granted: false`),写明该怎么补。 - 云上顺序:跑 HotCRM 的环境要先升到 HotCRM v6.0.0 或更高,再升这个版本。 - 有一个过渡缺口:规范里的 `PositionGrantVia` 仍然列着 `'name'` 这个取值,界面上也还有"(同名)"标记,但运行时已不再产生它。这部分由 #26022 收尾,在它合入前,这个已发布的取值不会再出现。 - `@objectstack/core` 公开类型新增 `directPermissions` 字段,属于纯新增。手写这两个类型对象的代码需要补上 `directPermissions: []`。 - 自查自己的权限时,会为真人用户多做一次授权解析,只用于诊断,不影响实际授权。 - 回滚:直接 revert 这个 PR 即可恢复同名授权,不涉及数据迁移。 **席位意见** **你要做的** 审阅后决定是否批准合并(Tier H,改动了 `.github/workflows/lint.yml`,改动行数约 3500 行,超过 3000 行人工合并阈值,其中约 1900 行是删除的两个脚本)。合并后请让 #26022 跟进规范和界面的那一半。 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9598405 commit 733b3c8

40 files changed

Lines changed: 1008 additions & 2555 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/core': patch
4+
---
5+
6+
fix(plugin-security)!: a permission set that shares a held position's name is no longer granted; declare it in the position's `permissionSets` (#24757)
7+
8+
Clause-②: yes (narrowing: a holder of position `P` is no longer granted the permission set named `P` unless `P`'s definition names it in `permissionSets`; widened: `@objectstack/core`'s `UserAuthzGrants` and `ResolvedAuthzContext` gain the additive field `directPermissions`)
9+
Metadata-protocol: none
10+
Ruling-ref: #13419 ruling B (`5472608110`); 「甲」 as amended (`6303865025`); order and unlock on #24757 (`6290781571`, `6392096931`); #25799 seat ruling A (claims `6414302166`, `6414315157`)
11+
12+
ADR-0131 D3/D4: a position grants only the permission sets its definition's `permissionSets` names — the binding is written, not inferred. The security plugin's set resolution still requested every held position's NAME as a permission-set name too (the position-name fold), so a set that happened to share a position's name was granted to its holders without anyone writing the binding. #13419 ruling B retires that channel; HotCRM declared its bindings first (objectstack-ai/hotcrm#2200, released in hotcrm `v6.0.0`).
13+
14+
What changes:
15+
16+
- `resolvePermissionSetsForContext` requests only the context's `permissions` and the baseline. A held position's name reaches no set. `dropDeactivatedFoldedSets`, the second activation read that only the fold needed, is gone.
17+
- The `position_name_fold_grant` warning keeps its event name and fields and now says the opposite: the same-named set is **not** granted, and how to grant it (`permissionSets: ['P']`) or to rename one of the two. Its payload carries `granted: false`. It is still logged once per position name per process.
18+
- `describePositionGrants` lists only the sets a position's definition names, so it no longer serves `via: 'name'`. The `'name'` member of `PositionGrantVia` (`@objectstack/spec`) and its UI badge are retired separately in #26022; until then the value is published but never served.
19+
- The delegated-administration gate judges an assignment of position `P` on the sets `P`'s definition names only. A set that only shares `P`'s name is no longer walked for the allowlist, the containment check or the organization boundary, because no assignment grants it.
20+
- `explainAccess` attributes a set to `position:P` when `P`'s definition names it, and says `direct grant` otherwise. When the position definitions cannot be read it says so, rather than guessing either. A set held both through the user's own grant row and through a held position's definition reads `direct grant + position:P`, so revoking the position is not mistaken for removing the access (#25799).
21+
- `@objectstack/core`: `UserAuthzGrants` and `ResolvedAuthzContext` gain `directPermissions: string[]`, the names conferred by the user's own `sys_user_permission_set` rows that apply in the active organization (a set a position definition also names included, a set only a definition names excluded). It is additive and grants nothing; the explainer reads it. Code that builds either object by hand must now set it (`directPermissions: []` when there are no grant rows).
22+
- The lint gate `check-position-name-fold-loaders` and the census script `measure-position-name-fold-census` are deleted. They guarded the fold's only route, which no longer exists.
23+
24+
**BREAKING** for an environment that relied on a shared name to grant a set.
25+
26+
- **Before.** A user holding position `sales_manager` was granted the permission set `sales_manager` whether or not the position's definition named it.
27+
- **Now.** That user is granted only the sets the `sales_manager` definition lists in `permissionSets`. The first resolution that would have folded the set logs `position_name_fold_grant` with `granted: false`, naming the position and the fix.
28+
- **Fix.** Name the set in the position: `permissionSets: ['sales_manager']`. If the shared name is a coincidence, nothing needs to change.
29+
- **Cloud ordering.** An environment running HotCRM must be on the HotCRM release that declares its bindings (`v6.0.0` or later) before it takes this release.
30+
31+
<!-- adr-0087: not-required (no-migration-prescription) a run-time narrowing in the security plugin's permission-set resolution: a held position's name is no longer requested as a permission-set name. No authorable key, spelling or stored shape moves, the position and permission-set schemas parse exactly as before, and no stored row is read, rewritten or converted. The repair is the author's choice between naming the set in the position's `permissionSets` and leaving it ungranted, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); nothing exported from `@objectstack/spec` moves in this change; and the one type change, the additive `directPermissions` on `@objectstack/core`'s resolver output, is a per-request value no stored artifact or authored metadata carries, so no conversion has a source to rewrite. -->

‎.github/workflows/lint.yml‎

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -947,29 +947,6 @@ jobs:
947947
continue-on-error: true
948948
run: pnpm check:stack-collection-maps
949949

950-
# The vendored HotCRM permissions artifact has NO non-test loader (#13419).
951-
# `security-plugin.ts` folds a POSITION name into the permission-set
952-
# request, so a permission set named after a position is granted with no
953-
# `sys_position_permission_set` row behind it. The #13419 census reports
954-
# exactly two such folds — `sales_rep` and `sales_manager` — and both take
955-
# their permission-set half from a fixture that only one TEST reads. That
956-
# makes them a collision between an example app's position names and a
957-
# test fixture's set names rather than grants any deployment holds, which
958-
# is why the ruling's 物化 worklist is empty in this repo. The empty
959-
# worklist is the thing being pinned: wire that artifact into a real
960-
# composition and this reddens, instead of two live authorization grants
961-
# appearing with nothing said. Its `--self-test` supplies the loader cases
962-
# a clean tree by construction does not contain — without them the
963-
# production zero could equally be a scanner that stopped matching.
964-
# Invoked as `node` rather than `pnpm check:*`: see the GATE INVOCATION
965-
# IDIOM note at the top of this file. ~1s, no spawns.
966-
- name: 'The #13419 name-fold fixture has no non-test loader'
967-
id: the-name-fold-fixture-has-no-non-test-loader
968-
continue-on-error: true
969-
run: |
970-
node scripts/check-position-name-fold-loaders.mjs --self-test
971-
node scripts/check-position-name-fold-loaders.mjs
972-
973950
# The dashboard widget `options` census vs the spec (#12926). The parser's
974951
# `CONSUMED_WIDGET_OPTION_KEYS` decides which `options` keys get a
975952
# `unconsumed-widget-option` warning, and five of its six members are the

‎packages/cli/test/verify-parent-write-reach.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ const PERMISSIONS = [
7171
},
7272
];
7373

74-
const POSITIONS = [{ name: 'vpw_manager', label: 'Manager' }];
74+
// [ADR-0131 D3/D4; #24757] The position names its set: a set that only shares
75+
// a position's name is granted by nothing.
76+
const POSITIONS = [{ name: 'vpw_manager', label: 'Manager', permissionSets: ['vpw_manager'] }];
7577

7678
/** The same app, plus a hook that refuses every edit of a contract with the platform's own code. */
7779
const CONTROL_SOURCE = `{

‎packages/core/src/security/assemble-execution-context.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ function observable(ctx: any): Record<string, unknown> {
141141
const baseAuthz = (over: Partial<ResolvedAuthzContext> = {}): ResolvedAuthzContext => ({
142142
positions: [],
143143
permissions: [],
144+
directPermissions: [],
144145
systemPermissions: [],
145146
org_user_ids: [],
146147
accessible_org_ids: [],

‎packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"everyone"
77
],
88
"permissions": [],
9+
"directPermissions": [],
910
"systemPermissions": [],
1011
"org_user_ids": [
1112
"u_empty"
@@ -75,6 +76,7 @@
7576
"everyone"
7677
],
7778
"permissions": [],
79+
"directPermissions": [],
7880
"systemPermissions": [],
7981
"org_user_ids": [
8082
"u_multi",
@@ -156,6 +158,7 @@
156158
"everyone"
157159
],
158160
"permissions": [],
161+
"directPermissions": [],
159162
"systemPermissions": [],
160163
"org_user_ids": [
161164
"u_lapsed",
@@ -241,6 +244,7 @@
241244
"read_all",
242245
"base_access"
243246
],
247+
"directPermissions": [],
244248
"systemPermissions": [
245249
"record_write"
246250
],
@@ -337,6 +341,10 @@
337341
"admin_full_access",
338342
"org_tools"
339343
],
344+
"directPermissions": [
345+
"admin_full_access",
346+
"org_tools"
347+
],
340348
"systemPermissions": [
341349
"manage_users"
342350
],
@@ -436,6 +444,9 @@
436444
"low",
437445
"organization_admin"
438446
],
447+
"directPermissions": [
448+
"low"
449+
],
439450
"systemPermissions": [],
440451
"org_user_ids": [
441452
"u_ta"
@@ -523,6 +534,7 @@
523534
"permissions": [
524535
"ai_seat"
525536
],
537+
"directPermissions": [],
526538
"systemPermissions": [],
527539
"org_user_ids": [
528540
"u_ai"
@@ -602,6 +614,7 @@
602614
"everyone"
603615
],
604616
"permissions": [],
617+
"directPermissions": [],
605618
"systemPermissions": [],
606619
"org_user_ids": [
607620
"u_noai"
@@ -676,6 +689,9 @@
676689
"ai_seat",
677690
"extra"
678691
],
692+
"directPermissions": [
693+
"extra"
694+
],
679695
"systemPermissions": [],
680696
"org_user_ids": [
681697
"u_seed"
@@ -749,6 +765,7 @@
749765
"everyone"
750766
],
751767
"permissions": [],
768+
"directPermissions": [],
752769
"systemPermissions": [],
753770
"org_user_ids": [
754771
"u_lim",
@@ -2029,6 +2046,7 @@
20292046
"everyone"
20302047
],
20312048
"permissions": [],
2049+
"directPermissions": [],
20322050
"systemPermissions": [],
20332051
"org_user_ids": [
20342052
"u_noorg"

‎packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ interface Golden { sequentialLegs: number; grants: unknown; queries: RecordedCal
114114
* — the last mainline commit before the batch (post-#10982). `sequentialLegs`
115115
* is that run's leg count, which equalled its query count because every read
116116
* awaited the one before it.
117+
*
118+
* [#25799] `directPermissions` postdates that capture: each fixture's value was
119+
* added from the current resolver, beside the captured envelope it leaves
120+
* unchanged (the names the fixture's own grant rows confer in its tenant).
117121
*/
118122
const GOLDEN: Record<string, Golden> = JSON.parse(
119123
readFileSync(new URL('./resolve-authz-context.batch-equivalence.golden.json', import.meta.url), 'utf8'),
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25799] `resolveUserAuthzGrants` reports its own split of the two channels a
5+
* set reaches a user through: `directPermissions` lists the names conferred by
6+
* the user's OWN grant rows (`sys_user_permission_set`, §6), and never a name
7+
* only a held position's definition names (§6a). The explainer reads it to
8+
* tell an assignment row from a position definition.
9+
*
10+
* The rows it lists are the rows §6 confers, by §6's own rules: the row applies
11+
* in the active organization, and its set is one the catalog holds and the
12+
* activation ledger has not switched off.
13+
*/
14+
15+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
16+
import { resetPlatformAdminEmailMemo } from './platform-admin.js';
17+
import { resolveUserAuthzGrants } from './resolve-authz-context.js';
18+
import { makeRecordingQl } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js';
19+
import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js';
20+
21+
const ORG_A = 'org_a';
22+
const ORG_B = 'org_b';
23+
24+
const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL';
25+
const POSTURE_ENV = 'OS_TENANCY_POSTURE';
26+
let ambient: Record<string, string | undefined> = {};
27+
beforeAll(() => {
28+
ambient = { [OWNER_ENV]: process.env[OWNER_ENV], [POSTURE_ENV]: process.env[POSTURE_ENV] };
29+
delete process.env[OWNER_ENV];
30+
delete process.env[POSTURE_ENV];
31+
resetPlatformAdminEmailMemo();
32+
});
33+
afterAll(() => {
34+
for (const [k, v] of Object.entries(ambient)) {
35+
if (v === undefined) delete process.env[k];
36+
else process.env[k] = v;
37+
}
38+
resetPlatformAdminEmailMemo();
39+
});
40+
41+
/** `everyone` (the anchor every member holds) names two sets: one also granted directly below, one not. */
42+
const definitions = {
43+
permissions: [
44+
{ name: 'tools', systemPermissions: ['cap_tools'] },
45+
{ name: 'elsewhere', systemPermissions: ['cap_elsewhere'] },
46+
{ name: 'both_channels', systemPermissions: ['cap_both'] },
47+
{ name: 'definition_only', systemPermissions: ['cap_definition'] },
48+
],
49+
positions: [{ name: 'everyone', permissionSets: ['both_channels', 'definition_only'] }] as Array<Record<string, unknown>>,
50+
};
51+
52+
/** The user's own grant rows: two in ORG_A, one in ORG_B. */
53+
const GRANTS = [
54+
{ permission_set: 'tools', organization_id: ORG_A },
55+
{ permission_set: 'both_channels', organization_id: ORG_A },
56+
{ permission_set: 'elsewhere', organization_id: ORG_B },
57+
];
58+
59+
async function resolve(tenantId: string | undefined, ledger: unknown[] = []) {
60+
const ql = bindStaticSecurityCatalog(makeRecordingQl({
61+
sys_user: [{ id: 'u1', email: 'u1@example.com' }],
62+
sys_member: [{ user_id: 'u1', organization_id: ORG_A, role: 'member' }, { user_id: 'u1', organization_id: ORG_B, role: 'member' }],
63+
sys_user_position: [],
64+
sys_position: [],
65+
sys_position_permission_set: [],
66+
sys_user_permission_set: GRANTS.map((g) => ({ user_id: 'u1', ...g })),
67+
sys_permission_set: [],
68+
sys_metadata_activation: ledger,
69+
}), definitions);
70+
return resolveUserAuthzGrants(ql, 'u1', tenantId ? { tenantId } : {});
71+
}
72+
73+
describe('[#25799] directPermissions is the resolver\'s own direct channel', () => {
74+
it('lists the sets the user\'s own rows confer in this organization, a set a definition also names included, and no set only a definition names', async () => {
75+
const out = await resolve(ORG_A);
76+
// The control: every one of these is in force for the user.
77+
expect(out.permissions).toEqual(expect.arrayContaining(['tools', 'both_channels', 'definition_only']));
78+
expect(out.directPermissions).toEqual(['tools', 'both_channels']);
79+
});
80+
81+
it('follows the tenant rule: a row of another organization is not a direct grant here, and is one there', async () => {
82+
expect((await resolve(ORG_A)).directPermissions).not.toContain('elsewhere');
83+
const inB = await resolve(ORG_B);
84+
expect(inB.directPermissions).toEqual(['elsewhere']);
85+
expect(inB.permissions).toContain('elsewhere');
86+
});
87+
88+
it('with no active organization no stored row applies, so nothing is direct while the definition-bound sets stand', async () => {
89+
const out = await resolve(undefined);
90+
expect(out.directPermissions).toEqual([]);
91+
expect(out.permissions).toEqual(expect.arrayContaining(['both_channels', 'definition_only']));
92+
});
93+
94+
it('a set the activation ledger switched off is not listed: the row confers nothing', async () => {
95+
const out = await resolve(ORG_A, [{ metadata_type: 'permission', name: 'tools', package_id: null, active: false }]);
96+
expect(out.permissions).not.toContain('tools');
97+
expect(out.directPermissions).toEqual(['both_channels']);
98+
});
99+
});

‎packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,13 @@ describe('[#11663 L5] standing across postures — three arms invariant, the leg
636636
requestPosture(p);
637637
envelopes[p] = await resolveUserAuthzGrants(makeQl(tables()), 'usr_1', { nowMs: NOW });
638638
}
639-
expect(envelopes.single).toEqual(envelopes.isolated);
639+
// [#25799] `directPermissions` names the CHANNEL a set arrives through,
640+
// not the standing: under \`single\` the legacy grant row anchors and so
641+
// applies with no active organization, while walled it applies nowhere
642+
// and the config confers the same set. The standing envelope is compared
643+
// without it.
644+
const standing = ({ directPermissions: _channel, ...rest }: any) => rest;
645+
expect(standing(envelopes.single)).toEqual(standing(envelopes.isolated));
640646
});
641647
}
642648

‎packages/core/src/security/resolve-authz-context.request-rounds.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ describe('[#10757 leg 4] the Default Organization read and the activation ledger
9494
expect(grants).toEqual({
9595
positions: ['platform_admin', 'org_owner', 'everyone'],
9696
permissions: [ADMIN_FULL_ACCESS],
97+
directPermissions: [ADMIN_FULL_ACCESS],
9798
systemPermissions: expect.any(Array),
9899
org_user_ids: ['usr_1'],
99100
accessible_org_ids: ['org_default'],

0 commit comments

Comments
 (0)