Sharing rules with a business-unit recipient silently grant nothing when the unit row has organization_id = NULL #14547

Description

@baozhoutao

Summary

BusinessUnitGraphService.orgScope screens sys_business_unit with a strictorganization_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:

privateorgScope(filter: Record): Record{if(this.organizationId)return{ ...filter,organization_id: this.organizationId};returnfilter;}

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.

  1. 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.
  2. 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_...).
  3. 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/main4a37870:

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=awaitthis.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-98does 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

Activity

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
     blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
    }
    } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
    })();
    (function(){
    try {
    var __m = "github.com";
    var __re = new RegExp('^' + "github\\.com" + '
    
    Skip to content

    Sharing rules with a business-unit recipient silently grant nothing when the unit row has organization_id = NULL #14547

    Description

    @baozhoutao

    Summary

    BusinessUnitGraphService.orgScope screens sys_business_unit with a strictorganization_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:

    privateorgScope(filter: Record): Record{if(this.organizationId)return{ ...filter,organization_id: this.organizationId};returnfilter;}

    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.

    1. 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.
    2. 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_...).
    3. 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/main4a37870:

    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=awaitthis.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-98does 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

    Activity

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

    Metadata

    Metadata

    Assignees

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
      Skip to content

      Sharing rules with a business-unit recipient silently grant nothing when the unit row has organization_id = NULL #14547

      Description

      @baozhoutao

      Summary

      BusinessUnitGraphService.orgScope screens sys_business_unit with a strictorganization_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:

      privateorgScope(filter: Record): Record{if(this.organizationId)return{ ...filter,organization_id: this.organizationId};returnfilter;}

      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.

      1. 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.
      2. 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_...).
      3. 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/main4a37870:

      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=awaitthis.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-98does 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

      Activity

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

      Metadata

      Metadata

      Assignees

      Labels

      Type

      No type

      Projects

      No projects

        Milestone

        No milestone

        Relationships

        None yet

        Development

        No branches or pull requests

        Issue actions

        , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
        Skip to content

        Sharing rules with a business-unit recipient silently grant nothing when the unit row has organization_id = NULL #14547

        Description

        @baozhoutao

        Summary

        BusinessUnitGraphService.orgScope screens sys_business_unit with a strictorganization_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:

        privateorgScope(filter: Record): Record{if(this.organizationId)return{ ...filter,organization_id: this.organizationId};returnfilter;}

        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.

        1. 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.
        2. 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_...).
        3. 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/main4a37870:

        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=awaitthis.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-98does 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

        Activity

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

        Metadata

        Metadata

        Assignees

        Labels

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
          Skip to content

          Sharing rules with a business-unit recipient silently grant nothing when the unit row has organization_id = NULL #14547

          Description

          @baozhoutao

          Summary

          BusinessUnitGraphService.orgScope screens sys_business_unit with a strictorganization_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:

          privateorgScope(filter: Record): Record{if(this.organizationId)return{ ...filter,organization_id: this.organizationId};returnfilter;}

          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.

          1. 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.
          2. 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_...).
          3. 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/main4a37870:

          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=awaitthis.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-98does 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

          Activity

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

          Metadata

          Metadata

          Assignees

          Labels

          Type

          No type

          Projects

          No projects

            Milestone

            No milestone

            Relationships

            None yet

            Development

            No branches or pull requests

            Issue actions

            , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
            Skip to content

            Sharing rules with a business-unit recipient silently grant nothing when the unit row has organization_id = NULL #14547

            Description

            @baozhoutao

            Summary

            BusinessUnitGraphService.orgScope screens sys_business_unit with a strictorganization_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:

            privateorgScope(filter: Record): Record{if(this.organizationId)return{ ...filter,organization_id: this.organizationId};returnfilter;}

            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.

            1. 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.
            2. 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_...).
            3. 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/main4a37870:

            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=awaitthis.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-98does 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

            Activity

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

            Metadata

            Metadata

            Assignees

            Labels

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              Sharing rules with a business-unit recipient silently grant nothing when the unit row has organization_id = NULL #14547

              Description

              @baozhoutao

              Summary

              BusinessUnitGraphService.orgScope screens sys_business_unit with a strictorganization_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:

              privateorgScope(filter: Record): Record{if(this.organizationId)return{ ...filter,organization_id: this.organizationId};returnfilter;}

              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.

              1. 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.
              2. 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_...).
              3. 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/main4a37870:

              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=awaitthis.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-98does 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

              Activity

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

              Metadata

              Metadata

              Assignees

              Labels

              Type

              No type

              Projects

              No projects

                Milestone

                No milestone

                Relationships

                None yet

                Development

                No branches or pull requests

                Issue actions

                , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                Skip to content

                Sharing rules with a business-unit recipient silently grant nothing when the unit row has organization_id = NULL #14547

                Description

                @baozhoutao

                Summary

                BusinessUnitGraphService.orgScope screens sys_business_unit with a strictorganization_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:

                privateorgScope(filter: Record): Record{if(this.organizationId)return{ ...filter,organization_id: this.organizationId};returnfilter;}

                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.

                1. 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.
                2. 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_...).
                3. 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/main4a37870:

                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=awaitthis.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-98does 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

                Activity

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

                Metadata

                Metadata

                Assignees

                Labels

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions