Skip to content

ADR-0057 hierarchy DEPTH: the resolver's tenant isolation never engages — plugin-sharing passes organizationId: null while the active org rides in tenantId #5852

Description

@hotlong

发现于 cloud#919 的 group-posture dogfood 探针(framework pin 06ba036270af1167345620954866aa0c8fbd1ecd,真实 HTTP boot,OS_TENANCY_POSTURE=group + @objectstack/organizations)。未提 PR——修复面在 spec 契约与 plugin-sharing 调用点,不在 cloud 的 showcase。

现象(实测,不是推断)

SharingService.resolveOwnerScopeIds 这样调用可插拔的 hierarchy resolver:

// packages/plugins/plugin-sharing/src/sharing-service.ts
const ids = await resolver.resolveOwnerIds(
  {
    userId: me,
    organizationId: (context as any).organizationId ?? null,
    tenantId: (context as any).tenantId ?? null,
  },
  scope,
);

在 resolver 位置挂一个 spy 转发到真实实现,抓到真实请求传进来的 context 是:

{ "userId": "LpwtDbO4c91DvsUl5xy5Yei7a50Ly92B",
  "organizationId": null,
  "tenantId": "97579948-c31a-4b55-a817-b2cfb4a86508" }

即:活动组织始终落在 tenantIdorganizationId 恒为 null——框架的 ExecutionContext 本来就以 tenantId 承载活动组织(ExecutionContextLike { userId, tenantId, timezone };plugin-security 的 RLS 也是从 ExecutionContext.tenantIdcurrent_user.organization_id)。

而企业版 resolver(cloud packages/security-enterprise/src/hierarchy/resolver.ts只读 context.organizationId

const org = context.organizationId != null ? String(context.organizationId) : null;
const ids = await this.computeOwnerIds(me, scope, org);

org == null 时它文档里写得很清楚的"belt-and-braces 租户隔离"整条链路被跳过:withOrg() 不加谓词、unitsInOrg() 直接透传、subtree BFS 不再限定 org。结果是 owner set 跨组织构建,与该 resolver 自己声明的语义("In multi-tenant it is the caller's active org, so the owner set is built ONLY from this tenant's units/positions/members")相反。

造成的越权(实测)

group 姿态、两个组织 A/B、对象 sharingModel: 'public_read'

主体 配置 POST /data/:obj/:idB/shares(B 组织的记录)
普通成员 + writeScope: 'unit_and_below',BU 锚在 A,BU 树有跨 org 的 parent 链 联合可见 201(授予成功)
同一主体,去掉 BU 锚(其余不变) 联合可见 403 PERMISSION_DENIED … requires record ownership or Modify All Data (ADR-0111 D1)

对照行是关键:翻转结果的正是 DEPTH 分支,不是别的旁路。也就是说 ADR-0111 D1 DEPTH 在 group 姿态下把「跨组织墙的共享管理权」发了出去——而这恰是 ADR-0111 D1/D2 与 ADR-0105 D2 组合起来要挡住的东西(union 可见 ≠ 可管理,见 cloud#921 已落地的断言)。

另一组实测(组织图完全良构、没有跨 org parent 链,只是同一个人在两个厂各有一个 placement):

owner set, org A scoped  -> [lead]
owner set, org B scoped  -> [lead, plantB]
owner set, UNSCOPED      -> [lead, plantB]   <- 运行时实际走的这条

即使组织图良构,运行时的 owner set 也不随活动组织变化,恒为所有 placement 的并集。

爆炸半径不止共享管理:resolveOwnerScopeIds 同样喂给 matchesOwnerScopecanEdit / canDelete / buildWriteFilter。在探针那个 app 里数据面写入另被 member_defaultowner_only_writes(keyed on created_by)挡下,所以只观测到共享管理这一路;但凡权限集不带这条 owner-only RLS 的部署,跨组织的 edit/delete 同样会被放行。

为什么它没被现有测试抓到

resolver 自己的单测(cloud packages/security-enterprise/src/hierarchy/resolver-org.test.ts 等)手工构造 { userId, organizationId },喂进去的是运行时从不产生的形状,所以隔离逻辑绿着;framework 侧 canManageShares 的 DEPTH 单测用 stub resolver,也看不到这一层。跟 framework#3695(authority-less gate context)同一族:单测喂的 context 与真实 seam 产生的 context 不是一个东西。

根因判断与修复方向(建议,未实施)

契约本身是含糊的——HierarchyScopeContext 同时声明 organizationId?tenantId?没有说哪一个是权威,于是 producer 只填了 tenantId、consumer 只读了 organizationId,两边各自"符合契约"。

建议按 contract-first 修在 spec + producer,而不是在 consumer 加 ?? tenantId 兜底(那正是宽容消费者模式:契约仍然含糊,下一个 resolver 实现还会踩):

  • A(推荐)HierarchyScopeContext 明确单一权威字段并写进 doc(保留另一个作为 deprecated 别名或直接删掉),resolveOwnerScopeIds 按该字段填充(context.tenantId 是框架里真实承载活动组织的那个)。同时给 plugin-sharing 补一条断言:真实 exec context 进来时 resolver 收到的 org 非空。
  • B:cloud HierarchyScopeResolverorganizationId ?? tenantId。一行修好当下的洞,但契约仍然含糊——不推荐单独做。

无论走哪条,cloud 侧 @objectstack/security-enterprise 需要同步;建议把「resolver 实际收到的 org 非空」做成门,否则这条隔离再次静默失效时没人会知道。

Refs: framework ADR-0057、ADR-0111 D1 DEPTH、ADR-0105 D2/D6;cloud#919、cloud#921。

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions