Skip to content

fix(plugin-sharing): hierarchy resolver 按权威字段拿到调用方活动组织 (#5859) - #6067

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-5859-owner-scope-org-authority
Aug 7, 2026
Merged

fix(plugin-sharing): hierarchy resolver 按权威字段拿到调用方活动组织 (#5859)#6067
baozhoutao merged 3 commits into
mainfrom
claude/issue-5859-owner-scope-org-authority

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #5859

契约半边(#5858 / PR #5973)已合入 origin/main(abeb375),producer 半边即本 PR。
本单三项已全部落地:① 权威字段填充、② 姿态感知的 fail-closed 门(按 #5859 的裁决取
C,裁决见 issue 留档)、③ 真实 seam 测试。

前提复核(对 origin/main,post-#5973)

事实 复核结果
plugin-sharing/src/sharing-service.ts 那处 (context as any).organizationId ?? null 仍在(#5973 的机械补齐未触及此处,行号漂移但形状不变)
仓内是否有传输层写过 organizationId 到执行上下文 零处rest-server.tsruntime/src/security/resolve-execution-context.ts 都只 tenantId: authz.tenantId
活动组织到底装在哪 resolveAuthzContext(@objectstack/core)tenantId = session.activeOrganizationId;API-key 路径 tenantId = sys_api_key.organization_id;ExecutionContext.tenantId 注释即「Current organization/tenant ID (resolved from session.activeOrganizationId)」
真实 HTTP boot 实测 在 dogfood showcase 全栈 boot(bootStack + 真实登录 + 真实请求)里插桩打印进入 resolveOwnerScopeIds 的 context:键集为 userId, tenantId, email, positions, permissions, systemPermissions, posture, isSystem, org_user_ids, accessible_org_ids, timezone, locale, __kernel, __readScope —— tenantId 键、无 organizationId

结论:前提成立,该读取结构性恒 null,DEPTH 的组织收窄从 ADR-0057 起就没生效过。

① 修复本体(producer 按权威字段填充)

同一映射在 plugin-security 的 Layer-0 租户墙里早已在用
(computeTenantLayer0Filter({ organizationId: context?.tenantId })),两层 enforcement
现在按同一字段的同一值收窄。

② 姿态感知的组织门(裁决 C)

SharingService 新增 late-bound tenancy 姿态探针 —— 读法照抄 SecurityPlugin
Layer-0 墙读 tenancy 服务的那段
(try { ctx.getService('tenancy') } catch { null },
与既有 securityService / hierarchyResolver 同形),由 SharingServicePlugin 接线。
ADR-0105 D1 的既有分叉决定「没有活动组织」意味着什么,与
computeTenantLayer0Filter 对同一问题的答案逐条同形:

姿态 缺权威组织时 依据
single 照常展开 DEPTH(行为不变) 那是唯一隐含租户,不是「所有组织」;ADR-0057 D1 的 20 条 e2e 正是在这一形态下钉住的
group / isolated 拒绝:不咨询 resolver,回落 owner-only,warn 点名 ADR-0095 D1 / ADR-0105 D1 与 #5973 的 fail-closed 义务原文(「no org」不是「every org」) 有墙即有租户维度;Layer 0 在同一情形下也是拒绝(RLS_DENY / 空访问集)
解析不出(未接线 / 探针抛错 / 词表外的值) 按有墙处理 未知姿态不是 single 的证据;把它读成 single 恰恰会在配置已可疑的部署上恢复展开。周围守卫同惯例:securityService 缺失 → owner-only,hierarchyResolver 缺失 → owner-only

legacy 形状 isolationActive: false 是一句肯定的「无墙」声明,按 single 处理;
isolationActive: undefined 仍算解析不出。

为什么不是无条件拒绝:实测证明「无活动组织」是本仓受支持的部署形态 ——
@objectstack/verify harness 故意 autoDefaultOrganization: false,注释自述它证明的是
隔离谱系的两端之一 pure single-tenant (no org, no scoping);我先实现的无条件版本让
ADR-0057 D1 的端到端证明红了 6 条(showcase-scope-depth{,-write,-fallback})。裁决取 C
后这 20 条全绿,而有墙姿态的洞按新钉子闭合。

③ 测试:改用真实 seam 产生的 context

新增 exec-context-seam.testkit.ts:组织以真实登录携带它的方式进入
(better-auth session.activeOrganizationId + sys_member 行),经由两条 HTTP 入口
共用的解析器 resolveAuthzContext 产出上下文,测试从不书写任何租户字段名
手工构造 { userId, organizationId } 的旧喂法正是本缺陷躲过全部单测的原因。

新增钉子(sharing-service.test.ts,12 条):

既有 DEPTH fixture 分诊(3 条):canManageShares 的三条 DEPTH 用例改喂 seam context ——
判定不变,但分支现在跑在运行时真实产出的上下文形状上。

反向验证(两轮,方向均先判后跑)

第一轮(映射):把 producer 改回 (context as any).organizationId ?? null
预判红,实测 Tests 6 failed | 348 passed,含
× #5852 flip … AssertionError: expected true to be false(201 在服务层复现)与
× hands the resolver a NON-EMPTY organizationId … expected null not to be null
唯一红的是 seam 形状钉 —— 它钉的是传输层而非本修复,如实说明不硬凑。

第二轮(姿态门):删掉门 → 预判「只有有墙方向红、single 方向必须保持绿」,实测:

× group posture: no active organization → the resolver is NOT consulted, loudly
× isolated posture: no active organization → the resolver is NOT consulted, loudly
× an UNRESOLVABLE posture is not evidence of `single` — it refuses too
× a blank organization is ALSO an absent one under a wall
 Tests  4 failed | 355 passed (359)

single 方向的两条钉子按预判保持绿 —— 它们钉的是「门必须不误伤单租户」,与门的存在
无关,这正是本次裁决要买的东西。

验证(全部前台阻塞执行)

pnpm --filter @objectstack/plugin-sharing test        → 13 files / 359 tests passed
pnpm --filter @objectstack/plugin-sharing typecheck   → tsc --noEmit 无输出
npx eslint (本 PR 的 4 个改动文件)                     → 无输出
node scripts/check-nul-bytes.mjs                      → OK (5782 files)
node scripts/check-org-identifier.mjs                 → OK (1666 files)
消费半径 e2e(ADR-0057 D1 全栈 boot,含插件接线):
pnpm --filter @objectstack/dogfood exec vitest run \
  test/showcase-scope-depth.dogfood.test.ts test/showcase-scope-depth-write.dogfood.test.ts test/showcase-scope-depth-fallback.dogfood.test.ts
                                                      → 3 files / 20 tests passed

最后这条同时是接线的端到端证据:那 20 条 e2e 里调用方确实没有活动组织,所以只有当
ctx.getService('tenancy') 真的解析出 single 时它们才可能绿 —— 探针没接上、抛错或解析
失败都会按「有墙」拒绝并让它们变红。

端到端 201 → 403 的诚实边界

#5852 的 201 需要企业版 resolver(@objectstack/security-enterprise,cloud 私有)
才能复现;本仓的多组织 harness 也依赖 cloud 私有的 @objectstack/organizations
(multiTenant: true),因此真正的 201/403 端到端在本仓不可运行 —— 不做假。
本 PR 能给的等价证据是:服务层的 201→403 翻转(反向验证第一轮)+ 真实 HTTP boot 里
context 键集的实测 + 全栈 boot 里姿态探针的接线证明。企业侧对照复测应在
cloud#919 / cloud#1148 一侧带上。

`resolveOwnerScopeIds` 构造 `HierarchyScopeContext` 时读 `(context as any).organizationId`
—— 仓内没有任何传输层写过这个键(REST 与 runtime dispatcher 都从 `resolveAuthzContext`
组装,活动组织落在 `tenantId`),所以该字段结构性恒 null,企业版 resolver 只读它,
整条 DEPTH 租户隔离从未生效(#5852:group 姿态下普通成员对兄弟组织记录的 share 得 201)。

- producer 按权威字段填充:`organizationId` = 执行上下文的活动组织;`tenantId` 作为
  @deprecated 别名原样携带(非消费端 `?? tenantId` 兜底)。
- 无组织时如实传 `null`,空白串归一为 `null`。
- resolver 抛错的静默回退改为留声(logger.warn)。
- 测试改用真实 seam 产生的 context(`resolveAuthzContext`,exec-context-seam.testkit.ts),
  不再手工构造 `{ userId, organizationId }` —— 那正是本缺陷躲过全部单测的原因。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 12:32am

Request Review

Copy link
Copy Markdown
Contributor Author

CI 首轮 4 条红 = GitHub Actions 基础设施故障,与本 PR 无关(实证)

filter / No other open PR may claim the same issue / Dogfood Verify CLI /
Flag docs affected by code changes 四条的日志都停在 checkout 之前的同一句:

Prepare all required actions
Getting action download info
Failed to resolve action download info. Error: Service Unavailable
Retrying in 22.795 seconds
##[error]Service Unavailable
##[error]Failed to resolve action download info.

四条分属三个不同 workflow run,均未跑到本仓任何代码;ESLint / Check Changeset /
Check PR Sizecancelledfilter 这个前置 job 失败后的连带取消。

顺带独立核了重复认领门想查的那件事:git ls-remote --heads origin | grep issue-5859
只有本分支一条,不存在第二个 PR 认领 #5859 —— 那条红同样是上面这个故障,不是真发现。

故障平息后重跑即可。本地实测(worktree 内)见 PR 正文「验证」一节:
plugin-sharing 354 tests / typecheck / eslint 全绿,ADR-0057 D1 消费半径 e2e 20 tests 全绿。


Generated by Claude Code

#5859 裁决(C)追加:`SharingService` 新增 late-bound `tenancy` 姿态探针(读法与
SecurityPlugin 为 Layer-0 墙读 `tenancy` 服务一致,由 SharingServicePlugin 接线),
按 ADR-0105 D1 的既有分叉决定「没有活动组织」意味着什么:

- `single`(纯单租户,无组织):行为不变,DEPTH 照常 —— 那是唯一隐含租户,不是「所有组织」;
- `group` / `isolated`:权威组织缺失/空白 → 不咨询 resolver,回落 owner-only,
  warn 点名 ADR-0095 D1 / ADR-0105 D1 与 #5973 的 fail-closed 契约义务;
- 姿态解析不出(未接线/抛错/词表外)→ 按有墙处理,未知姿态不是 single 的证据。

测试两个方向都钉:single+无组织仍 widened(先绿保持绿)、walled+无组织拒绝(先红后绿)、
姿态不可解析拒绝、legacy `isolationActive:false` 视为无墙、空白组织在两侧各自的表现。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/plugin-sharing)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-sharing)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-sharing)
  • content/docs/permissions/authorization.mdx (via packages/plugins/plugin-sharing)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-sharing)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-sharing)
  • content/docs/protocol/objectql/security.mdx (via packages/plugins/plugin-sharing)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-sharing)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Contributor Author

