Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/sharing-hierarchy-org-authority.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
"@objectstack/plugin-sharing": patch
---

fix(plugin-sharing): hierarchy resolver 拿到调用方真实的活动组织(权威 `organizationId`)(#5859)

`resolveOwnerScopeIds` 构造 `HierarchyScopeContext` 时读的是
`(context as any).organizationId` —— **仓内没有任何传输层写过这个键**。REST
(`rest-server.ts`)和 runtime dispatcher(`resolve-execution-context.ts`)都从同一个
授权解析器 `resolveAuthzContext` 组装执行上下文,活动组织落在 `tenantId`
(session 路径 = `session.activeOrganizationId`,API-key 路径 = `sys_api_key.organization_id`),
`ExecutionContext` 的字段注释写的也正是这句。所以这个读取**结构性恒为 `null`**:
自 ADR-0057 以来,每一次 DEPTH(`unit` / `unit_and_below` / `own_and_reports`)解析
都是在**没有组织约束**的前提下跑的,而企业版 resolver 只按 `organizationId` 收窄
自己的 owner 集合 —— 于是整条 DEPTH 租户隔离从未生效。

爆炸半径不止「共享管理」一路:同一个 owner 集合喂给 `matchesOwnerScope` →
`canEdit` / `canDelete`,以及批量写路径 `buildWriteFilter`。#5852 的实测里,
`group` 姿态下的普通成员对**兄弟组织**记录 `POST /data/:obj/:idB/shares` 得到
**201**;探针那个 app 的写路径另被 `member_default` 的 `owner_only_writes`
(keyed on `created_by`)挡下,所以只观测到共享管理一路 —— **不带这条 owner-only
RLS 的部署,跨组织 edit/delete 同样放行**。

本次修复(producer 半边,契约半边见 #5858 / PR #5973):

- 权威字段 `organizationId` 由执行上下文的活动组织填充;`tenantId` 作为
`@deprecated` 兼容别名原样继续携带(不是消费端 `?? tenantId` 兜底 —— 那正是
#5858 为 resolver 明令排除的宽容消费者形状)。同样的映射在
`@objectstack/plugin-security` 的 Layer-0 租户墙里早已在用
(`computeTenantLayer0Filter({ organizationId: context?.tenantId })`),两层
enforcement 现在按同一个字段的同一个值收窄。
- 无活动组织时如实传 `null`(契约类型即 `string | null`),空白字符串归一为
`null`,绝不让一个假的组织 id 混进 resolver 的查询与日志。
- resolver 抛错的静默回退改为**留声**(`logger.warn`):此前「resolver 炸了」和
「层级里确实没有别人」在外部完全同形,这也是本缺陷长期不可见的原因之一。

## 姿态感知的组织门(user-visible 行为变化)

`SharingService` 新增一个 late-bound 的 `tenancy` 姿态探针(读法与 `SecurityPlugin`
为 Layer-0 墙读 `tenancy` 服务的完全一致,由 `SharingServicePlugin` 自动接线),
按 **ADR-0105 D1** 的既有分叉决定「没有活动组织」意味着什么 —— 与
`computeTenantLayer0Filter` 对同一问题给出的答案逐条同形:

- **`single`**(纯单租户,无组织):**行为不变**,DEPTH 照常widened。此处「没有组织」
是那一个隐含租户,不是「所有组织」。
- **`group` / `isolated`**(有墙):权威组织缺失/空白 → **拒绝**,根本不咨询 resolver,
回落 owner-only 并打一条点名 ADR-0095 D1 / ADR-0105 D1 与 #5973 契约义务的 `warn`。
即:有墙部署里,缺组织的 owner-scope 解析从「按无租户约束展开」变为「拒绝展开」。
- **姿态解析不出**(未接线 / 探针抛错 / 词表外的值)→ 按**有墙**处理。未知姿态不是
`single` 的证据,否则恰恰在配置已经可疑的部署上恢复了展开。

对已有部署的影响:`single` 部署零变化;`group` / `isolated` 部署中,一个**没有活动
组织**的调用方将不再通过 DEPTH 拿到跨组织的 owner 集合(共享管理 / edit / delete /
批量写四条路径同时闭合)。
107 changes: 107 additions & 0 deletions packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#5859] A REAL execution context for sharing tests — produced by the seam,
* never hand-written.
*
* Why this exists: #5852's cross-organization escalation survived every unit
* test in this package because those tests hand-built the context they fed in
* (`{ userId, organizationId }`) — a shape the runtime NEVER produces. The
* sharing service read `organizationId`, the transports write the caller's
* active org onto `tenantId`, and no test could see the gap because each test
* wrote both sides itself.
*
* So a test that wants to say something about tenancy must not name the
* context's tenancy fields at all. This helper takes what a real deployment
* actually holds — a better-auth session (`activeOrganizationId`) and
* `sys_member` rows — and runs it through `resolveAuthzContext`, the SINGLE
* shared authorization resolver (`@objectstack/core/security`) that BOTH HTTP
* entry points delegate to (`packages/rest/src/rest-server.ts` and
* `packages/runtime/src/security/resolve-execution-context.ts`). Whichever
* field that resolver decides carries the active organization is the field the
* test hands to the sharing service — so a rename, a drop, or a re-spelling of
* the tenancy authority breaks these tests instead of silently disabling them.
*
* `.testkit.ts`, not `.test.ts`: it holds no assertions and must not be
* collected as a suite. It is imported only by tests, so tsup (entry
* `src/index.ts`) never bundles it into `dist`.
*/

import { resolveAuthzContext } from '@objectstack/core';
import type { SharingExecutionContext } from '@objectstack/spec/contracts';

/** A `sys_member` row as the identity tables really store it. */
export interface SeamMembership {
organization_id: string;
role?: string;
}

export interface SeamPrincipal {
/** `sys_user.id` of the signed-in caller. */
userId: string;
email?: string;
/**
* better-auth `session.activeOrganizationId` — the ONE wire field a real
* login carries the caller's active organization on (ADR-0081 D1 stamps it
* from the user's `sys_member` row on session create). `null` reproduces a
* membership-less / platform-scoped session.
*/
activeOrganizationId?: string | null;
/** `sys_member` rows for this user (defaults to one row per active org). */
memberships?: SeamMembership[];
}

/** Minimal in-memory ObjectQL: `find(object, { where })` with `===` + `$in`. */
function makeSeamQl(tables: Record<string, any[]>) {
return {
async find(object: string, opts: any) {
const rows = tables[object] ?? [];
const where = opts?.where ?? {};
return rows.filter((r) =>
Object.entries(where).every(([k, v]) => {
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]);
return r[k] === v;
}),
);
},
};
}

/**
* Resolve an execution context the way an inbound HTTP request does.
*
* The returned object is the authorization envelope `resolveAuthzContext`
* produced, spread exactly as both transports spread it (plus
* `isSystem: false`) — this helper never names a tenancy field, so neither does
* the test that calls it.
*/
export async function bootRequestContext(principal: SeamPrincipal): Promise<SharingExecutionContext> {
const activeOrg = principal.activeOrganizationId ?? null;
const memberships: SeamMembership[] =
principal.memberships ?? (activeOrg ? [{ organization_id: activeOrg, role: 'member' }] : []);

const ql = makeSeamQl({
sys_user: [{ id: principal.userId, email: principal.email }],
sys_member: memberships.map((m, i) => ({
id: `mem_${principal.userId}_${i}`,
user_id: principal.userId,
organization_id: m.organization_id,
role: m.role ?? 'member',
})),
sys_user_position: [],
sys_user_permission_set: [],
sys_permission_set: [],
});

const authz = await resolveAuthzContext({
ql,
headers: new Headers(),
// The better-auth session shape, as `AuthManager` hands it to the resolver.
getSession: async () => ({
user: { id: principal.userId, email: principal.email },
session: { userId: principal.userId, activeOrganizationId: activeOrg },
}),
});

return { ...authz, isSystem: false } as unknown as SharingExecutionContext;
}
10 changes: 10 additions & 0 deletions packages/plugins/plugin-sharing/src/sharing-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,16 @@ export class SharingServicePlugin implements Plugin {
try { return ctx.getService<any>('security'); }
catch { return null; }
},
// [ADR-0105 D1 / #5859] Late-bound tenancy posture — read exactly the
// way SecurityPlugin reads it for the Layer 0 wall, so the two layers
// can never disagree about whether an organization wall is in force.
// Absent (no plugin-auth) → the org gate assumes WALLED and refuses to
// widen a hierarchy scope that carries no organization; an unresolvable
// posture is not evidence of `single`.
tenancy: () => {
try { return ctx.getService<any>('tenancy'); }
catch { return null; }
},
});
ctx.registerService('sharing', this.service);

Expand Down
Loading
Loading