Summary
BusinessUnitGraphService.orgScope screens sys_business_unit with a strict organization_id equality. A sharing rule always carries the caller's organization (the engine stamps it, and an explicit organization_id: null in the payload is overridden), while a business unit created by seed data carries organization_id = NULL. The two never match, so expandUsers / expandUnitMembers return zero users — the rule is accepted, stays active: true, materialises no sys_record_share rows, and logs nothing.
This is the "second, worse copy" that sharing-rule-service.ts already warns about in criteriaContext:
Open-coding an organization_id equality into filter would be a second, worse copy — ... and it would drop the NULL arm that keeps platform-seeded rows visible to every tenant (#2734).
SqlDriver.applyTenantScope emits (organization_id = ? OR organization_id IS NULL). business-unit-graph.ts does not:
private orgScope(filter: Record): Record {
if (this.organizationId) return { ...filter, organization_id: this.organizationId };
return filter;
}
seedIsUsable() runs that screen first, so an org-NULL unit reads as "does not exist" and contributes nobody — for both recipient widths (business_unit and unit_and_subordinates).
Minimal reproduction
Platform 17.2.0, objectstack dev, SQLite, single tenancy, fresh DB.
- App seed data creates
sys_business_unit rows (SeedSchema, mode: 'upsert'). They land with organization_id: null — there is no way for authored seed data to name a runtime organization id.
- Add a member:
POST /api/v1/data/sys_business_unit_member {user_id, business_unit_id: 'bu_market', is_primary: true} (this row does get organization_id: org_...).
- Create a rule as the org admin:
POST /api/v1/data/sys_sharing_rule
{ "name":"probe", "label":"probe", "object_name":"kpi_entry_sheet",
"criteria_json":"{\"subject\":\"bu_market\"}",
"recipient_type":"unit_and_subordinates", "recipient_id":"bu_market",
"access_level":"edit", "active":true }
Observed: 201, rule active, sys_record_share gains 0 rows; the member sees 0 records. No error, no warning.
Control, one variable changed — give the seeded unit the organization the rule already carries, then re-touch the same rule:
BEFORE — rule org = org_mtjzvlexj91joh48 | bu_market org = null | shares from this rule = 0
patch unit org -> 200 | bu_market org now = org_mtjzvlexj91joh48
AFTER — shares from this rule = 3
Nothing else changed. Two further controls in the same session, same DB:
- a rule with
recipient_type: 'user' grants correctly (the BU graph is not consulted);
- the same unit recipient against a unit created through the REST API (org-stamped) grants correctly — this is why the defect is invisible in tests that build their fixtures through the API.
Passing organization_id: null explicitly on the rule does not work around it: the response comes back stamped with the caller's organization.
Expected
BusinessUnitGraphService.orgScope should apply the platform's own null-inclusive tenant screen — (organization_id = ? OR organization_id IS NULL) — so that org-NULL platform/app-seeded units keep participating in recipient expansion, matching SqlDriver.applyTenantScope and the #2734 rationale. Failing that, the mismatch should be loud (a warning naming the rule and the unit) rather than an active rule that grants nothing.
Impact
Any app that seeds its organization tree and then provisions sharing rules at runtime loses its entire unit-scoped data range, silently. Declared (metadata) sharing rules are unaffected because they are bootstrapped without an organization context and end up organization_id: null, so orgScope is a no-op for them — which makes the runtime-provisioned path the only one that breaks, and makes the breakage look like an application bug.
Environment
@objectstack/* 17.2.0 · Node 22 · better-sqlite3 · single tenancy · plugins include SharingServicePlugin, Security, PlatformObjects.
Triage — confirmed, and ⛔ do NOT dispatch the Expected fix as written
Every claim reproduces at origin/main 4a37870:
The card is right about the defect. But its recommended fix cannot be dispatched, and this is the finding that changes the grade:
⚠️ Making orgScope null-inclusive, on its own, converts a silent under-grant into a cross-tenant over-grant
business-unit-graph.ts:169-176 — the member lookup inside expandUnitMembers:
rows = await this.engine.find('sys_business_unit_member', {
where: { business_unit_id: businessUnitId },
fields: ['user_id'],
limit: 10000,
context: SYSTEM_CTX,
});
No orgScope. And SYSTEM_CTX is { isSystem: true, positions: [], permissions: [] } (:7) — it carries no tenant field, so the engine applies no scope of its own either. The member query is completely unscoped by organization. (The descendants walk at :93-98 does use orgScope; only the member step does not.)
So today the strict equality in orgScope is the only thing keeping an org-NULL unit from reaching that unscoped query. The bug is moonlighting as the tenant guard. Flip orgScope to the null-inclusive form alone, and a seeded unit id shared across tenants — exactly the bu_market shape in the reproduction — lets tenant A's sharing rule expand to tenant B's members and materialise real sys_record_share rows for them.
Silent under-grant would become silent cross-tenant over-grant. That is strictly the worse failure, and it is why this is security + needs-user-decision rather than a queued bug fix.
<!-- os-decision-facets -->
- ① 项目长远合理性(权重 ≥50%,领起推荐) —— 平台已经有一处统一决定「这一行本租户看不看得见」的口子,它发的是「本租户的 或 无归属的」。而共享插件自己在
sharing-rule-service.ts:994-999 白纸黑字写着:自己手写一遍等值判断是「第二份更差的拷贝」,会丢掉那条让平台种子行对每个租户可见的分支 —— 同一个插件里的另一个文件正好就这么写了。长远终态只有一种:租户可见性只在一处决定,别处一律复用。①指向「让部门图走平台的口子」,而不是各写各的。
- ② 实际业务拉动 —— 有,而且是外部使用者报的,带完整复现和单变量对照:只改部门行的归属字段,共享行从 0 变 3,别的什么都没动。任何「种子里建部门树 + 运行时建共享规则」的应用,整条数据范围静默丢失,而且看起来像应用自己的 bug。真实、已发生、非零。
- ③ 防 AI 犯错 —— 出错时谁看到什么:规则建成功返回 201、状态显示启用、共享表零行、日志一个字都没有。作者只能反复怀疑自己的应用写错了。静默容忍的教科书形态。 而且无论方向怎么裁,「一条启用的规则展开出零个收件人」这件事本身就该响亮说出来 —— 这是两条路都拦不住的那句告警。
- ④ 创业阶段不扩散 —— 不引入新概念,复用既有口子,不新增声明。⛔ 但这一棱在这里有个例外要写明:「少写代码」的那条路恰恰是危险的那条 —— 只改
orgScope 一行是最小 diff,也正是会开出跨租户泄露的那个改法。
推荐:A —— 两处必须同批改。 ① orgScope 换成平台的 null-inclusive 形;② expandUnitMembers 的成员查询补上租户筛选(成员行本来就带归属 —— 卡面第 2 步实测 sys_business_unit_member 会被 org 戳上)。⛔ 只改 ① 就是引入泄露;⛔ 不接受拆成两个 PR 前后脚落地 —— 中间那一刻就是敞口。
回退:B —— 不动可见性语义,只把静默变响亮。 规则展开出零收件人时告警并指名规则与部门。零泄露风险、今天就能做,代价是应用仍然拿不到它要的功能,只是不再需要靠猜。
置信缺口(本分析看不见什么): 我读的是代码,没有跑多租户实例。sys_business_unit_member 是否在所有创建路径上都带归属,只有 REST 一条路被实测过(卡面第 2 步)—— 若存在不戳 org 的写入路径(种子、导入、迁移),那么 A 的 ② 也会漏,泄露照旧。这是执行 A 之前必须先量的第一件事,不是执行中顺手确认的事。
Generated by Claude Code
Summary
BusinessUnitGraphService.orgScopescreenssys_business_unitwith a strictorganization_idequality. A sharing rule always carries the caller's organization (the engine stamps it, and an explicitorganization_id: nullin the payload is overridden), while a business unit created by seed data carriesorganization_id = NULL. The two never match, soexpandUsers/expandUnitMembersreturn zero users — the rule is accepted, staysactive: true, materialises nosys_record_sharerows, and logs nothing.This is the "second, worse copy" that
sharing-rule-service.tsalready warns about incriteriaContext:SqlDriver.applyTenantScopeemits(organization_id = ? OR organization_id IS NULL).business-unit-graph.tsdoes not:seedIsUsable()runs that screen first, so an org-NULL unit reads as "does not exist" and contributes nobody — for both recipient widths (business_unitandunit_and_subordinates).Minimal reproduction
Platform 17.2.0,
objectstack dev, SQLite, single tenancy, fresh DB.sys_business_unitrows (SeedSchema,mode: 'upsert'). They land withorganization_id: null— there is no way for authored seed data to name a runtime organization id.POST /api/v1/data/sys_business_unit_member {user_id, business_unit_id: 'bu_market', is_primary: true}(this row does getorganization_id: org_...).Observed:
201, rule active,sys_record_sharegains 0 rows; the member sees 0 records. No error, no warning.Control, one variable changed — give the seeded unit the organization the rule already carries, then re-touch the same rule:
Nothing else changed. Two further controls in the same session, same DB:
recipient_type: 'user'grants correctly (the BU graph is not consulted);Passing
organization_id: nullexplicitly on the rule does not work around it: the response comes back stamped with the caller's organization.Expected
BusinessUnitGraphService.orgScopeshould apply the platform's own null-inclusive tenant screen —(organization_id = ? OR organization_id IS NULL)— so that org-NULL platform/app-seeded units keep participating in recipient expansion, matchingSqlDriver.applyTenantScopeand the #2734 rationale. Failing that, the mismatch should be loud (a warning naming the rule and the unit) rather than an active rule that grants nothing.Impact
Any app that seeds its organization tree and then provisions sharing rules at runtime loses its entire unit-scoped data range, silently. Declared (metadata) sharing rules are unaffected because they are bootstrapped without an organization context and end up
organization_id: null, soorgScopeis a no-op for them — which makes the runtime-provisioned path the only one that breaks, and makes the breakage look like an application bug.Environment
@objectstack/*17.2.0 · Node 22 · better-sqlite3 · single tenancy · plugins include SharingServicePlugin, Security, PlatformObjects.Triage — confirmed, and ⛔ do NOT dispatch the Expected fix as written
Every claim reproduces at
origin/main4a37870:packages/plugins/plugin-sharing/src/business-unit-graph.ts:269-272—orgScopeis the strict equality, verbatim as quoted.:127—seedIsUsablerunswhere: this.orgScope({ id: businessUnitId })as the first screen, and:82-85/:165-168return[]when it finds nothing. Both recipient widths, as the card says.sharing-rule-service.ts:994-999— the warning is real and it is in the same plugin: open-coding anorganization_idequality is "a second, worse copy" that "would drop the NULL arm that keeps platform-seeded rows visible to every tenant (Freshobjectstack devboot: tenant admin sees ZERO rows in sys_position / sys_permission_set / sys_business_unit over REST (Setup Access Control renders empty) #2734)". The file next door names the mistake; this file makes it.The card is right about the defect. But its recommended fix cannot be dispatched, and this is the finding that changes the grade:
orgScopenull-inclusive, on its own, converts a silent under-grant into a cross-tenant over-grantbusiness-unit-graph.ts:169-176— the member lookup insideexpandUnitMembers:No
orgScope. AndSYSTEM_CTXis{ isSystem: true, positions: [], permissions: [] }(:7) — it carries no tenant field, so the engine applies no scope of its own either. The member query is completely unscoped by organization. (The descendants walk at:93-98does useorgScope; only the member step does not.)So today the strict equality in
orgScopeis the only thing keeping an org-NULL unit from reaching that unscoped query. The bug is moonlighting as the tenant guard. FliporgScopeto the null-inclusive form alone, and a seeded unit id shared across tenants — exactly thebu_marketshape in the reproduction — lets tenant A's sharing rule expand to tenant B's members and materialise realsys_record_sharerows for them.Silent under-grant would become silent cross-tenant over-grant. That is strictly the worse failure, and it is why this is
security+needs-user-decisionrather than a queued bug fix.<!-- os-decision-facets -->
sharing-rule-service.ts:994-999白纸黑字写着:自己手写一遍等值判断是「第二份更差的拷贝」,会丢掉那条让平台种子行对每个租户可见的分支 —— 同一个插件里的另一个文件正好就这么写了。长远终态只有一种:租户可见性只在一处决定,别处一律复用。①指向「让部门图走平台的口子」,而不是各写各的。orgScope一行是最小 diff,也正是会开出跨租户泄露的那个改法。推荐:A —— 两处必须同批改。 ①
orgScope换成平台的 null-inclusive 形;②expandUnitMembers的成员查询补上租户筛选(成员行本来就带归属 —— 卡面第 2 步实测sys_business_unit_member会被 org 戳上)。⛔ 只改 ① 就是引入泄露;⛔ 不接受拆成两个 PR 前后脚落地 —— 中间那一刻就是敞口。回退:B —— 不动可见性语义,只把静默变响亮。 规则展开出零收件人时告警并指名规则与部门。零泄露风险、今天就能做,代价是应用仍然拿不到它要的功能,只是不再需要靠猜。
置信缺口(本分析看不见什么): 我读的是代码,没有跑多租户实例。
sys_business_unit_member是否在所有创建路径上都带归属,只有 REST 一条路被实测过(卡面第 2 步)—— 若存在不戳 org 的写入路径(种子、导入、迁移),那么 A 的 ② 也会漏,泄露照旧。这是执行 A 之前必须先量的第一件事,不是执行中顺手确认的事。Generated by Claude Code