fix(plugin-sharing): hierarchy resolver 按权威字段拿到调用方活动组织 (#5859) - #6067
Conversation
`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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
CI 首轮 4 条红 = GitHub Actions 基础设施故障,与本 PR 无关(实证)
四条分属三个不同 workflow run,均未跑到本仓任何代码; 顺带独立核了重复认领门想查的那件事: 故障平息后重跑即可。本地实测(worktree 内)见 PR 正文「验证」一节: 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
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
|
裁决 C 已追加落地(commit
上一轮那 4 条 Actions 基础设施红已自愈:新 head Generated by Claude Code |
Pull request was closed
|
落地面处置审计(identity 车道 PM, 头 sha 未变( Generated by Claude Code |
|
队列管家:新签名 ⇒ ⛔ 不重投,留完整签名与判读(四分支之「新签名」——台账无此条,且这是真实门禁失败,不是 flaky) 完整签名(取完整 job 归档,⛔ 未看 tail)失败 job:ESLint( 初步判读:第 11 个 erasure 站点就是本 PR 新增的那一行本 PR 在 tenancy: () => {
try { return ctx.getService<any>('tenancy'); }
catch { return null; }
},
已排除共享损伤(notes 10): 建议动作(⛔ 本座位不改代码)把 连坐提示(本 PR 之外)
已核让行:本 PR 最近 30 分钟无车道 PM 动作。本 PR 当前仍在队列内(仅 Generated by Claude Code |
|
上一条拦截评论里写的「已排除共享损伤: 现状(
⇒ 已按 note 10「立即止血 + 立单」开 #6100(止血单,含两条修法与⛔豁免名单的禁令)。本 PR 侧无需再动作 —— 后续在 #6100 收口。 ⛔ 队列管家无改代码授权面,止血需要一次推送;已同步通知维护者。 Generated by Claude Code |
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 ?? nullorganizationId到执行上下文rest-server.ts与runtime/src/security/resolve-execution-context.ts都只tenantId: authz.tenantIdresolveAuthzContext(@objectstack/core)tenantId = session.activeOrganizationId;API-key 路径tenantId = sys_api_key.organization_id;ExecutionContext.tenantId注释即「Current organization/tenant ID (resolved from session.activeOrganizationId)」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 按权威字段填充)
organizationId← 执行上下文的活动组织(新增 module-privateactiveOrganizationId(context),doc 写清 provenance 与「这不是消费端?? tenantId兜底」的区别);tenantId作为@deprecated兼容别名原样继续携带([#5852 契约半边]HierarchyScopeContext未声明organizationId/tenantId哪个权威 —— producer 只填一个、consumer 只读另一个,两边都「符合契约」 #5858 明确不删,仓外 resolver 可能仍读它);null(契约类型即string | null);空白字符串归一为null,不让假 id 混进 resolver 查询与日志;logger.warn)。判定不变(owner-only),但「resolver 炸了」与「层级里确实没别人」此前完全同形。同一映射在
plugin-security的 Layer-0 租户墙里早已在用(
computeTenantLayer0Filter({ organizationId: context?.tenantId })),两层 enforcement现在按同一字段的同一值收窄。
② 姿态感知的组织门(裁决 C)
SharingService新增 late-boundtenancy姿态探针 —— 读法照抄SecurityPlugin为Layer-0 墙读
tenancy服务的那段(try { ctx.getService('tenancy') } catch { null },与既有
securityService/hierarchyResolver同形),由SharingServicePlugin接线。按 ADR-0105 D1 的既有分叉决定「没有活动组织」意味着什么,与
computeTenantLayer0Filter对同一问题的答案逐条同形:singlegroup/isolatedwarn点名 ADR-0095 D1 / ADR-0105 D1 与 #5973 的 fail-closed 义务原文(「no org」不是「every org」)RLS_DENY/ 空访问集)single的证据;把它读成 single 恰恰会在配置已可疑的部署上恢复展开。周围守卫同惯例:securityService缺失 → owner-only,hierarchyResolver缺失 → owner-onlylegacy 形状
isolationActive: false是一句肯定的「无墙」声明,按single处理;isolationActive: undefined仍算解析不出。为什么不是无条件拒绝:实测证明「无活动组织」是本仓受支持的部署形态 ——
@objectstack/verifyharness 故意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 条):tenantId、不带organizationId(所以 producer 必须映射);organizationId非空且等于活动组织(并检别名tenantId原样);organizationId: nullwhile the active org rides intenantId#5852 对照:group姿态成员(同时属 org_a / org_b、活动 org_a)对兄弟组织记录canManageShares= false,同组织记录仍 = true(不过度收紧);canEdit/canDelete跨组织均 false,buildWriteFilter的 owner 集合不含兄弟组织的 owner —— fixture 里没有任何 owner-only RLS 兜底,共享服务是唯一的门
(即 ADR-0057 hierarchy DEPTH: the resolver's tenant isolation never engages — plugin-sharing passes
organizationId: nullwhile the active org rides intenantId#5852 点名的无兜底部署形态);single+ 无组织 ⇒ resolver 照常被咨询、DEPTH 工作、且拿到的是显式
null(键在、值为 null);group/isolated+ 无组织 ⇒ resolver 一次都不被咨询、判定 false、warn 文案含 ADR 依据(
it.each两个姿态各一条);isolationActive: false视为无墙;single下归一为null传下去,有墙下与缺席同样拒绝;既有 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方向必须保持绿」,实测:single方向的两条钉子按预判保持绿 —— 它们钉的是「门必须不误伤单租户」,与门的存在无关,这正是本次裁决要买的东西。
验证(全部前台阻塞执行)
最后这条同时是接线的端到端证据:那 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 一侧带上。