裁决 C 已追加落地(commit ad0e5d3),PR 正文已按最终形态重写

  • 姿态感知门:single 行为不变 / group + isolated 缺权威组织即拒绝(不咨询 resolver,warn 点名 ADR-0095 D1 / ADR-0105 D1 与 fix(spec)!: HierarchyScopeContext 声明 organizationId 为权威租户字段并转必填 (#5858) #5973)/ 姿态解析不出按有墙处理;探针读法照抄 SecurityPlugintenancy 服务那段,由 SharingServicePlugin 接线。
  • 第二轮反向验证(删门)按预判落点:有墙方向 4 条红,single 方向 2 条保持绿(它们钉的正是「门不得误伤单租户」)。
  • 本地全套重跑:plugin-sharing 13 files / 359 tests passedtypecheck 无输出、eslint 4 文件无输出、check-nul-bytes OK(5782)、check-org-identifier OK(1666);ADR-0057 D1 消费半径 e2e 3 files / 20 tests passed。后者同时是接线的端到端证据:那 20 条里调用方确实没有活动组织,只有 ctx.getService('tenancy') 真解析出 single 才可能绿 —— 探针没接上/抛错/解析失败都会按有墙拒绝并让它们变红。

上一轮那 4 条 Actions 基础设施红已自愈:新 head ad0e5d3filter / No other open PR may claim the same issue / Flag docs affected by code changes / Dogfood Verify CLI 全部 success,无需人工重跑。当前 head 已完成的 11 项全绿(含 Build Core / Test Core (1/3) / Dogfood Regression Gate (2/3) / Check Changeset / Check PR Size),其余 5 项(Test Core 2,3 / Dogfood Regression Gate 1,3 / Temporal Conformance)仍在队列,零红;若干 cancelled 是上一提交同名 workflow 被新提交取代的连带。


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

落地面处置审计(identity 车道 PM,session_01JwwiU9bjhwy2SWj13ho8uv,20:1xZ):本 PR 的 PR 级 CI 中了与 #6082 同族的「分片被平台 churn 取消 → 聚合门禁假红」——run 31122667436 内 Test Core (2/3)(18:20:39Z)与 Dogfood Regression Gate (1/3)(18:25:52Z)被 cancelled,其余分片全绿、零测试失败,两个聚合门禁却在 19:14:40Z / 19:35:16Z 各用 4–5 秒判红(另有 ESLint / TypeScript Type Check 等 17:4xZ 被平台事件 cancelled)。checks 已定格 failure 不会自愈,auto-merge 无从触发。

头 sha 未变(ad0e5d3)、base 即当前 main ⇒ update-branch 无法触发新 run。处置:close → reopen 重触发全量 PR CI + 重挂 auto-merge。内容侧零改动,ACCEPT 结论不变。


Generated by Claude Code

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

队列管家:新签名 ⇒ ⛔ 不重投,留完整签名与判读(四分支之「新签名」——台账无此条,且这是真实门禁失败,不是 flaky)

完整签名(取完整 job 归档,⛔ 未看 tail)

失败 job:ESLint(Lint & Type Check run 31136178849),致命 step 9 Slot-lookup ratchet(pnpm check:slot-lookupnode scripts/check-slot-lookup-ratchet.mjs):

✗ slot-lookup ratchet (1 problem(s)):

  • packages/plugins/plugin-sharing/src/sharing-plugin.ts: erasure count grew 10 → 11.
    The file is grandfathered for its EXISTING sites only; new ones must carry the slot's contract type.

Unswept: 144 site(s) in 32 file(s). Sweeping is #4251's batch work — see SLOT_LOOKUP_UNSWEPT in eslint.config.mjs.
ELIFECYCLE  Command failed with exit code 1.

初步判读:第 11 个 erasure 站点就是本 PR 新增的那一行

本 PR 在 sharing-plugin.ts 新增:

tenancy: () => {
  try { return ctx.getService<any>('tenancy'); }
  catch { return null; }
},

getService<**any**> 即 ratchet 所数的 erasure(类型擦除)站点。该文件被 grandfather 的是既有的 10 个站点,新增的必须带 slot 契约类型 —— 报错原文 "new ones must carry the slot's contract type" 正是此意。⇒ 10 → 11 由本 PR 造成,不是 main 上的既有红。

已排除共享损伤(notes 10):origin/mainbe596953Lint & Type Checkcompleted/success ⇒ main 未被污染,本条红只存在于含本 PR 的队列世代。

建议动作(⛔ 本座位不改代码)

<any> 换成 tenancy slot 的契约类型(与 SecurityPlugin 读取 Layer 0 wall 时所用的同一个类型,正是本 PR 注释里承诺的「两层永不分歧」),或按 SLOT_LOOKUP_UNSWEPT 的既有约定处理。⛔ 不要把本文件加进豁免名单来消红 —— 那与 #4251 的清扫方向相反。

连坐提示(本 PR 之外)

grouping_strategy: ALLGREEN 下,链上位于本 PR 之后的条目会继承这条红。本轮实测链序 #5999#6067#6086#6061#6068,同批红的 #6086 / #6061 自身无问题,已在各自 PR 留说明。本 PR 转绿后它们会自动恢复。

已核让行:本 PR 最近 30 分钟无车道 PM 动作。本 PR 当前仍在队列内(仅 added_to_merge_queue,无 removed),⛔ 本座位未撤队、未重投、未改代码。


Generated by Claude Code

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

⚠️ 更正上一条评论:「main 未被污染」已不再成立 —— 本 PR 于 01:13Z 带着这条红合并了。

上一条拦截评论里写的「已排除共享损伤:origin/mainbe596953Lint & Type Checkcompleted/success ⇒ main 未被污染」在写下的那一刻是准确的;但本 PR 随后合并,事实已变,故在同一处更正(SKILL notes 7:诊断结论被推翻,更正要发在同样公开的位置)。

现状(origin/main 实读):

⇒ 已按 note 10「立即止血 + 立单」开 #6100(止血单,含两条修法与⛔豁免名单的禁令)。本 PR 侧无需再动作 —— 后续在 #6100 收口。

⛔ 队列管家无改代码授权面,止血需要一次推送;已同步通知维护者。


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[#5852 producer 半边] resolveOwnerScopeIds 按权威字段填充 hierarchy resolver context + 补「resolver 收到的 org 非空」断言

2 participants