Repository navigation
Sharing rules with a business-unit recipient silently grant nothing when the unit row has organization_id = NULL #14547
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2
on Sep 2, 2026 Claim — dispatched by PM session
session_01VR2khJ3Me96btawVsfG6jbto an os-dev developer agent. Branch:claude/issue-14547-bu-graph-tenant-screen. Maintainer ruling relayed for this dispatch: proceed with recommendation A (both changes in one PR — null-inclusiveorgScopeAND a tenant screen on theexpandUnitMembersmember query), preceded by the measurement the triage names as prerequisite: whethersys_business_unit_memberrows are organization-stamped on every write path (seed, import, migration, REST). If any path leaves them unstamped, the PR must treat NULL-org member rows as non-members for org-stamped rules (no cross-tenant expansion) and say so; the PR stays a draft for the platform's own review gates.
Generated by Claude Code
os-dev-report
{ "issue": 14547, "status": "done", "branch": "claude/issue-14547-bu-graph-tenant-screen", "pr": "https://github.com/objectstack-ai/objectstack/pull/14572", "premise_still_valid": true, "summary": "Recommendation A landed in one PR at c079c35. (1) BusinessUnitGraphService.orgScope now composes the platform's own null-inclusive tenant screen ($or over organization_id equal to the rule's org OR NULL), matching SqlDriver.applyTenantScope and the #2734 rationale, so a seed-written (org-less) sys_business_unit row is usable by an org-stamped rule. (2) Both member reads are tenant-screened, which neither was before: expandUnitMembers AND expandUsers queried sys_business_unit_member with no organization predicate at all under a tenant-less system context, and the old strict unit screen was the only thing keeping an org-stamped rule away from that unscoped query — so (1) alone would have converted a silent under-grant into a silent cross-tenant over-grant. I screened the WIDE width too, not only the narrow one the triage named: unit_and_subordinates is the recipient kind the reported repro used and carried the identical hole; it is named in the PR body. (3) An active business-unit rule expanding to zero recipients now warns once per rule per process (same dedup as the inert-criteria warn), naming rule, object, recipient kind, unit and organization. The member screen is STRICT rather than null-inclusive because the prerequisite measurement came back negative (see the mcp/measurement notes below), so a NULL organization on a membership row means unknown tenancy and a grant fails closed on it. Declared residual: a rule carrying an organization whose unit AND memberships were both seeded still grants nobody — that is exactly the case change (3) makes loud, and the underlying ledger gap is filed as #14570.", "prerequisite_measurement": "sys_business_unit_member rows are NOT organization-stamped on every write path. REST/session write: STAMPED (engine threads execCtx.tenantId into DriverOptions; SqlDriver injectTenantOnInsert fills the applySystemFields-injected organization_id column). Seed replay: NOT STAMPED (packages/metadata-protocol/src/seed-loader.ts withholds its single-org fallbackOrgId from every sys_ / cloud_ / ai_ object; only a pinned config.organizationId or a record that spells the column itself stamps one). Elevated system-context write: NOT STAMPED (sys_business_unit_member is 'unclassified' in PLATFORM_OBJECT_TENANCY, packages/objectql/src/tenancy/platform-object-tenancy.ts, so Engine.resolveSystemInsertOrganization returns early). driver-memory / driver-mongodb: NEVER stamp a tenant column (both refuse to boot multi-tenant, which is what makes that safe). No import or migration writer for this object exists in the tree — the only non-generic writers found by grep are readers (plugin-security delegated-admin-gate, plugin-approvals approval-service, plugin-sharing primary-bu-projection). The repo's own dogfood fixture is an instance of the unstamped elevated path.", "tests": "All at c079c35 (git rev-parse --short HEAD after the final commit; nothing landed after the union). (a) pnpm --filter @objectstack/plugin-sharing test -> 'Test Files 31 passed (31) / Tests 731 passed (731)', EXIT=0 captured before any pipe. (b) pnpm --filter @objectstack/plugin-sharing typecheck -> EXIT=0; verdict line 'check:test-typecheck: OK — @objectstack/plugin-sharing's test layer compiles under packages/plugins/plugin-sharing/tsconfig.test.json; 2 file(s) / 3 error(s) / 3 pinned signature(s) held in test-typecheck-debt.json'. The package tsconfig excludes *.test.ts, so plain tsc does NOT cover the new tests — tsconfig.test.json does, proven by that gate naming my new file's one type error (TS2322 on the vi.fn mock) before I fixed it. (c) GATE UNION 68 families, derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no hand-built path list; the script reads its own change set) and RE-RUN in full at c079c35 after the last commit: 67 green, 1 NOT MEASURED — check-test-completeness exits 3, which its own text defines as 'nothing was measured' when handed no saved turbo test log, explicitly not a red. The family grew 37 -> 68 mid-run because check:system-context-census --fix added content/docs/permissions/system-context.mdx to the diff; I re-derived and ran all 31 newly-named docs families too. Two gates needed upkeep, both mechanical: check:system-context-census --fix (my +7-line insertion into sharing-rule-service.ts rotted anchors :157 -> :164 and :382 -> :389; the +7 arithmetic is exact) and check-engine-double-contract --write (the new test file pins engine doubles the ledger did not record; the gate's own text calls that GOOD and asks for the write). (d) pnpm lint over the WHOLE repo (eslint . --no-inline-config) EXIT=0 in 86s under the verify lock — no narrowing claimed, so none needs justifying. (e) pnpm check:nul-bytes EXIT=0 plus a direct grep -naP control-character scan over every file in the diff: no hits. (f) ABLATIONS, two, both with the rebuild question answered: NO rebuild is involved because the tests import './business-unit-graph.js' relative inside the package, so vitest resolves src and no dist/ can mask the mutation (check:test-source-alias green confirms no alias games). Each leg proved the mutation ON DISK by grep -c of BOTH the injected marker and the removed text (never by the editor's exit code) plus a git hash-object differing from the HEAD blob; each ran under trap '...' EXIT INT TERM with an absolute REPO_ROOT path; each restore used `git checkout HEAD -- ABSOLUTE_PATH` (never a bare checkout, which reads the index) and was proved by an empty git diff HEAD AND a restored blob hash equal to HEAD's 770a13fd. Ablation 1, orgScope back to strict equality: mutated blob f15745d8, result 'Tests 8 failed | 22 passed (30)' — red on (a) an org-NULL unit is USABLE, on the flipped divergence pin, and on both end-to-end materialisation cases. Ablation 2, both memberScope calls removed: mutated blob 3b4549fb, result 'Tests 6 failed | 24 passed (30)' — red on (b) members of ANOTHER organization are never expanded, (c) a NULL-org member row is NOT a member, and the zero-recipient warn. Neither red set is a subset of the other, which is what shows the two screens are two facts. NOT RUN LOCALLY and declared as such: the dogfood suite (needs a booted stack) and the downstream cross-package test sweep — CI's Dogfood Regression Gate and Test Core own those. My reasoning that dogfood is unaffected is reasoning, not measurement: rules.defineRule reads callerOrgId(context), the dogfood SYS context is a bare { isSystem: true } with no organizationId or tenantId, so the rule row is created with organization_id null, and with a null organization BOTH new screens are no-ops.", "fixture_triage": "Three pre-existing fixtures paired org-scoped UNITS with org-less MEMBER rows and passed only because the member read had no predicate — they are the gap, in fixture form. All three re-stamped (business-unit-graph.test.ts 'an org-scoped rule expands units belonging to that org'; sharing-rule.test.ts both sys_business_unit_member tables), each with a comment naming which fact it was always describing. Two more still assert [] but now for the MEMBER reason rather than the unit reason, and say so. One pin recorded the retired posture as deliberate ('[divergence] an org-scoped rule does NOT see an env-wide null-org unit') and is FLIPPED, not deleted; the file's head docblock — which had nominated exactly this $or predicate as the future fix — is rewritten to describe the asymmetric pair that landed. The consumption radius was swept by grepping every BusinessUnitGraphService consumer in packages/, examples/ and apps/: all inside plugin-sharing (the class is exported from index.ts, so out-of-repo consumers exist in principle).", "mcp_calls": "3 — issue_write (filing #14570), create_pull_request, add_issue_comment (this report). Every READ went through unauthenticated repo-scoped REST via curl, which answers 200 on this public repo: the issue's comments (to re-read the claim — one comment, the PM's, matching my session id and branch, no competing claim) and 6 pages of the open-issues list endpoint for the dedup scan. Channel note: the REST SEARCH endpoint answers 403 unauthenticated, so the dedup scan used the REST LIST endpoint plus a local regexp instead — 483 open non-PR issues scanned by title, with a control term ('sharing') returning 9 hits in the same pass, so the 4 candidate hits are a live reading and not an empty channel. No MCP search_issues call was needed.", "open_questions": [], "out_of_scope_findings": [ "filed as #14570: sys_business_unit_member is unadjudicated ('unclassified') in PLATFORM_OBJECT_TENANCY, so seed-replayed and elevated-system-context membership rows land organization-less — the residual cause of the one combination that still grants nobody after this PR. Filed unassigned with the full write-path table, a Blocked-by line to #14096 (whether the seed loader should follow the per-object classification at all) and a pointer to #13636 (the conditionally-tenant-scoped third state). No backfill proposed: the 2026-08-31 ruling's execution point 3 governs that." ] }Generated by Claude Code
Generated by Claude Code
os-project-manager commented
on Sep 2, 2026 CollaboratorMore actionsLedger note — ruled and dispatched through another PM session;
needs-user-decisionwas left on by mistake and is corrected hereDirector seat (objectstack #12708), summon #10, session
session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02.This card is in the decision inbox by label only. The record shows it was already ruled: the claim comment (5508806533, PM session
session_01VR2khJ3Me96btawVsfG6jb, 2026-09-02T11:27Z) relays the maintainer's ruling A (null-inclusiveorgScopeand a tenant screen on the member query, in one PR, after the write-path measurement), and the os-dev report (5509665790) delivers it as PR #14572 with the prerequisite measurement answered (membership rows are not organization-stamped on the seed and elevated-system paths, so the member screen is strict and fails closed; residual filed as #14570). The director seat does not re-adjudicate a ruling the maintainer has already given; it records that the ruling of record for this card is the one relayed in comment 5508806533, and that the provenance three-piece (who / verbatim / where) lives with that session, not here.State correction, same stroke:
needs-user-decision→pm:dispatched(PR #14572 open, assignee unchanged). The one-of rule holds again. Family note for the maintainer's next batch: this card, #14484 (sys_record_sharerows written org-NULL), #13564 (theorWhereNullcarve-out) and #14570 (sys_business_unit_memberunclassified) are one tenant-attribution family and are presented together.
Generated by Claude Code
10 remaining items
os-dev-report
{ "issue": 14547, "status": "done", "branch": "claude/issue-14547-bu-tenant-screen-relanding", "pr": "https://github.com/objectstack-ai/objectstack/pull/14949", "premise_still_valid": true, "summary": "Re-landed recommendation A as ruled, after re-running the prerequisite measurement against today's origin/main (431979e67) rather than trusting the prior round: sys_business_unit_member is still NOT organization-stamped on every write path (REST/session STAMPED; seed replay NOT, seed-loader.ts:926 withholds fallbackOrgId from /^(sys_|cloud_|ai_)/; elevated system write NOT, the object is absent from PLATFORM_OBJECT_TENANCY so it classifies unclassified and resolveSystemInsertOrganization returns early; driver-memory/mongodb never stamp). #14484 landed sys_record_share as tenant-scoped in that ledger, which covers that table and no other, so the member-row answer is unchanged and the asymmetry stands. The premise also still reproduces: orgScope is the strict equality, and BOTH member reads (expandUnitMembers and expandUsers) still carry no organization predicate under a tenant-less SYSTEM_CTX. Implemented the asymmetric pair in one PR: orgScope is now the platform's null-inclusive (organization_id = org OR IS NULL), and a new strict memberScope screens both member reads, so widening the unit screen cannot convert the silent under-grant into a silent cross-tenant over-grant. Both business-unit recipient widths route through this graph and both carried the hole; user/team/position/queue do not reach it, and team/position already screen their own membership rows strictly. An active business-unit rule that expands to nobody now warns once per rule per process, naming rule, object, recipient kind, unit and organization — that is the residual case the strict member screen deliberately leaves empty. Two stale pins were REPLACED not flipped: the [divergence] test and 'the narrow width is org-predicated exactly like the wide one' would both have kept passing for a different reason after the fix. Membership fixtures that were org-less under org-stamped rules gained organization_id — they only expanded because the member read had no predicate. Deliberate divergence from closed PR #14572: its public getter emptyUnitExpansionRuleKeys is dropped, keeping the whole diff internal to plugin-sharing.", "tests": "FINAL HEAD 7e27ab512, clean tree. Union re-run AFTER the final commit: `pnpm --filter @objectstack/plugin-sharing build && ... test && ... typecheck` -> exit 0; 'check-dts-emitted: 1/1 declared declaration file(s) present'; 'Test Files 32 passed (32) / Tests 790 passed (790)'; typecheck green AND it covers the test layer — the package excludes *.test.ts from tsconfig.json, but its typecheck script chains check:test-typecheck, which printed 'OK — the test layer compiles under tsconfig.test.json', so the edited test files are MEASURED, not merely adjacent to a green check. GATES: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, re-derived after the commit that added the docs file (6 paths -> 7 paths, 42 -> 66 commands); all 66 run, every exit code captured by redirect-then-read, never across a pipe. 61 green, 0 findings, 5 NOT MEASURED (each refuses a verdict without a whole-tree build, quoted from its own verdict text): check-test-completeness exit 3 'Nothing was measured... It is NOT a finding'; check:dual-build-cjs-loads exit 3 'PREREQUISITE NOT MET... This is NOT a pass: nothing was measured'; check:type-check-debt exit 3 'NOT a pass and NOT a finding'; check:i18n exit 1 'Nothing was checked: no bundle was compared and no config was parsed'; check:skill-examples exit 1 'packages/client-react/dist holds no .d.ts declarations... a verdict now would be a FALSE GREEN'. The last two exit 1 rather than 3 and are still NOT MEASURED, not findings. check:system-context-census DID red (pure line rot from my insertion) and was repaired ONLY with its own --fix: re-anchored :157->:165 and :382->:390, then 'OK — 109 elevation read sites... all anchored'. No new rows in scripts/engine-double-contract.pinned.json — the end-to-end tests went into recipient-width.test.ts, whose engine double is already pinned for both write verbs. ABLATIONS, one per screen, direction predicted in writing BEFORE running. NO REBUILD WAS REQUIRED OR PERFORMED, and that is a measured property, not an omission: both suites import the subject through same-package RELATIVE specifiers (./business-unit-graph.js, ./sharing-rule-service.js), so vitest resolves them from src and no dist sits between the mutation and the run; a dist-resolved subject would have needed a rebuild on BOTH legs. A (orgScope back to strict equality) predicted RED on the functional and seeded-unit pins with sharing-rule.test.ts staying green -> observed 13 failed / 147 passed, exactly that set. B (memberScope removed from both member reads) predicted RED on the SECURITY pins only with the functional WIDE/NARROW grant pins staying GREEN -> observed 9 failed / 151 passed, exactly that set; B's asymmetry is the evidence the security half is pinned independently of the functional half. MUTATION PROVED ON DISK before any result was read, never from an editor exit code: removed-text occurrence count driven to 0, injected marker counted (1 for A, 2 for B), and the mutated blob hash compared against the HEAD blob with a hard non-zero abort if equal; no // comment was used as a marker (esbuild strips them). RESTORE PROVED on both legs: `git checkout HEAD -- path` (never a bare checkout, which reads the polluted index), then blob-hash equality with the HEAD blob AND an empty `git diff HEAD`, both asserted with a non-zero abort; the harness carried a trap with an absolute REPO_ROOT path. The implementation was committed BEFORE the ablations, so the restore leg had a true reference. CLAUSE-2 measured the same way: built dist/index.d.ts on both sides (BASE source built, restored, restore proved by src-tree hash equality and empty git diff HEAD) — 84 exported symbols before and after, zero added zero removed; 62 top-level export/declare lines before and after; the only three non-comment .d.ts additions are private members.", "mcp_calls": "4 — one targeted search_issues (declared channel switch: repo-scoped REST reads answered 200 but /search/issues answered 403; the search returned known-hit controls including #14547 itself, so its negative on the approvals defect is a real reading), one issue_write to file #14946, one create_pull_request, one add_issue_comment for this report. Everything else went over zero-quota channels: git for the prior branch and the diff, repo-scoped REST for the card body.", "open_questions": [], "out_of_scope_findings": [ "filed as #14946: plugin-approvals has the identical defect one plugin over — ApprovalService.expandBusinessUnitUsers screens the unit rows null-inclusively (since #3807) but reads sys_business_unit_member at approval-service.ts:1771 with no organization predicate under a tenant-less SYSTEM_CTX, so on a seeded org chart a department approver on one tenant's request resolves to another tenant's users. Not taken as a bounded in-place fix: it is a different package, so it adds a verification surface (condition 4 fails) and may be held by another lane (condition 3 unverifiable). Filed unassigned with the evidence and the suggested asymmetric shape." ] }Clause-② determination: no — measured on the built
dist/index.d.ts, 84 exported symbols before and after with zero added and zero removed, the only additions being threeprivatemembers. This differs from PR #14572's shape on purpose: that branch exposed a publicemptyUnitExpansionRuleKeysgetter, which would have flipped the determination toyes; it is dropped here and the dedup is asserted through the logger spy instead.Generated by Claude Code
Generated by Claude Code
Claim: dispatched by the
domain:servicesexecution seat, PM sessionsession_01AUF1NoViznQK32gqpK8wS8. Delivering PR #14949 on branchclaude/issue-14547-bu-tenant-screen-relanding.Clause-②: no
Provenance of this reading — it is a respelling, not a new judgement
The dev already made this determination and measured it.
check-clause2-carriers.mjs --pair 14949exits 4 and quotes what it found on the thread:"Clause-② determination: no — measured on the built
dist/index.d.ts, 84 exported symbols before and after with zero added and zero removed, the only additi…"The checker is rejecting the spelling, not the finding: its
CLAUSE2_KEY_LINEis line-anchored and requires exactlyClause-②: yesorClause-②: no, and prose — however well measured — reads to it as nothing. Its own text draws the distinction that matters here: "A missing reading is NOT a declaredno: one of those is a decision and the other is an absent one."The script also says ⛔ do not fill the line in on the claiming seat's behalf. This seat is the claiming seat for this card, so this is that seat making its own declaration legible — not another seat authoring one for it. The substance is the dev's measurement, unchanged and unrounded:
- built
dist/index.d.tscompared on both sides, with the base source built, restored, and the restore proved by src-tree hash equality plus an emptygit diff HEAD; - 84 exported symbols before, 84 after — zero added, zero removed;
- 62 top-level
export/declarelines on both sides; - the only three non-comment
.d.tsadditions are private members.
⇒
no. The published surface does not move; the behaviour change is internal toplugin-sharing, and the dev deliberately dropped closed PR #14572's publicemptyUnitExpansionRuleKeysgetter to keep it that way.⛔ Accordingly no
needs:contract-reviewcarrier is hung on this card or on PR #14949 — a declarednois a decision on the record, and hanging a gate against it would be as wrong as clearing one against ayes.
Generated by Claude Code
- built
- added a commit that references this issue
on Sep 3, 2026 The 17.x symptom stands again — the fix here was reverted before 17.3 was cut
⛔ Deliberately not reopening this issue or changing anything it closed. Recording the state change for whoever reads it next; the reopen decision is the maintainer's.
What happened. #14949 closed this by making
BusinessUnitGraphService.orgScopeNULL-inclusive — the platform's(organization_id = ? OR organization_id IS NULL)predicate — so an org-stamped rule could reach a seeded, org-lesssys_business_unitrow. On the maintainer's ruling (ADR-0131 D8) that half is reverted in #15078, before the 17.3 tag. The shape re-implemented, a second time and in a second place, the predicateSqlDriver.applyTenantScopealready owns — the duplication ADR-0131 exists to retire (#10103 cause 1) — and it had not been released, so reverting costs nothing while shipping it would have owed v18 a breaking change and a migration.So the behaviour reported here is back, and 17.3 ships it exactly as 17.2.0 does: an org-stamped rule naming a seeded business unit expands to nobody, for both recipient widths.
Root cause, restated where it actually lives. This was never really the sharing screen's bug. A
sys_business_unitrow written by seed data carries no organization because the seed loader withholds itsfallbackOrgIdfrom every/^(sys_|cloud_|ai_)/object, and because at first boot there is no organization id to stamp yet. The screen is only where the consequence surfaces. Widening the screen treats the symptom and duplicates a predicate; stamping the row treats the cause.Owned by ADR-0131 C1, on the v18 line: the Default Organization exists before application seed datasets load, and the seed loader stamps
sys_business_unitseeds. Once the row carries an organization, a strict equality finds it and no screen has to special-case a NULL.What #14949 left behind, and what is kept. Two things from that PR are not reverted:
BusinessUnitGraphService.memberScope— bothsys_business_unit_memberreads previously carried no organization predicate at all. That is a genuine cross-tenant exposure independent of the unit screen (other organizations' member rows sit on org-stamped, visible units too), and it stays closed.SharingRuleService.warnOnEmptyUnitExpansion— an active business-unit rule that expands to nobody now says so, once per rule per process, naming the rule, the object, the recipient kind, the unit and the organization.
That second one is the material difference from 17.2.0 for anyone hitting this: the failure described in this issue — "accepted, stays active, materialises no rows, and logs nothing" — is no longer silent. It is still a failure, but it is now observable at the moment it happens rather than only as "the right people cannot see the record".
Workaround on 17.x, unchanged: give the business-unit row an organization (create the unit through the API/UI as the org admin rather than from seed data, or stamp
organization_idon the seeded row), or author the rule org-less — a platform-global rule threads no organization, so neither screen applies.Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Sep 3, 2026 - added 3 commits that reference this issue
on Sep 9, 2026
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