Repository navigation
fix(plugin-security)!: retire the position-name fold; a namesake set is reported, never granted (#24757) - #26064
Conversation
…is reported, never granted WIP: resolver, describePositionGrants, delegated walk, explain attribution, the fold-loader gate and census instrument. Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD Co-authored-by: Claude <noreply@anthropic.com>
…es to declared grants Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD Co-authored-by: Claude <noreply@anthropic.com>
) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
…ld census (#24757) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
📓 Docs Drift CheckThis PR changes 2 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 36 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f968d1acecd9894b62fd6b69471b3ef6dee46f18 && git checkout f968d1acecd9894b62fd6b69471b3ef6dee46f18
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6862ef62abd9062b54e92a8d8f82ec1abf1320c1 0239ff1635ef4879e49ebaea37190839f4f402f6 && git checkout -B drift-repro 6862ef62abd9062b54e92a8d8f82ec1abf1320c1 && git merge --no-ff 0239ff1635ef4879e49ebaea37190839f4f402f6
node scripts/docs-audit/affected-docs.mjs --json 6862ef62abd9062b54e92a8d8f82ec1abf1320c1
|
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
…d directly and through a position (#25799) resolveUserAuthzGrants reports its own split of the two channels as the additive directPermissions on UserAuthzGrants and ResolvedAuthzContext: the names the user's own sys_user_permission_set rows confer in the active organization. The explainer reads it on the self and userId paths, so a set held both directly and through a held position's definition reads 'direct grant + position:P' instead of hiding the direct row. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
…channel split (#25799) The request envelope a self-explanation runs on carries no directPermissions, so explainAccessForCaller resolves a human caller once more through resolveUserAuthzGrants and attaches only that split. Agent, guest and system callers keep the context as it arrived; a failed resolution is said, never guessed. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
…; conforming engine doubles (#25799) The batch-equivalence golden and two exact envelopes gain the resolver's channel split; the plugin-security grant goldens, recorded before the split existed, compare the grants without it. The new explain doubles honour where (refusing combinators), the caller's limit and the findOne predicate. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
…plain double (#25799) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
…lares its positions' sets (#24757, #25799) plugin-audit's hand-built authz envelopes add the now-required directPermissions (workspace typecheck). The parent-write-reach fixture granted pwr_manager / pwr_rep through the retired position-name fold; its positions now name their sets. Stale fold-as-live prose in resolve-authz-context.ts is corrected. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
…24757) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
Contract reviewServed-tier: Check-runs on the head: 47 check-runs, 39 success, 8 skipped (opt-in harnesses and matrix placeholders: Reviewed against card #24757 (body and all 18 comments, rulings ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」. This PR is Tier H twice over (
Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 40355861805 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Queue red, triaged · Run 40355861805 (Test Core 5/8) failed Generated by Claude Code |
…a direct grant (#24757) controlled-by-parent-owner-may-edit.test.ts landed on main (#26005) after this branch's base. Its delegator reached omd_requester through a sys_user_position row named after the set, the fold this PR retires, so the delegated-update pin refused at the on-behalf-of check instead of the master gate. The row is now a sys_user_permission_set grant, the same rebinding the chain-middle-master suite received; every expectation is unchanged. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
Contract reviewServed-tier: Check-runs on the head (read 2026-10-26T17:23Z, all concluded): 47 check-runs, 39 success, 8 skipped (opt-in harnesses and matrix placeholders: Reviewed against card #24757 (body and all 18 comments, rulings ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 (re-ACCEPT after the dequeue)ACCEPT · This supersedes the ACCEPT on
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #24757
Fixes #25799
Clause-②: yes (narrowing: a holder of position
Pis no longer granted the permission set namedPunlessP's definition names it inpermissionSets; widened:@objectstack/core'sUserAuthzGrantsandResolvedAuthzContextgain the additive fielddirectPermissions)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 (claims6414302166,6414315157)Summary
ADR-0131 D3/D4 says a position grants only the permission sets its definition's
permissionSetsnames. 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 inv6.0.0), and #24757's unlock (6392096931) clears this half.security-plugin-permission-sets.ts): requests onlycontext.permissionsplus the baseline.dropDeactivatedFoldedSets, the second activation read only the fold needed, is deleted.position_name_fold_grantkeeps its event name and fields, addsgranted: false, and now says the same-named set is not granted and how to declare it.reportNameFoldCollisionsbecamereportUngrantedNamesakeSets. 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 servesvia: 'name'.delegated-admin-gate.ts):setsCarriedByPositionreturns 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 theownNameNoterefusal suffix is gone.explain-engine.ts):position:PwhenP's definition names the set (read from the security catalog), otherwisedirect grant. When the definitions are unreadable it saysdirect 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 security/explain: sets a position grants through its definition's permissionSets are attributed "via direct grant", not to the position #25799's case.scripts/pm/dispatch-gates.mjs(docblock only): it cited the deleted census's#selfTestas a live anchor, whichcheck-scripts-symbol-anchorsflagged 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.scripts/check-position-name-fold-loaders.mjs, its.github/workflows/lint.ymlstep, andscripts/measure-position-name-fold-census.mjs(its self-test was already failing onmain). Removed the gate's block fromscripts/pm/dispatch-gates.self-test.mjs.scripts/pm/dispatch-gates.data.mjsheld no entry for it (measured: no match), so it is unchanged.#25799: a set held through both channels names both
resolve-authz-context.ts):UserAuthzGrantsandResolvedAuthzContextgaindirectPermissions: string[]. These are the names the user's ownsys_user_permission_setrows 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: []).direct grant + position:P, so revoking the position is not read as removing the access. On the userId path,buildContextForUsercarries the split.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 throughresolveUserAuthzGrants(bypassGrantsCache, asbuildContextForUserdoes), and only the split is attached. Agent, guest and system callers keep the context as it arrived. A failed re-resolve readsposition:P (whether a direct grant also names it could not be read).directPermissions, generated from the current resolver; the captured envelope is otherwise unchanged. Two exact envelopes add it (request-rounds,explain-enginebuildContextForUser). Inplatform-admin-config's posture-invariance arm, the posture moves only the channel (asinglelegacy 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.jsonrecords 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 themanagedEnvironmentUsers.tsxreader are outside this lane's fence (nopackages/spec/**orui/**change here). Until #26022 lands,'name'is a published value that nothing emits. Explain'sviaisz.string(), so the attribution fix is runtime-only.Observations carried from the 18.x half (
6303645764)dbLoaderForContextreturns the samecatalogSetLoaderfor every caller), so per-name dedupe is correct. Nothing to change.reportUngrantedNamesakeSets.BREAKING
sales_managerwas granted the setsales_managerwhether or not the position named it.position_name_fold_grantwithgranted: false.permissionSets: ['sales_manager']on the position, or nothing if the shared name is a coincidence.v6.0.0or later before it takes this release.Changeset:
.changeset/24757-retire-position-name-fold.md,@objectstack/plugin-securitypatch, with the ADR-0087 markernot-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 assertsgranted: 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 afield_auditteeth control that a listed set still is.explain-position-set-attribution.test.ts: definition-bound, direct, a direct namesake, and an unreadable catalog.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 toblob == HEAD)const requested = [...positions, ...explicitPermissionSets];setsCarriedByPositionwalks the own-name set againdescribePositionGrantsrequests the position's own namenames each set its definition grants … no set that only shares its name, agreement with the resolver)positions.includes(name) ? name : …expected 'position:sales_manager' to be 'direct grant')reportUngrantedNamesakeSetsposition:Pagainexpected 'position:sales_rep' to be 'direct grant + position:sales_rep')directPermissionsapplyingGrantstenant-blindfalseexpected '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)32fe4f1b(main merged a second time):@objectstack/plugin-security226 files, 4747 passed, 45 skipped, 0 failed.@objectstack/core128 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-anchorsandcheck:authz-resolverall exit 0.621e6afe+ the ledger commit: full build exit 0. plugin-security 4705 passed, core 2855 passed. eslint over plugin-security andcore/src/securityexit 0.dispatch-gates --commands: 113 derived, each run with its exit code captured. 112 were green;check:engine-double-contractasked 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.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.aa5c5b22, 110/110 gates) stand in the history. The gate repairs made in that round,dispatch-gates.mjsdocblock among them, are listed in the Summary.plugin-security/**: only feat(spec,plugin-security,lint): sharing.controlledByParent.ownerMayEdit — a detail row's owner updates it with read on the master #26005 touchessecurity-plugin.ts(CBPownerMayEdit), in other hunks. A localgit merge-treeof 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 spec: retire PositionGrantVia 'name' (the position-name fold's grant mark) once #24757's runtime retirement lands #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.tsandcli/src/utils/permission-set-name-collisions.ts: measured. These are the cross-package set-name collision ([finding] A package's whole declared permission set is dropped on a set-name collision with no diagnostic and no build gate — the comment says "refuse loudly", the code warns through a doubly-optional logger #17516), not the fold, so they are unchanged.维护者速读(草稿)
改了什么
以前,只要一个权限集和某个职位同名,持有这个职位的人就会自动拿到这个权限集,哪怕职位定义里根本没写它。现在取消了这条"同名自动授权"的暗道:职位只授予它定义里
permissionSets明确列出的权限集。委派管理员的检查、权限解释面板("这个权限从哪来")和职位列表,也都改成只认定义里写明的绑定。解释面板还能分辨"直接授予"和"通过职位授予":两条渠道都有时会同时写明(#25799),这样撤掉职位也不会被误读成撤掉了权限。为此 core 的授权结果增加了一个只读字段directPermissions。原来专门守护这条暗道的一个 CI 检查脚本和一个统计脚本一并删除。为什么改
按 ADR-0131 和 #13419 裁决 B,"绑定要写出来,不能靠猜"。同名授权是隐式的:改个名字就可能无声地多给或少给权限,审计时也看不出来。HotCRM 已在 v6.0.0 把所有绑定写明,这一步是计划好的收尾。
风险与代价(含回滚)
position_name_fold_grant,granted: false),写明该怎么补。PositionGrantVia仍然列着'name'这个取值,界面上也还有"(同名)"标记,但运行时已不再产生它。这部分由 spec: retire PositionGrantVia 'name' (the position-name fold's grant mark) once #24757's runtime retirement lands #26022 收尾,在它合入前,这个已发布的取值不会再出现。@objectstack/core公开类型新增directPermissions字段,属于纯新增。手写这两个类型对象的代码需要补上directPermissions: []。席位意见
你要做的
审阅后决定是否批准合并(Tier H,改动了
.github/workflows/lint.yml,改动行数约 3500 行,超过 3000 行人工合并阈值,其中约 1900 行是删除的两个脚本)。合并后请让 #26022 跟进规范和界面的那一半。🤖 Generated with Claude Code
https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD