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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bu-tenant-screen-relanding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Fix: a sharing rule with a business-unit recipient granted nothing when the unit came from seed data — and tenant-screen the member reads that widening exposes.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own read-side chokepoint (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`, because a NULL organization marks a platform/seeded row every tenant may see. A sharing rule always carries the caller's organization, but a business unit written by seed data carries none — a seed cannot know the id the runtime mints at boot — so the two never matched. The seed check read the unit as "does not exist", both recipient widths (`business_unit` and `unit_and_subordinates`) expanded to zero users, and the rule stayed active having materialised no `sys_record_share` row and logged nothing. `orgScope` now applies the platform's null-inclusive screen, the same predicate `plugin-approvals` already applies to these very rows and `SharingRuleService.adminOrgScope` applies to the rule table.

The member reads are now tenant-screened, which they were not before. Both `expandUnitMembers` and `expandUsers` queried `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either, so the strict unit screen was the only thing keeping an org-stamped rule away from that unscoped query. Widening the unit screen alone would have turned a silent under-grant into a silent cross-tenant over-grant, since a seeded unit id exists identically in every tenant. The member screen is strict rather than null-inclusive on purpose: seed replay and elevated system writes both leave `sys_business_unit_member.organization_id` NULL, so a NULL there means unknown tenancy rather than platform-global, and an org-scoped rule does not grant to it. The sibling recipient widths already read their membership rows this way.

An active business-unit rule that expands to no recipients now warns once per rule per process, naming the rule, the object, the recipient kind, the unit and the organization. That case — a rule whose unit and membership rows were both seeded — is the one combination that still grants nobody, and it is no longer silent.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,7 +137,7 @@ The largest single consumer — **20 of the 109 sites**.
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:165`, `:390` |

### 4. Approvals, reports, attachments, comments, knowledge

Expand Down
265 changes: 228 additions & 37 deletions packages/plugins/plugin-sharing/src/business-unit-graph.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,45 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* BusinessUnitGraphService — org scoping of the unit tree.
* BusinessUnitGraphService — the TWO tenant screens, pinned as a pair.
*
* These pin the ORG-SCOPE behaviour specifically, because it is the exact
* shape that broke approvals in #3807: `orgScope()` AND-composes a strict
* `organization_id = <rule org>` equality, so a unit written with no
* organization at all (a seeded / file-layer / bootstrap row — a seed cannot
* know the org id the runtime mints at boot) matches nothing, the seed check
* fails, and the expansion returns zero members. In approvals that produced a
* dead `department:<id>` approver slot; here it would produce a sharing rule
* that silently grants nobody.
* ## What this file used to say, and why it changed
*
* It is NOT reachable today: every materialized `sys_sharing_rule` row carries
* `organization_id = null` (verified on a live showcase stack), so
* `expandRecipient` passes `null` and `orgScope` is skipped entirely. The
* moment rules start carrying an org — a multi-tenant deployment — a BU
* subtree rule against a seeded unit stops granting, and the symptom is
* "the right people cannot see the record", which is far quieter than a stuck
* approval.
* Until #14547 `orgScope()` AND-composed a strict `organization_id = <rule
* org>` equality onto the UNIT read. A unit written with no organization at
* all (a seeded / file-layer / bootstrap row — a seed cannot know the org id
* the runtime mints at boot) therefore matched nothing, the seed check failed,
* and BOTH widths expanded to zero members. #3807 had already fixed exactly
* that on the approvals side; this file recorded the sharing side's divergence
* as deliberate on the grounds that it was unreachable, because every
* materialized `sys_sharing_rule` row carried `organization_id = null`.
*
* So this file locks BOTH sides down:
* - the reachable paths (null-org rule) keep working, and
* - the divergence from approvals is written down as an executable fact
* rather than a comment, so flipping it is a deliberate edit to a named
* test and never a silent behaviour change.
* It was reachable. #14547 is the external report: an org admin creating a
* rule at runtime gets an org-stamped rule, the seeded unit carries none, and
* the rule is accepted, stays active, materialises zero `sys_record_share`
* rows and logs nothing. The `[divergence]` test that pinned the old posture
* is gone — replaced, not merely flipped, because an assertion that keeps
* passing while the mechanism under it changes is worse than no assertion.
*
* If the platform decides null-org means "env-wide, visible to every org" for
* sharing too — the way `plugin-approvals` and `sys_metadata` already read it —
* the test named `[divergence]` below is the one to flip, and `orgScope` grows
* the same `$or: [{ organization_id }, { organization_id: null }]` predicate.
* ## The pair this file now pins
*
* The fix is ASYMMETRIC and both halves have to be pinned, because each one
* alone is a defect:
*
* - the UNIT screen (`orgScope`) is NULL-INCLUSIVE — the platform's own
* `(organization_id = ? OR organization_id IS NULL)`, the predicate
* `SqlDriver.applyTenantScope` writes and `plugin-approvals` already
* applies to these very rows;
* - the MEMBER screen (`memberScope`) is STRICT. Both member reads used to
* carry no organization predicate at all, and the strict unit screen was
* the only thing holding an org-stamped rule away from that unscoped
* query. Widening the unit screen ALONE turns a silent under-grant into a
* silent CROSS-TENANT OVER-GRANT, since a seeded unit id exists
* identically in every tenant.
*
* So the security half is pinned separately from the functional half below: a
* change that expands the right members while also expanding another
* organization's members satisfies the functional pin completely.
*/

import { describe, it, expect } from 'vitest';
Expand All@@ -40,8 +50,9 @@ interface UnitRow {
parent_business_unit_id?: string | null;
organization_id?: string | null;
active?: boolean;
manager_user_id?: string | null;
}
interface MemberRow { business_unit_id: string; user_id: string }
interface MemberRow { business_unit_id: string; user_id: string; organization_id?: string | null }

/**
* Minimal engine over `sys_business_unit` + `sys_business_unit_member`.
Expand DownExpand Up@@ -168,13 +179,33 @@ describe('BusinessUnitGraphService — the two widths are actually two widths (#
});

it('the narrow width is org-predicated exactly like the wide one', async () => {
// [#14547] Same fixture, new mechanism — and the mechanism is spelled out
// because the ASSERTION did not move. `DIV_MEMBERS` carry no organization,
// so before #14547 this returned `[]` because the strict UNIT screen hid
// the seeded unit, and after it returns `[]` because the strict MEMBER
// screen refuses membership rows of unknown tenancy. An unchanged
// expectation over a changed cause is exactly the kind of pin that stops
// guarding anything, so the two causes are separated below: the unit is
// now visible (`descendants` sees the whole seeded tree), and it is the
// members that are refused.
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, DIV_MEMBERS),
organizationId: 'org_a',
});
// Seeded (null-org) units are not visible to an org-scoped rule — the
// same `[divergence]` posture the wide width holds below.
expect(await g.expandUnitMembers('bu_div')).toEqual([]);
expect((await g.descendants('bu_div')).sort()).toEqual(['bu_dept', 'bu_div', 'bu_office']);
});

it('[#14547] both widths reach org-stamped members of a SEEDED unit tree', async () => {
// The one change that flips the outcome: the membership rows are stamped,
// exactly as a REST/session write stamps them. The units stay seeded.
const members: MemberRow[] = DIV_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_div')).toEqual(['u_div']);
expect((await g.expandUsers('bu_div')).sort()).toEqual(['u_dept', 'u_div', 'u_office']);
});

it('the two widths do NOT share a cache entry for the same unit id', async () => {
Expand DownExpand Up@@ -208,8 +239,14 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
{ id: 'bu_root', organization_id: 'org_a', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_a', active: true },
];
// [#14547] The membership rows are stamped now. They used to be org-less
// here and still expanded, because the member read carried no organization
// predicate whatever — the gap #14547 closed. Units created through the
// API by org_a have memberships created the same way, so this is the
// fixture becoming faithful, not the assertion being relaxed.
const members: MemberRow[] = SEEDED_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(units, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
Expand All@@ -224,17 +261,171 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
expect(await g.expandUsers('bu_root')).toEqual([]);
});

it('[divergence] an org-scoped rule does NOT see an env-wide (null-org) unit — approvals does (#3807)', async () => {
// Same inputs that #3807 fixed on the approvals side. Sharing still reads
// a null-org unit as "belongs to no org, therefore not mine" and grants
// nobody. Unreachable today (rules are null-org), deliberate until the
// platform rules on null-org semantics for AUTHORIZATION paths — widening
// who can SEE a record is not a change to make on a defect that cannot
// currently fire.
it('an org-scoped rule never reaches another org’s MEMBER of a unit it can see', async () => {
// [#14547] The unit is org_a's and visible; the membership row is org_b's.
// The member screen is the only thing that answers here, so this fails if
// `memberScope` is dropped even while every unit-level assertion passes.
const units: UnitRow[] = [{ id: 'bu_root', organization_id: 'org_a', active: true }];
const members: MemberRow[] = [
{ business_unit_id: 'bu_root', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_root', user_id: 'u_b', organization_id: 'org_b' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual(['u_a']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_a']);
});
});

/**
* [#14547] The UNIT screen is null-inclusive — the divergence from
* `plugin-approvals` (#3807) is CLOSED.
*
* The `[divergence]` test that used to live in the block above pinned the
* opposite posture on the grounds that it could not fire. It fired: the
* external report is an org admin creating a rule at runtime against a unit
* the app seeded.
*/
describe('BusinessUnitGraphService — the UNIT screen (#14547)', () => {
const STAMPED_MEMBERS: MemberRow[] = SEEDED_MEMBERS.map((m) => ({
...m,
organization_id: 'org_a',
}));

it('an org-scoped rule DOES see an env-wide (null-org) seeded unit', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_root']);
});

it('the seed check and the subtree walk BOTH admit the seeded rows', async () => {
// `seedIsUsable` and the `descendants` BFS are two separate reads through
// the same screen; a widening applied to one and not the other would still
// answer `[]` for the subtree width.
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.descendants('bu_root')).sort()).toEqual(['bu_child', 'bu_root']);
});

it('`headOf` resolves the manager of a seeded unit too', async () => {
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: null, active: true, manager_user_id: 'u_head' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, []),
organizationId: 'org_a',
});
expect(await g.headOf('bu_root')).toBe('u_head');
});

it('ONLY the NULL arm widened — another org’s unit is still invisible', async () => {
// The control that separates "null-inclusive" from "unscoped". Without it
// a screen that had simply been deleted would pass every assertion above.
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: 'org_b', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_b', active: true },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
expect(await g.descendants('bu_root')).toEqual([]);
expect(await g.headOf('bu_root')).toBeNull();
});

it('an INACTIVE seeded unit still contributes nobody', async () => {
const units: UnitRow[] = SEEDED_UNITS.map((u) =>
u.id === 'bu_root' ? { ...u, active: false } : u,
);
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
});
});

/**
* [#14547] The MEMBER screen is STRICT — the leak the unit widening would
* otherwise have opened.
*
* ⚠️ These are the SECURITY half and they are pinned apart from the functional
* half on purpose: a change that expands the right members while also
* expanding another organization's members passes every assertion in the block
* above.
*/
describe('BusinessUnitGraphService — the MEMBER screen (#14547)', () => {
/**
* One SEEDED unit id with two tenants' memberships hanging off it — the
* shape that exists on every deployment whose org chart came from a seed,
* and the one the widened unit screen makes reachable.
*/
const SHARED_SEED_UNITS: UnitRow[] = [
{ id: 'bu_market', organization_id: null, active: true },
{ id: 'bu_market_west', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
];
const TWO_TENANT_MEMBERS: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_market', user_id: 'u_b', organization_id: 'org_b' },
{ business_unit_id: 'bu_market_west', user_id: 'u_a_west', organization_id: 'org_a' },
{ business_unit_id: 'bu_market_west', user_id: 'u_b_west', organization_id: 'org_b' },
];

it('WIDE — a subtree expansion never crosses into another organization', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
const users = await g.expandUsers('bu_market');
expect(users.sort()).toEqual(['u_a', 'u_a_west']);
expect(users).not.toContain('u_b');
expect(users).not.toContain('u_b_west');
});

it('NARROW — the single-unit expansion does not cross either', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual(['u_a']);
});

it('an org-LESS membership row is NOT a member of an org-scoped rule', async () => {
// Unknown tenancy, not platform-global: `sys_business_unit_member` is not
// organization-stamped by seed replay or by an elevated system write, so a
// NULL here cannot be read the way a NULL on the UNIT row is read. The
// grant fails closed, and `SharingRuleService` warns rather than staying
// silent about it.
const members: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_seeded', organization_id: null },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual([]);
expect(await g.expandUsers('bu_market')).toEqual([]);
});

it('an org-LESS rule is unmoved — both screens stay no-ops', async () => {
// The dominant shape today (declared rules bootstrap org-less). #14547
// must not change what they expand to, in either direction.
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: null,
});
expect((await g.expandUsers('bu_market')).sort()).toEqual([
'u_a', 'u_a_west', 'u_b', 'u_b_west',
]);
});
});
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bu-tenant-screen-relanding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Fix: a sharing rule with a business-unit recipient granted nothing when the unit came from seed data — and tenant-screen the member reads that widening exposes.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own read-side chokepoint (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`, because a NULL organization marks a platform/seeded row every tenant may see. A sharing rule always carries the caller's organization, but a business unit written by seed data carries none — a seed cannot know the id the runtime mints at boot — so the two never matched. The seed check read the unit as "does not exist", both recipient widths (`business_unit` and `unit_and_subordinates`) expanded to zero users, and the rule stayed active having materialised no `sys_record_share` row and logged nothing. `orgScope` now applies the platform's null-inclusive screen, the same predicate `plugin-approvals` already applies to these very rows and `SharingRuleService.adminOrgScope` applies to the rule table.

The member reads are now tenant-screened, which they were not before. Both `expandUnitMembers` and `expandUsers` queried `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either, so the strict unit screen was the only thing keeping an org-stamped rule away from that unscoped query. Widening the unit screen alone would have turned a silent under-grant into a silent cross-tenant over-grant, since a seeded unit id exists identically in every tenant. The member screen is strict rather than null-inclusive on purpose: seed replay and elevated system writes both leave `sys_business_unit_member.organization_id` NULL, so a NULL there means unknown tenancy rather than platform-global, and an org-scoped rule does not grant to it. The sibling recipient widths already read their membership rows this way.

An active business-unit rule that expands to no recipients now warns once per rule per process, naming the rule, the object, the recipient kind, the unit and the organization. That case — a rule whose unit and membership rows were both seeded — is the one combination that still grants nobody, and it is no longer silent.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,7 +137,7 @@ The largest single consumer — **20 of the 109 sites**.
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:165`, `:390` |

### 4. Approvals, reports, attachments, comments, knowledge

Expand Down
265 changes: 228 additions & 37 deletions packages/plugins/plugin-sharing/src/business-unit-graph.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,45 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* BusinessUnitGraphService — org scoping of the unit tree.
* BusinessUnitGraphService — the TWO tenant screens, pinned as a pair.
*
* These pin the ORG-SCOPE behaviour specifically, because it is the exact
* shape that broke approvals in #3807: `orgScope()` AND-composes a strict
* `organization_id = <rule org>` equality, so a unit written with no
* organization at all (a seeded / file-layer / bootstrap row — a seed cannot
* know the org id the runtime mints at boot) matches nothing, the seed check
* fails, and the expansion returns zero members. In approvals that produced a
* dead `department:<id>` approver slot; here it would produce a sharing rule
* that silently grants nobody.
* ## What this file used to say, and why it changed
*
* It is NOT reachable today: every materialized `sys_sharing_rule` row carries
* `organization_id = null` (verified on a live showcase stack), so
* `expandRecipient` passes `null` and `orgScope` is skipped entirely. The
* moment rules start carrying an org — a multi-tenant deployment — a BU
* subtree rule against a seeded unit stops granting, and the symptom is
* "the right people cannot see the record", which is far quieter than a stuck
* approval.
* Until #14547 `orgScope()` AND-composed a strict `organization_id = <rule
* org>` equality onto the UNIT read. A unit written with no organization at
* all (a seeded / file-layer / bootstrap row — a seed cannot know the org id
* the runtime mints at boot) therefore matched nothing, the seed check failed,
* and BOTH widths expanded to zero members. #3807 had already fixed exactly
* that on the approvals side; this file recorded the sharing side's divergence
* as deliberate on the grounds that it was unreachable, because every
* materialized `sys_sharing_rule` row carried `organization_id = null`.
*
* So this file locks BOTH sides down:
* - the reachable paths (null-org rule) keep working, and
* - the divergence from approvals is written down as an executable fact
* rather than a comment, so flipping it is a deliberate edit to a named
* test and never a silent behaviour change.
* It was reachable. #14547 is the external report: an org admin creating a
* rule at runtime gets an org-stamped rule, the seeded unit carries none, and
* the rule is accepted, stays active, materialises zero `sys_record_share`
* rows and logs nothing. The `[divergence]` test that pinned the old posture
* is gone — replaced, not merely flipped, because an assertion that keeps
* passing while the mechanism under it changes is worse than no assertion.
*
* If the platform decides null-org means "env-wide, visible to every org" for
* sharing too — the way `plugin-approvals` and `sys_metadata` already read it —
* the test named `[divergence]` below is the one to flip, and `orgScope` grows
* the same `$or: [{ organization_id }, { organization_id: null }]` predicate.
* ## The pair this file now pins
*
* The fix is ASYMMETRIC and both halves have to be pinned, because each one
* alone is a defect:
*
* - the UNIT screen (`orgScope`) is NULL-INCLUSIVE — the platform's own
* `(organization_id = ? OR organization_id IS NULL)`, the predicate
* `SqlDriver.applyTenantScope` writes and `plugin-approvals` already
* applies to these very rows;
* - the MEMBER screen (`memberScope`) is STRICT. Both member reads used to
* carry no organization predicate at all, and the strict unit screen was
* the only thing holding an org-stamped rule away from that unscoped
* query. Widening the unit screen ALONE turns a silent under-grant into a
* silent CROSS-TENANT OVER-GRANT, since a seeded unit id exists
* identically in every tenant.
*
* So the security half is pinned separately from the functional half below: a
* change that expands the right members while also expanding another
* organization's members satisfies the functional pin completely.
*/

import { describe, it, expect } from 'vitest';
Expand All@@ -40,8 +50,9 @@ interface UnitRow {
parent_business_unit_id?: string | null;
organization_id?: string | null;
active?: boolean;
manager_user_id?: string | null;
}
interface MemberRow { business_unit_id: string; user_id: string }
interface MemberRow { business_unit_id: string; user_id: string; organization_id?: string | null }

/**
* Minimal engine over `sys_business_unit` + `sys_business_unit_member`.
Expand DownExpand Up@@ -168,13 +179,33 @@ describe('BusinessUnitGraphService — the two widths are actually two widths (#
});

it('the narrow width is org-predicated exactly like the wide one', async () => {
// [#14547] Same fixture, new mechanism — and the mechanism is spelled out
// because the ASSERTION did not move. `DIV_MEMBERS` carry no organization,
// so before #14547 this returned `[]` because the strict UNIT screen hid
// the seeded unit, and after it returns `[]` because the strict MEMBER
// screen refuses membership rows of unknown tenancy. An unchanged
// expectation over a changed cause is exactly the kind of pin that stops
// guarding anything, so the two causes are separated below: the unit is
// now visible (`descendants` sees the whole seeded tree), and it is the
// members that are refused.
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, DIV_MEMBERS),
organizationId: 'org_a',
});
// Seeded (null-org) units are not visible to an org-scoped rule — the
// same `[divergence]` posture the wide width holds below.
expect(await g.expandUnitMembers('bu_div')).toEqual([]);
expect((await g.descendants('bu_div')).sort()).toEqual(['bu_dept', 'bu_div', 'bu_office']);
});

it('[#14547] both widths reach org-stamped members of a SEEDED unit tree', async () => {
// The one change that flips the outcome: the membership rows are stamped,
// exactly as a REST/session write stamps them. The units stay seeded.
const members: MemberRow[] = DIV_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_div')).toEqual(['u_div']);
expect((await g.expandUsers('bu_div')).sort()).toEqual(['u_dept', 'u_div', 'u_office']);
});

it('the two widths do NOT share a cache entry for the same unit id', async () => {
Expand DownExpand Up@@ -208,8 +239,14 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
{ id: 'bu_root', organization_id: 'org_a', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_a', active: true },
];
// [#14547] The membership rows are stamped now. They used to be org-less
// here and still expanded, because the member read carried no organization
// predicate whatever — the gap #14547 closed. Units created through the
// API by org_a have memberships created the same way, so this is the
// fixture becoming faithful, not the assertion being relaxed.
const members: MemberRow[] = SEEDED_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(units, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
Expand All@@ -224,17 +261,171 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
expect(await g.expandUsers('bu_root')).toEqual([]);
});

it('[divergence] an org-scoped rule does NOT see an env-wide (null-org) unit — approvals does (#3807)', async () => {
// Same inputs that #3807 fixed on the approvals side. Sharing still reads
// a null-org unit as "belongs to no org, therefore not mine" and grants
// nobody. Unreachable today (rules are null-org), deliberate until the
// platform rules on null-org semantics for AUTHORIZATION paths — widening
// who can SEE a record is not a change to make on a defect that cannot
// currently fire.
it('an org-scoped rule never reaches another org’s MEMBER of a unit it can see', async () => {
// [#14547] The unit is org_a's and visible; the membership row is org_b's.
// The member screen is the only thing that answers here, so this fails if
// `memberScope` is dropped even while every unit-level assertion passes.
const units: UnitRow[] = [{ id: 'bu_root', organization_id: 'org_a', active: true }];
const members: MemberRow[] = [
{ business_unit_id: 'bu_root', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_root', user_id: 'u_b', organization_id: 'org_b' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual(['u_a']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_a']);
});
});

/**
* [#14547] The UNIT screen is null-inclusive — the divergence from
* `plugin-approvals` (#3807) is CLOSED.
*
* The `[divergence]` test that used to live in the block above pinned the
* opposite posture on the grounds that it could not fire. It fired: the
* external report is an org admin creating a rule at runtime against a unit
* the app seeded.
*/
describe('BusinessUnitGraphService — the UNIT screen (#14547)', () => {
const STAMPED_MEMBERS: MemberRow[] = SEEDED_MEMBERS.map((m) => ({
...m,
organization_id: 'org_a',
}));

it('an org-scoped rule DOES see an env-wide (null-org) seeded unit', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_root']);
});

it('the seed check and the subtree walk BOTH admit the seeded rows', async () => {
// `seedIsUsable` and the `descendants` BFS are two separate reads through
// the same screen; a widening applied to one and not the other would still
// answer `[]` for the subtree width.
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.descendants('bu_root')).sort()).toEqual(['bu_child', 'bu_root']);
});

it('`headOf` resolves the manager of a seeded unit too', async () => {
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: null, active: true, manager_user_id: 'u_head' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, []),
organizationId: 'org_a',
});
expect(await g.headOf('bu_root')).toBe('u_head');
});

it('ONLY the NULL arm widened — another org’s unit is still invisible', async () => {
// The control that separates "null-inclusive" from "unscoped". Without it
// a screen that had simply been deleted would pass every assertion above.
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: 'org_b', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_b', active: true },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
expect(await g.descendants('bu_root')).toEqual([]);
expect(await g.headOf('bu_root')).toBeNull();
});

it('an INACTIVE seeded unit still contributes nobody', async () => {
const units: UnitRow[] = SEEDED_UNITS.map((u) =>
u.id === 'bu_root' ? { ...u, active: false } : u,
);
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
});
});

/**
* [#14547] The MEMBER screen is STRICT — the leak the unit widening would
* otherwise have opened.
*
* ⚠️ These are the SECURITY half and they are pinned apart from the functional
* half on purpose: a change that expands the right members while also
* expanding another organization's members passes every assertion in the block
* above.
*/
describe('BusinessUnitGraphService — the MEMBER screen (#14547)', () => {
/**
* One SEEDED unit id with two tenants' memberships hanging off it — the
* shape that exists on every deployment whose org chart came from a seed,
* and the one the widened unit screen makes reachable.
*/
const SHARED_SEED_UNITS: UnitRow[] = [
{ id: 'bu_market', organization_id: null, active: true },
{ id: 'bu_market_west', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
];
const TWO_TENANT_MEMBERS: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_market', user_id: 'u_b', organization_id: 'org_b' },
{ business_unit_id: 'bu_market_west', user_id: 'u_a_west', organization_id: 'org_a' },
{ business_unit_id: 'bu_market_west', user_id: 'u_b_west', organization_id: 'org_b' },
];

it('WIDE — a subtree expansion never crosses into another organization', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
const users = await g.expandUsers('bu_market');
expect(users.sort()).toEqual(['u_a', 'u_a_west']);
expect(users).not.toContain('u_b');
expect(users).not.toContain('u_b_west');
});

it('NARROW — the single-unit expansion does not cross either', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual(['u_a']);
});

it('an org-LESS membership row is NOT a member of an org-scoped rule', async () => {
// Unknown tenancy, not platform-global: `sys_business_unit_member` is not
// organization-stamped by seed replay or by an elevated system write, so a
// NULL here cannot be read the way a NULL on the UNIT row is read. The
// grant fails closed, and `SharingRuleService` warns rather than staying
// silent about it.
const members: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_seeded', organization_id: null },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual([]);
expect(await g.expandUsers('bu_market')).toEqual([]);
});

it('an org-LESS rule is unmoved — both screens stay no-ops', async () => {
// The dominant shape today (declared rules bootstrap org-less). #14547
// must not change what they expand to, in either direction.
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: null,
});
expect((await g.expandUsers('bu_market')).sort()).toEqual([
'u_a', 'u_a_west', 'u_b', 'u_b_west',
]);
});
});
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bu-tenant-screen-relanding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Fix: a sharing rule with a business-unit recipient granted nothing when the unit came from seed data — and tenant-screen the member reads that widening exposes.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own read-side chokepoint (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`, because a NULL organization marks a platform/seeded row every tenant may see. A sharing rule always carries the caller's organization, but a business unit written by seed data carries none — a seed cannot know the id the runtime mints at boot — so the two never matched. The seed check read the unit as "does not exist", both recipient widths (`business_unit` and `unit_and_subordinates`) expanded to zero users, and the rule stayed active having materialised no `sys_record_share` row and logged nothing. `orgScope` now applies the platform's null-inclusive screen, the same predicate `plugin-approvals` already applies to these very rows and `SharingRuleService.adminOrgScope` applies to the rule table.

The member reads are now tenant-screened, which they were not before. Both `expandUnitMembers` and `expandUsers` queried `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either, so the strict unit screen was the only thing keeping an org-stamped rule away from that unscoped query. Widening the unit screen alone would have turned a silent under-grant into a silent cross-tenant over-grant, since a seeded unit id exists identically in every tenant. The member screen is strict rather than null-inclusive on purpose: seed replay and elevated system writes both leave `sys_business_unit_member.organization_id` NULL, so a NULL there means unknown tenancy rather than platform-global, and an org-scoped rule does not grant to it. The sibling recipient widths already read their membership rows this way.

An active business-unit rule that expands to no recipients now warns once per rule per process, naming the rule, the object, the recipient kind, the unit and the organization. That case — a rule whose unit and membership rows were both seeded — is the one combination that still grants nobody, and it is no longer silent.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,7 +137,7 @@ The largest single consumer — **20 of the 109 sites**.
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:165`, `:390` |

### 4. Approvals, reports, attachments, comments, knowledge

Expand Down
265 changes: 228 additions & 37 deletions packages/plugins/plugin-sharing/src/business-unit-graph.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,45 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* BusinessUnitGraphService — org scoping of the unit tree.
* BusinessUnitGraphService — the TWO tenant screens, pinned as a pair.
*
* These pin the ORG-SCOPE behaviour specifically, because it is the exact
* shape that broke approvals in #3807: `orgScope()` AND-composes a strict
* `organization_id = <rule org>` equality, so a unit written with no
* organization at all (a seeded / file-layer / bootstrap row — a seed cannot
* know the org id the runtime mints at boot) matches nothing, the seed check
* fails, and the expansion returns zero members. In approvals that produced a
* dead `department:<id>` approver slot; here it would produce a sharing rule
* that silently grants nobody.
* ## What this file used to say, and why it changed
*
* It is NOT reachable today: every materialized `sys_sharing_rule` row carries
* `organization_id = null` (verified on a live showcase stack), so
* `expandRecipient` passes `null` and `orgScope` is skipped entirely. The
* moment rules start carrying an org — a multi-tenant deployment — a BU
* subtree rule against a seeded unit stops granting, and the symptom is
* "the right people cannot see the record", which is far quieter than a stuck
* approval.
* Until #14547 `orgScope()` AND-composed a strict `organization_id = <rule
* org>` equality onto the UNIT read. A unit written with no organization at
* all (a seeded / file-layer / bootstrap row — a seed cannot know the org id
* the runtime mints at boot) therefore matched nothing, the seed check failed,
* and BOTH widths expanded to zero members. #3807 had already fixed exactly
* that on the approvals side; this file recorded the sharing side's divergence
* as deliberate on the grounds that it was unreachable, because every
* materialized `sys_sharing_rule` row carried `organization_id = null`.
*
* So this file locks BOTH sides down:
* - the reachable paths (null-org rule) keep working, and
* - the divergence from approvals is written down as an executable fact
* rather than a comment, so flipping it is a deliberate edit to a named
* test and never a silent behaviour change.
* It was reachable. #14547 is the external report: an org admin creating a
* rule at runtime gets an org-stamped rule, the seeded unit carries none, and
* the rule is accepted, stays active, materialises zero `sys_record_share`
* rows and logs nothing. The `[divergence]` test that pinned the old posture
* is gone — replaced, not merely flipped, because an assertion that keeps
* passing while the mechanism under it changes is worse than no assertion.
*
* If the platform decides null-org means "env-wide, visible to every org" for
* sharing too — the way `plugin-approvals` and `sys_metadata` already read it —
* the test named `[divergence]` below is the one to flip, and `orgScope` grows
* the same `$or: [{ organization_id }, { organization_id: null }]` predicate.
* ## The pair this file now pins
*
* The fix is ASYMMETRIC and both halves have to be pinned, because each one
* alone is a defect:
*
* - the UNIT screen (`orgScope`) is NULL-INCLUSIVE — the platform's own
* `(organization_id = ? OR organization_id IS NULL)`, the predicate
* `SqlDriver.applyTenantScope` writes and `plugin-approvals` already
* applies to these very rows;
* - the MEMBER screen (`memberScope`) is STRICT. Both member reads used to
* carry no organization predicate at all, and the strict unit screen was
* the only thing holding an org-stamped rule away from that unscoped
* query. Widening the unit screen ALONE turns a silent under-grant into a
* silent CROSS-TENANT OVER-GRANT, since a seeded unit id exists
* identically in every tenant.
*
* So the security half is pinned separately from the functional half below: a
* change that expands the right members while also expanding another
* organization's members satisfies the functional pin completely.
*/

import { describe, it, expect } from 'vitest';
Expand All@@ -40,8 +50,9 @@ interface UnitRow {
parent_business_unit_id?: string | null;
organization_id?: string | null;
active?: boolean;
manager_user_id?: string | null;
}
interface MemberRow { business_unit_id: string; user_id: string }
interface MemberRow { business_unit_id: string; user_id: string; organization_id?: string | null }

/**
* Minimal engine over `sys_business_unit` + `sys_business_unit_member`.
Expand DownExpand Up@@ -168,13 +179,33 @@ describe('BusinessUnitGraphService — the two widths are actually two widths (#
});

it('the narrow width is org-predicated exactly like the wide one', async () => {
// [#14547] Same fixture, new mechanism — and the mechanism is spelled out
// because the ASSERTION did not move. `DIV_MEMBERS` carry no organization,
// so before #14547 this returned `[]` because the strict UNIT screen hid
// the seeded unit, and after it returns `[]` because the strict MEMBER
// screen refuses membership rows of unknown tenancy. An unchanged
// expectation over a changed cause is exactly the kind of pin that stops
// guarding anything, so the two causes are separated below: the unit is
// now visible (`descendants` sees the whole seeded tree), and it is the
// members that are refused.
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, DIV_MEMBERS),
organizationId: 'org_a',
});
// Seeded (null-org) units are not visible to an org-scoped rule — the
// same `[divergence]` posture the wide width holds below.
expect(await g.expandUnitMembers('bu_div')).toEqual([]);
expect((await g.descendants('bu_div')).sort()).toEqual(['bu_dept', 'bu_div', 'bu_office']);
});

it('[#14547] both widths reach org-stamped members of a SEEDED unit tree', async () => {
// The one change that flips the outcome: the membership rows are stamped,
// exactly as a REST/session write stamps them. The units stay seeded.
const members: MemberRow[] = DIV_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_div')).toEqual(['u_div']);
expect((await g.expandUsers('bu_div')).sort()).toEqual(['u_dept', 'u_div', 'u_office']);
});

it('the two widths do NOT share a cache entry for the same unit id', async () => {
Expand DownExpand Up@@ -208,8 +239,14 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
{ id: 'bu_root', organization_id: 'org_a', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_a', active: true },
];
// [#14547] The membership rows are stamped now. They used to be org-less
// here and still expanded, because the member read carried no organization
// predicate whatever — the gap #14547 closed. Units created through the
// API by org_a have memberships created the same way, so this is the
// fixture becoming faithful, not the assertion being relaxed.
const members: MemberRow[] = SEEDED_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(units, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
Expand All@@ -224,17 +261,171 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
expect(await g.expandUsers('bu_root')).toEqual([]);
});

it('[divergence] an org-scoped rule does NOT see an env-wide (null-org) unit — approvals does (#3807)', async () => {
// Same inputs that #3807 fixed on the approvals side. Sharing still reads
// a null-org unit as "belongs to no org, therefore not mine" and grants
// nobody. Unreachable today (rules are null-org), deliberate until the
// platform rules on null-org semantics for AUTHORIZATION paths — widening
// who can SEE a record is not a change to make on a defect that cannot
// currently fire.
it('an org-scoped rule never reaches another org’s MEMBER of a unit it can see', async () => {
// [#14547] The unit is org_a's and visible; the membership row is org_b's.
// The member screen is the only thing that answers here, so this fails if
// `memberScope` is dropped even while every unit-level assertion passes.
const units: UnitRow[] = [{ id: 'bu_root', organization_id: 'org_a', active: true }];
const members: MemberRow[] = [
{ business_unit_id: 'bu_root', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_root', user_id: 'u_b', organization_id: 'org_b' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual(['u_a']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_a']);
});
});

/**
* [#14547] The UNIT screen is null-inclusive — the divergence from
* `plugin-approvals` (#3807) is CLOSED.
*
* The `[divergence]` test that used to live in the block above pinned the
* opposite posture on the grounds that it could not fire. It fired: the
* external report is an org admin creating a rule at runtime against a unit
* the app seeded.
*/
describe('BusinessUnitGraphService — the UNIT screen (#14547)', () => {
const STAMPED_MEMBERS: MemberRow[] = SEEDED_MEMBERS.map((m) => ({
...m,
organization_id: 'org_a',
}));

it('an org-scoped rule DOES see an env-wide (null-org) seeded unit', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_root']);
});

it('the seed check and the subtree walk BOTH admit the seeded rows', async () => {
// `seedIsUsable` and the `descendants` BFS are two separate reads through
// the same screen; a widening applied to one and not the other would still
// answer `[]` for the subtree width.
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.descendants('bu_root')).sort()).toEqual(['bu_child', 'bu_root']);
});

it('`headOf` resolves the manager of a seeded unit too', async () => {
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: null, active: true, manager_user_id: 'u_head' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, []),
organizationId: 'org_a',
});
expect(await g.headOf('bu_root')).toBe('u_head');
});

it('ONLY the NULL arm widened — another org’s unit is still invisible', async () => {
// The control that separates "null-inclusive" from "unscoped". Without it
// a screen that had simply been deleted would pass every assertion above.
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: 'org_b', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_b', active: true },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
expect(await g.descendants('bu_root')).toEqual([]);
expect(await g.headOf('bu_root')).toBeNull();
});

it('an INACTIVE seeded unit still contributes nobody', async () => {
const units: UnitRow[] = SEEDED_UNITS.map((u) =>
u.id === 'bu_root' ? { ...u, active: false } : u,
);
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
});
});

/**
* [#14547] The MEMBER screen is STRICT — the leak the unit widening would
* otherwise have opened.
*
* ⚠️ These are the SECURITY half and they are pinned apart from the functional
* half on purpose: a change that expands the right members while also
* expanding another organization's members passes every assertion in the block
* above.
*/
describe('BusinessUnitGraphService — the MEMBER screen (#14547)', () => {
/**
* One SEEDED unit id with two tenants' memberships hanging off it — the
* shape that exists on every deployment whose org chart came from a seed,
* and the one the widened unit screen makes reachable.
*/
const SHARED_SEED_UNITS: UnitRow[] = [
{ id: 'bu_market', organization_id: null, active: true },
{ id: 'bu_market_west', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
];
const TWO_TENANT_MEMBERS: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_market', user_id: 'u_b', organization_id: 'org_b' },
{ business_unit_id: 'bu_market_west', user_id: 'u_a_west', organization_id: 'org_a' },
{ business_unit_id: 'bu_market_west', user_id: 'u_b_west', organization_id: 'org_b' },
];

it('WIDE — a subtree expansion never crosses into another organization', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
const users = await g.expandUsers('bu_market');
expect(users.sort()).toEqual(['u_a', 'u_a_west']);
expect(users).not.toContain('u_b');
expect(users).not.toContain('u_b_west');
});

it('NARROW — the single-unit expansion does not cross either', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual(['u_a']);
});

it('an org-LESS membership row is NOT a member of an org-scoped rule', async () => {
// Unknown tenancy, not platform-global: `sys_business_unit_member` is not
// organization-stamped by seed replay or by an elevated system write, so a
// NULL here cannot be read the way a NULL on the UNIT row is read. The
// grant fails closed, and `SharingRuleService` warns rather than staying
// silent about it.
const members: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_seeded', organization_id: null },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual([]);
expect(await g.expandUsers('bu_market')).toEqual([]);
});

it('an org-LESS rule is unmoved — both screens stay no-ops', async () => {
// The dominant shape today (declared rules bootstrap org-less). #14547
// must not change what they expand to, in either direction.
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: null,
});
expect((await g.expandUsers('bu_market')).sort()).toEqual([
'u_a', 'u_a_west', 'u_b', 'u_b_west',
]);
});
});
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bu-tenant-screen-relanding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Fix: a sharing rule with a business-unit recipient granted nothing when the unit came from seed data — and tenant-screen the member reads that widening exposes.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own read-side chokepoint (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`, because a NULL organization marks a platform/seeded row every tenant may see. A sharing rule always carries the caller's organization, but a business unit written by seed data carries none — a seed cannot know the id the runtime mints at boot — so the two never matched. The seed check read the unit as "does not exist", both recipient widths (`business_unit` and `unit_and_subordinates`) expanded to zero users, and the rule stayed active having materialised no `sys_record_share` row and logged nothing. `orgScope` now applies the platform's null-inclusive screen, the same predicate `plugin-approvals` already applies to these very rows and `SharingRuleService.adminOrgScope` applies to the rule table.

The member reads are now tenant-screened, which they were not before. Both `expandUnitMembers` and `expandUsers` queried `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either, so the strict unit screen was the only thing keeping an org-stamped rule away from that unscoped query. Widening the unit screen alone would have turned a silent under-grant into a silent cross-tenant over-grant, since a seeded unit id exists identically in every tenant. The member screen is strict rather than null-inclusive on purpose: seed replay and elevated system writes both leave `sys_business_unit_member.organization_id` NULL, so a NULL there means unknown tenancy rather than platform-global, and an org-scoped rule does not grant to it. The sibling recipient widths already read their membership rows this way.

An active business-unit rule that expands to no recipients now warns once per rule per process, naming the rule, the object, the recipient kind, the unit and the organization. That case — a rule whose unit and membership rows were both seeded — is the one combination that still grants nobody, and it is no longer silent.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,7 +137,7 @@ The largest single consumer — **20 of the 109 sites**.
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:165`, `:390` |

### 4. Approvals, reports, attachments, comments, knowledge

Expand Down
265 changes: 228 additions & 37 deletions packages/plugins/plugin-sharing/src/business-unit-graph.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,45 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* BusinessUnitGraphService — org scoping of the unit tree.
* BusinessUnitGraphService — the TWO tenant screens, pinned as a pair.
*
* These pin the ORG-SCOPE behaviour specifically, because it is the exact
* shape that broke approvals in #3807: `orgScope()` AND-composes a strict
* `organization_id = <rule org>` equality, so a unit written with no
* organization at all (a seeded / file-layer / bootstrap row — a seed cannot
* know the org id the runtime mints at boot) matches nothing, the seed check
* fails, and the expansion returns zero members. In approvals that produced a
* dead `department:<id>` approver slot; here it would produce a sharing rule
* that silently grants nobody.
* ## What this file used to say, and why it changed
*
* It is NOT reachable today: every materialized `sys_sharing_rule` row carries
* `organization_id = null` (verified on a live showcase stack), so
* `expandRecipient` passes `null` and `orgScope` is skipped entirely. The
* moment rules start carrying an org — a multi-tenant deployment — a BU
* subtree rule against a seeded unit stops granting, and the symptom is
* "the right people cannot see the record", which is far quieter than a stuck
* approval.
* Until #14547 `orgScope()` AND-composed a strict `organization_id = <rule
* org>` equality onto the UNIT read. A unit written with no organization at
* all (a seeded / file-layer / bootstrap row — a seed cannot know the org id
* the runtime mints at boot) therefore matched nothing, the seed check failed,
* and BOTH widths expanded to zero members. #3807 had already fixed exactly
* that on the approvals side; this file recorded the sharing side's divergence
* as deliberate on the grounds that it was unreachable, because every
* materialized `sys_sharing_rule` row carried `organization_id = null`.
*
* So this file locks BOTH sides down:
* - the reachable paths (null-org rule) keep working, and
* - the divergence from approvals is written down as an executable fact
* rather than a comment, so flipping it is a deliberate edit to a named
* test and never a silent behaviour change.
* It was reachable. #14547 is the external report: an org admin creating a
* rule at runtime gets an org-stamped rule, the seeded unit carries none, and
* the rule is accepted, stays active, materialises zero `sys_record_share`
* rows and logs nothing. The `[divergence]` test that pinned the old posture
* is gone — replaced, not merely flipped, because an assertion that keeps
* passing while the mechanism under it changes is worse than no assertion.
*
* If the platform decides null-org means "env-wide, visible to every org" for
* sharing too — the way `plugin-approvals` and `sys_metadata` already read it —
* the test named `[divergence]` below is the one to flip, and `orgScope` grows
* the same `$or: [{ organization_id }, { organization_id: null }]` predicate.
* ## The pair this file now pins
*
* The fix is ASYMMETRIC and both halves have to be pinned, because each one
* alone is a defect:
*
* - the UNIT screen (`orgScope`) is NULL-INCLUSIVE — the platform's own
* `(organization_id = ? OR organization_id IS NULL)`, the predicate
* `SqlDriver.applyTenantScope` writes and `plugin-approvals` already
* applies to these very rows;
* - the MEMBER screen (`memberScope`) is STRICT. Both member reads used to
* carry no organization predicate at all, and the strict unit screen was
* the only thing holding an org-stamped rule away from that unscoped
* query. Widening the unit screen ALONE turns a silent under-grant into a
* silent CROSS-TENANT OVER-GRANT, since a seeded unit id exists
* identically in every tenant.
*
* So the security half is pinned separately from the functional half below: a
* change that expands the right members while also expanding another
* organization's members satisfies the functional pin completely.
*/

import { describe, it, expect } from 'vitest';
Expand All@@ -40,8 +50,9 @@ interface UnitRow {
parent_business_unit_id?: string | null;
organization_id?: string | null;
active?: boolean;
manager_user_id?: string | null;
}
interface MemberRow { business_unit_id: string; user_id: string }
interface MemberRow { business_unit_id: string; user_id: string; organization_id?: string | null }

/**
* Minimal engine over `sys_business_unit` + `sys_business_unit_member`.
Expand DownExpand Up@@ -168,13 +179,33 @@ describe('BusinessUnitGraphService — the two widths are actually two widths (#
});

it('the narrow width is org-predicated exactly like the wide one', async () => {
// [#14547] Same fixture, new mechanism — and the mechanism is spelled out
// because the ASSERTION did not move. `DIV_MEMBERS` carry no organization,
// so before #14547 this returned `[]` because the strict UNIT screen hid
// the seeded unit, and after it returns `[]` because the strict MEMBER
// screen refuses membership rows of unknown tenancy. An unchanged
// expectation over a changed cause is exactly the kind of pin that stops
// guarding anything, so the two causes are separated below: the unit is
// now visible (`descendants` sees the whole seeded tree), and it is the
// members that are refused.
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, DIV_MEMBERS),
organizationId: 'org_a',
});
// Seeded (null-org) units are not visible to an org-scoped rule — the
// same `[divergence]` posture the wide width holds below.
expect(await g.expandUnitMembers('bu_div')).toEqual([]);
expect((await g.descendants('bu_div')).sort()).toEqual(['bu_dept', 'bu_div', 'bu_office']);
});

it('[#14547] both widths reach org-stamped members of a SEEDED unit tree', async () => {
// The one change that flips the outcome: the membership rows are stamped,
// exactly as a REST/session write stamps them. The units stay seeded.
const members: MemberRow[] = DIV_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_div')).toEqual(['u_div']);
expect((await g.expandUsers('bu_div')).sort()).toEqual(['u_dept', 'u_div', 'u_office']);
});

it('the two widths do NOT share a cache entry for the same unit id', async () => {
Expand DownExpand Up@@ -208,8 +239,14 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
{ id: 'bu_root', organization_id: 'org_a', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_a', active: true },
];
// [#14547] The membership rows are stamped now. They used to be org-less
// here and still expanded, because the member read carried no organization
// predicate whatever — the gap #14547 closed. Units created through the
// API by org_a have memberships created the same way, so this is the
// fixture becoming faithful, not the assertion being relaxed.
const members: MemberRow[] = SEEDED_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(units, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
Expand All@@ -224,17 +261,171 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
expect(await g.expandUsers('bu_root')).toEqual([]);
});

it('[divergence] an org-scoped rule does NOT see an env-wide (null-org) unit — approvals does (#3807)', async () => {
// Same inputs that #3807 fixed on the approvals side. Sharing still reads
// a null-org unit as "belongs to no org, therefore not mine" and grants
// nobody. Unreachable today (rules are null-org), deliberate until the
// platform rules on null-org semantics for AUTHORIZATION paths — widening
// who can SEE a record is not a change to make on a defect that cannot
// currently fire.
it('an org-scoped rule never reaches another org’s MEMBER of a unit it can see', async () => {
// [#14547] The unit is org_a's and visible; the membership row is org_b's.
// The member screen is the only thing that answers here, so this fails if
// `memberScope` is dropped even while every unit-level assertion passes.
const units: UnitRow[] = [{ id: 'bu_root', organization_id: 'org_a', active: true }];
const members: MemberRow[] = [
{ business_unit_id: 'bu_root', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_root', user_id: 'u_b', organization_id: 'org_b' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual(['u_a']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_a']);
});
});

/**
* [#14547] The UNIT screen is null-inclusive — the divergence from
* `plugin-approvals` (#3807) is CLOSED.
*
* The `[divergence]` test that used to live in the block above pinned the
* opposite posture on the grounds that it could not fire. It fired: the
* external report is an org admin creating a rule at runtime against a unit
* the app seeded.
*/
describe('BusinessUnitGraphService — the UNIT screen (#14547)', () => {
const STAMPED_MEMBERS: MemberRow[] = SEEDED_MEMBERS.map((m) => ({
...m,
organization_id: 'org_a',
}));

it('an org-scoped rule DOES see an env-wide (null-org) seeded unit', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_root']);
});

it('the seed check and the subtree walk BOTH admit the seeded rows', async () => {
// `seedIsUsable` and the `descendants` BFS are two separate reads through
// the same screen; a widening applied to one and not the other would still
// answer `[]` for the subtree width.
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.descendants('bu_root')).sort()).toEqual(['bu_child', 'bu_root']);
});

it('`headOf` resolves the manager of a seeded unit too', async () => {
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: null, active: true, manager_user_id: 'u_head' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, []),
organizationId: 'org_a',
});
expect(await g.headOf('bu_root')).toBe('u_head');
});

it('ONLY the NULL arm widened — another org’s unit is still invisible', async () => {
// The control that separates "null-inclusive" from "unscoped". Without it
// a screen that had simply been deleted would pass every assertion above.
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: 'org_b', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_b', active: true },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
expect(await g.descendants('bu_root')).toEqual([]);
expect(await g.headOf('bu_root')).toBeNull();
});

it('an INACTIVE seeded unit still contributes nobody', async () => {
const units: UnitRow[] = SEEDED_UNITS.map((u) =>
u.id === 'bu_root' ? { ...u, active: false } : u,
);
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
});
});

/**
* [#14547] The MEMBER screen is STRICT — the leak the unit widening would
* otherwise have opened.
*
* ⚠️ These are the SECURITY half and they are pinned apart from the functional
* half on purpose: a change that expands the right members while also
* expanding another organization's members passes every assertion in the block
* above.
*/
describe('BusinessUnitGraphService — the MEMBER screen (#14547)', () => {
/**
* One SEEDED unit id with two tenants' memberships hanging off it — the
* shape that exists on every deployment whose org chart came from a seed,
* and the one the widened unit screen makes reachable.
*/
const SHARED_SEED_UNITS: UnitRow[] = [
{ id: 'bu_market', organization_id: null, active: true },
{ id: 'bu_market_west', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
];
const TWO_TENANT_MEMBERS: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_market', user_id: 'u_b', organization_id: 'org_b' },
{ business_unit_id: 'bu_market_west', user_id: 'u_a_west', organization_id: 'org_a' },
{ business_unit_id: 'bu_market_west', user_id: 'u_b_west', organization_id: 'org_b' },
];

it('WIDE — a subtree expansion never crosses into another organization', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
const users = await g.expandUsers('bu_market');
expect(users.sort()).toEqual(['u_a', 'u_a_west']);
expect(users).not.toContain('u_b');
expect(users).not.toContain('u_b_west');
});

it('NARROW — the single-unit expansion does not cross either', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual(['u_a']);
});

it('an org-LESS membership row is NOT a member of an org-scoped rule', async () => {
// Unknown tenancy, not platform-global: `sys_business_unit_member` is not
// organization-stamped by seed replay or by an elevated system write, so a
// NULL here cannot be read the way a NULL on the UNIT row is read. The
// grant fails closed, and `SharingRuleService` warns rather than staying
// silent about it.
const members: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_seeded', organization_id: null },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual([]);
expect(await g.expandUsers('bu_market')).toEqual([]);
});

it('an org-LESS rule is unmoved — both screens stay no-ops', async () => {
// The dominant shape today (declared rules bootstrap org-less). #14547
// must not change what they expand to, in either direction.
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: null,
});
expect((await g.expandUsers('bu_market')).sort()).toEqual([
'u_a', 'u_a_west', 'u_b', 'u_b_west',
]);
});
});
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bu-tenant-screen-relanding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Fix: a sharing rule with a business-unit recipient granted nothing when the unit came from seed data — and tenant-screen the member reads that widening exposes.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own read-side chokepoint (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`, because a NULL organization marks a platform/seeded row every tenant may see. A sharing rule always carries the caller's organization, but a business unit written by seed data carries none — a seed cannot know the id the runtime mints at boot — so the two never matched. The seed check read the unit as "does not exist", both recipient widths (`business_unit` and `unit_and_subordinates`) expanded to zero users, and the rule stayed active having materialised no `sys_record_share` row and logged nothing. `orgScope` now applies the platform's null-inclusive screen, the same predicate `plugin-approvals` already applies to these very rows and `SharingRuleService.adminOrgScope` applies to the rule table.

The member reads are now tenant-screened, which they were not before. Both `expandUnitMembers` and `expandUsers` queried `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either, so the strict unit screen was the only thing keeping an org-stamped rule away from that unscoped query. Widening the unit screen alone would have turned a silent under-grant into a silent cross-tenant over-grant, since a seeded unit id exists identically in every tenant. The member screen is strict rather than null-inclusive on purpose: seed replay and elevated system writes both leave `sys_business_unit_member.organization_id` NULL, so a NULL there means unknown tenancy rather than platform-global, and an org-scoped rule does not grant to it. The sibling recipient widths already read their membership rows this way.

An active business-unit rule that expands to no recipients now warns once per rule per process, naming the rule, the object, the recipient kind, the unit and the organization. That case — a rule whose unit and membership rows were both seeded — is the one combination that still grants nobody, and it is no longer silent.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,7 +137,7 @@ The largest single consumer — **20 of the 109 sites**.
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:165`, `:390` |

### 4. Approvals, reports, attachments, comments, knowledge

Expand Down
265 changes: 228 additions & 37 deletions packages/plugins/plugin-sharing/src/business-unit-graph.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,45 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* BusinessUnitGraphService — org scoping of the unit tree.
* BusinessUnitGraphService — the TWO tenant screens, pinned as a pair.
*
* These pin the ORG-SCOPE behaviour specifically, because it is the exact
* shape that broke approvals in #3807: `orgScope()` AND-composes a strict
* `organization_id = <rule org>` equality, so a unit written with no
* organization at all (a seeded / file-layer / bootstrap row — a seed cannot
* know the org id the runtime mints at boot) matches nothing, the seed check
* fails, and the expansion returns zero members. In approvals that produced a
* dead `department:<id>` approver slot; here it would produce a sharing rule
* that silently grants nobody.
* ## What this file used to say, and why it changed
*
* It is NOT reachable today: every materialized `sys_sharing_rule` row carries
* `organization_id = null` (verified on a live showcase stack), so
* `expandRecipient` passes `null` and `orgScope` is skipped entirely. The
* moment rules start carrying an org — a multi-tenant deployment — a BU
* subtree rule against a seeded unit stops granting, and the symptom is
* "the right people cannot see the record", which is far quieter than a stuck
* approval.
* Until #14547 `orgScope()` AND-composed a strict `organization_id = <rule
* org>` equality onto the UNIT read. A unit written with no organization at
* all (a seeded / file-layer / bootstrap row — a seed cannot know the org id
* the runtime mints at boot) therefore matched nothing, the seed check failed,
* and BOTH widths expanded to zero members. #3807 had already fixed exactly
* that on the approvals side; this file recorded the sharing side's divergence
* as deliberate on the grounds that it was unreachable, because every
* materialized `sys_sharing_rule` row carried `organization_id = null`.
*
* So this file locks BOTH sides down:
* - the reachable paths (null-org rule) keep working, and
* - the divergence from approvals is written down as an executable fact
* rather than a comment, so flipping it is a deliberate edit to a named
* test and never a silent behaviour change.
* It was reachable. #14547 is the external report: an org admin creating a
* rule at runtime gets an org-stamped rule, the seeded unit carries none, and
* the rule is accepted, stays active, materialises zero `sys_record_share`
* rows and logs nothing. The `[divergence]` test that pinned the old posture
* is gone — replaced, not merely flipped, because an assertion that keeps
* passing while the mechanism under it changes is worse than no assertion.
*
* If the platform decides null-org means "env-wide, visible to every org" for
* sharing too — the way `plugin-approvals` and `sys_metadata` already read it —
* the test named `[divergence]` below is the one to flip, and `orgScope` grows
* the same `$or: [{ organization_id }, { organization_id: null }]` predicate.
* ## The pair this file now pins
*
* The fix is ASYMMETRIC and both halves have to be pinned, because each one
* alone is a defect:
*
* - the UNIT screen (`orgScope`) is NULL-INCLUSIVE — the platform's own
* `(organization_id = ? OR organization_id IS NULL)`, the predicate
* `SqlDriver.applyTenantScope` writes and `plugin-approvals` already
* applies to these very rows;
* - the MEMBER screen (`memberScope`) is STRICT. Both member reads used to
* carry no organization predicate at all, and the strict unit screen was
* the only thing holding an org-stamped rule away from that unscoped
* query. Widening the unit screen ALONE turns a silent under-grant into a
* silent CROSS-TENANT OVER-GRANT, since a seeded unit id exists
* identically in every tenant.
*
* So the security half is pinned separately from the functional half below: a
* change that expands the right members while also expanding another
* organization's members satisfies the functional pin completely.
*/

import { describe, it, expect } from 'vitest';
Expand All@@ -40,8 +50,9 @@ interface UnitRow {
parent_business_unit_id?: string | null;
organization_id?: string | null;
active?: boolean;
manager_user_id?: string | null;
}
interface MemberRow { business_unit_id: string; user_id: string }
interface MemberRow { business_unit_id: string; user_id: string; organization_id?: string | null }

/**
* Minimal engine over `sys_business_unit` + `sys_business_unit_member`.
Expand DownExpand Up@@ -168,13 +179,33 @@ describe('BusinessUnitGraphService — the two widths are actually two widths (#
});

it('the narrow width is org-predicated exactly like the wide one', async () => {
// [#14547] Same fixture, new mechanism — and the mechanism is spelled out
// because the ASSERTION did not move. `DIV_MEMBERS` carry no organization,
// so before #14547 this returned `[]` because the strict UNIT screen hid
// the seeded unit, and after it returns `[]` because the strict MEMBER
// screen refuses membership rows of unknown tenancy. An unchanged
// expectation over a changed cause is exactly the kind of pin that stops
// guarding anything, so the two causes are separated below: the unit is
// now visible (`descendants` sees the whole seeded tree), and it is the
// members that are refused.
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, DIV_MEMBERS),
organizationId: 'org_a',
});
// Seeded (null-org) units are not visible to an org-scoped rule — the
// same `[divergence]` posture the wide width holds below.
expect(await g.expandUnitMembers('bu_div')).toEqual([]);
expect((await g.descendants('bu_div')).sort()).toEqual(['bu_dept', 'bu_div', 'bu_office']);
});

it('[#14547] both widths reach org-stamped members of a SEEDED unit tree', async () => {
// The one change that flips the outcome: the membership rows are stamped,
// exactly as a REST/session write stamps them. The units stay seeded.
const members: MemberRow[] = DIV_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_div')).toEqual(['u_div']);
expect((await g.expandUsers('bu_div')).sort()).toEqual(['u_dept', 'u_div', 'u_office']);
});

it('the two widths do NOT share a cache entry for the same unit id', async () => {
Expand DownExpand Up@@ -208,8 +239,14 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
{ id: 'bu_root', organization_id: 'org_a', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_a', active: true },
];
// [#14547] The membership rows are stamped now. They used to be org-less
// here and still expanded, because the member read carried no organization
// predicate whatever — the gap #14547 closed. Units created through the
// API by org_a have memberships created the same way, so this is the
// fixture becoming faithful, not the assertion being relaxed.
const members: MemberRow[] = SEEDED_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(units, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
Expand All@@ -224,17 +261,171 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
expect(await g.expandUsers('bu_root')).toEqual([]);
});

it('[divergence] an org-scoped rule does NOT see an env-wide (null-org) unit — approvals does (#3807)', async () => {
// Same inputs that #3807 fixed on the approvals side. Sharing still reads
// a null-org unit as "belongs to no org, therefore not mine" and grants
// nobody. Unreachable today (rules are null-org), deliberate until the
// platform rules on null-org semantics for AUTHORIZATION paths — widening
// who can SEE a record is not a change to make on a defect that cannot
// currently fire.
it('an org-scoped rule never reaches another org’s MEMBER of a unit it can see', async () => {
// [#14547] The unit is org_a's and visible; the membership row is org_b's.
// The member screen is the only thing that answers here, so this fails if
// `memberScope` is dropped even while every unit-level assertion passes.
const units: UnitRow[] = [{ id: 'bu_root', organization_id: 'org_a', active: true }];
const members: MemberRow[] = [
{ business_unit_id: 'bu_root', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_root', user_id: 'u_b', organization_id: 'org_b' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual(['u_a']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_a']);
});
});

/**
* [#14547] The UNIT screen is null-inclusive — the divergence from
* `plugin-approvals` (#3807) is CLOSED.
*
* The `[divergence]` test that used to live in the block above pinned the
* opposite posture on the grounds that it could not fire. It fired: the
* external report is an org admin creating a rule at runtime against a unit
* the app seeded.
*/
describe('BusinessUnitGraphService — the UNIT screen (#14547)', () => {
const STAMPED_MEMBERS: MemberRow[] = SEEDED_MEMBERS.map((m) => ({
...m,
organization_id: 'org_a',
}));

it('an org-scoped rule DOES see an env-wide (null-org) seeded unit', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_root']);
});

it('the seed check and the subtree walk BOTH admit the seeded rows', async () => {
// `seedIsUsable` and the `descendants` BFS are two separate reads through
// the same screen; a widening applied to one and not the other would still
// answer `[]` for the subtree width.
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.descendants('bu_root')).sort()).toEqual(['bu_child', 'bu_root']);
});

it('`headOf` resolves the manager of a seeded unit too', async () => {
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: null, active: true, manager_user_id: 'u_head' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, []),
organizationId: 'org_a',
});
expect(await g.headOf('bu_root')).toBe('u_head');
});

it('ONLY the NULL arm widened — another org’s unit is still invisible', async () => {
// The control that separates "null-inclusive" from "unscoped". Without it
// a screen that had simply been deleted would pass every assertion above.
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: 'org_b', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_b', active: true },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
expect(await g.descendants('bu_root')).toEqual([]);
expect(await g.headOf('bu_root')).toBeNull();
});

it('an INACTIVE seeded unit still contributes nobody', async () => {
const units: UnitRow[] = SEEDED_UNITS.map((u) =>
u.id === 'bu_root' ? { ...u, active: false } : u,
);
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
});
});

/**
* [#14547] The MEMBER screen is STRICT — the leak the unit widening would
* otherwise have opened.
*
* ⚠️ These are the SECURITY half and they are pinned apart from the functional
* half on purpose: a change that expands the right members while also
* expanding another organization's members passes every assertion in the block
* above.
*/
describe('BusinessUnitGraphService — the MEMBER screen (#14547)', () => {
/**
* One SEEDED unit id with two tenants' memberships hanging off it — the
* shape that exists on every deployment whose org chart came from a seed,
* and the one the widened unit screen makes reachable.
*/
const SHARED_SEED_UNITS: UnitRow[] = [
{ id: 'bu_market', organization_id: null, active: true },
{ id: 'bu_market_west', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
];
const TWO_TENANT_MEMBERS: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_market', user_id: 'u_b', organization_id: 'org_b' },
{ business_unit_id: 'bu_market_west', user_id: 'u_a_west', organization_id: 'org_a' },
{ business_unit_id: 'bu_market_west', user_id: 'u_b_west', organization_id: 'org_b' },
];

it('WIDE — a subtree expansion never crosses into another organization', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
const users = await g.expandUsers('bu_market');
expect(users.sort()).toEqual(['u_a', 'u_a_west']);
expect(users).not.toContain('u_b');
expect(users).not.toContain('u_b_west');
});

it('NARROW — the single-unit expansion does not cross either', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual(['u_a']);
});

it('an org-LESS membership row is NOT a member of an org-scoped rule', async () => {
// Unknown tenancy, not platform-global: `sys_business_unit_member` is not
// organization-stamped by seed replay or by an elevated system write, so a
// NULL here cannot be read the way a NULL on the UNIT row is read. The
// grant fails closed, and `SharingRuleService` warns rather than staying
// silent about it.
const members: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_seeded', organization_id: null },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual([]);
expect(await g.expandUsers('bu_market')).toEqual([]);
});

it('an org-LESS rule is unmoved — both screens stay no-ops', async () => {
// The dominant shape today (declared rules bootstrap org-less). #14547
// must not change what they expand to, in either direction.
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: null,
});
expect((await g.expandUsers('bu_market')).sort()).toEqual([
'u_a', 'u_a_west', 'u_b', 'u_b_west',
]);
});
});
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bu-tenant-screen-relanding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Fix: a sharing rule with a business-unit recipient granted nothing when the unit came from seed data — and tenant-screen the member reads that widening exposes.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own read-side chokepoint (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`, because a NULL organization marks a platform/seeded row every tenant may see. A sharing rule always carries the caller's organization, but a business unit written by seed data carries none — a seed cannot know the id the runtime mints at boot — so the two never matched. The seed check read the unit as "does not exist", both recipient widths (`business_unit` and `unit_and_subordinates`) expanded to zero users, and the rule stayed active having materialised no `sys_record_share` row and logged nothing. `orgScope` now applies the platform's null-inclusive screen, the same predicate `plugin-approvals` already applies to these very rows and `SharingRuleService.adminOrgScope` applies to the rule table.

The member reads are now tenant-screened, which they were not before. Both `expandUnitMembers` and `expandUsers` queried `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either, so the strict unit screen was the only thing keeping an org-stamped rule away from that unscoped query. Widening the unit screen alone would have turned a silent under-grant into a silent cross-tenant over-grant, since a seeded unit id exists identically in every tenant. The member screen is strict rather than null-inclusive on purpose: seed replay and elevated system writes both leave `sys_business_unit_member.organization_id` NULL, so a NULL there means unknown tenancy rather than platform-global, and an org-scoped rule does not grant to it. The sibling recipient widths already read their membership rows this way.

An active business-unit rule that expands to no recipients now warns once per rule per process, naming the rule, the object, the recipient kind, the unit and the organization. That case — a rule whose unit and membership rows were both seeded — is the one combination that still grants nobody, and it is no longer silent.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,7 +137,7 @@ The largest single consumer — **20 of the 109 sites**.
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:165`, `:390` |

### 4. Approvals, reports, attachments, comments, knowledge

Expand Down
265 changes: 228 additions & 37 deletions packages/plugins/plugin-sharing/src/business-unit-graph.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,45 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* BusinessUnitGraphService — org scoping of the unit tree.
* BusinessUnitGraphService — the TWO tenant screens, pinned as a pair.
*
* These pin the ORG-SCOPE behaviour specifically, because it is the exact
* shape that broke approvals in #3807: `orgScope()` AND-composes a strict
* `organization_id = <rule org>` equality, so a unit written with no
* organization at all (a seeded / file-layer / bootstrap row — a seed cannot
* know the org id the runtime mints at boot) matches nothing, the seed check
* fails, and the expansion returns zero members. In approvals that produced a
* dead `department:<id>` approver slot; here it would produce a sharing rule
* that silently grants nobody.
* ## What this file used to say, and why it changed
*
* It is NOT reachable today: every materialized `sys_sharing_rule` row carries
* `organization_id = null` (verified on a live showcase stack), so
* `expandRecipient` passes `null` and `orgScope` is skipped entirely. The
* moment rules start carrying an org — a multi-tenant deployment — a BU
* subtree rule against a seeded unit stops granting, and the symptom is
* "the right people cannot see the record", which is far quieter than a stuck
* approval.
* Until #14547 `orgScope()` AND-composed a strict `organization_id = <rule
* org>` equality onto the UNIT read. A unit written with no organization at
* all (a seeded / file-layer / bootstrap row — a seed cannot know the org id
* the runtime mints at boot) therefore matched nothing, the seed check failed,
* and BOTH widths expanded to zero members. #3807 had already fixed exactly
* that on the approvals side; this file recorded the sharing side's divergence
* as deliberate on the grounds that it was unreachable, because every
* materialized `sys_sharing_rule` row carried `organization_id = null`.
*
* So this file locks BOTH sides down:
* - the reachable paths (null-org rule) keep working, and
* - the divergence from approvals is written down as an executable fact
* rather than a comment, so flipping it is a deliberate edit to a named
* test and never a silent behaviour change.
* It was reachable. #14547 is the external report: an org admin creating a
* rule at runtime gets an org-stamped rule, the seeded unit carries none, and
* the rule is accepted, stays active, materialises zero `sys_record_share`
* rows and logs nothing. The `[divergence]` test that pinned the old posture
* is gone — replaced, not merely flipped, because an assertion that keeps
* passing while the mechanism under it changes is worse than no assertion.
*
* If the platform decides null-org means "env-wide, visible to every org" for
* sharing too — the way `plugin-approvals` and `sys_metadata` already read it —
* the test named `[divergence]` below is the one to flip, and `orgScope` grows
* the same `$or: [{ organization_id }, { organization_id: null }]` predicate.
* ## The pair this file now pins
*
* The fix is ASYMMETRIC and both halves have to be pinned, because each one
* alone is a defect:
*
* - the UNIT screen (`orgScope`) is NULL-INCLUSIVE — the platform's own
* `(organization_id = ? OR organization_id IS NULL)`, the predicate
* `SqlDriver.applyTenantScope` writes and `plugin-approvals` already
* applies to these very rows;
* - the MEMBER screen (`memberScope`) is STRICT. Both member reads used to
* carry no organization predicate at all, and the strict unit screen was
* the only thing holding an org-stamped rule away from that unscoped
* query. Widening the unit screen ALONE turns a silent under-grant into a
* silent CROSS-TENANT OVER-GRANT, since a seeded unit id exists
* identically in every tenant.
*
* So the security half is pinned separately from the functional half below: a
* change that expands the right members while also expanding another
* organization's members satisfies the functional pin completely.
*/

import { describe, it, expect } from 'vitest';
Expand All@@ -40,8 +50,9 @@ interface UnitRow {
parent_business_unit_id?: string | null;
organization_id?: string | null;
active?: boolean;
manager_user_id?: string | null;
}
interface MemberRow { business_unit_id: string; user_id: string }
interface MemberRow { business_unit_id: string; user_id: string; organization_id?: string | null }

/**
* Minimal engine over `sys_business_unit` + `sys_business_unit_member`.
Expand DownExpand Up@@ -168,13 +179,33 @@ describe('BusinessUnitGraphService — the two widths are actually two widths (#
});

it('the narrow width is org-predicated exactly like the wide one', async () => {
// [#14547] Same fixture, new mechanism — and the mechanism is spelled out
// because the ASSERTION did not move. `DIV_MEMBERS` carry no organization,
// so before #14547 this returned `[]` because the strict UNIT screen hid
// the seeded unit, and after it returns `[]` because the strict MEMBER
// screen refuses membership rows of unknown tenancy. An unchanged
// expectation over a changed cause is exactly the kind of pin that stops
// guarding anything, so the two causes are separated below: the unit is
// now visible (`descendants` sees the whole seeded tree), and it is the
// members that are refused.
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, DIV_MEMBERS),
organizationId: 'org_a',
});
// Seeded (null-org) units are not visible to an org-scoped rule — the
// same `[divergence]` posture the wide width holds below.
expect(await g.expandUnitMembers('bu_div')).toEqual([]);
expect((await g.descendants('bu_div')).sort()).toEqual(['bu_dept', 'bu_div', 'bu_office']);
});

it('[#14547] both widths reach org-stamped members of a SEEDED unit tree', async () => {
// The one change that flips the outcome: the membership rows are stamped,
// exactly as a REST/session write stamps them. The units stay seeded.
const members: MemberRow[] = DIV_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_div')).toEqual(['u_div']);
expect((await g.expandUsers('bu_div')).sort()).toEqual(['u_dept', 'u_div', 'u_office']);
});

it('the two widths do NOT share a cache entry for the same unit id', async () => {
Expand DownExpand Up@@ -208,8 +239,14 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
{ id: 'bu_root', organization_id: 'org_a', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_a', active: true },
];
// [#14547] The membership rows are stamped now. They used to be org-less
// here and still expanded, because the member read carried no organization
// predicate whatever — the gap #14547 closed. Units created through the
// API by org_a have memberships created the same way, so this is the
// fixture becoming faithful, not the assertion being relaxed.
const members: MemberRow[] = SEEDED_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(units, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
Expand All@@ -224,17 +261,171 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
expect(await g.expandUsers('bu_root')).toEqual([]);
});

it('[divergence] an org-scoped rule does NOT see an env-wide (null-org) unit — approvals does (#3807)', async () => {
// Same inputs that #3807 fixed on the approvals side. Sharing still reads
// a null-org unit as "belongs to no org, therefore not mine" and grants
// nobody. Unreachable today (rules are null-org), deliberate until the
// platform rules on null-org semantics for AUTHORIZATION paths — widening
// who can SEE a record is not a change to make on a defect that cannot
// currently fire.
it('an org-scoped rule never reaches another org’s MEMBER of a unit it can see', async () => {
// [#14547] The unit is org_a's and visible; the membership row is org_b's.
// The member screen is the only thing that answers here, so this fails if
// `memberScope` is dropped even while every unit-level assertion passes.
const units: UnitRow[] = [{ id: 'bu_root', organization_id: 'org_a', active: true }];
const members: MemberRow[] = [
{ business_unit_id: 'bu_root', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_root', user_id: 'u_b', organization_id: 'org_b' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual(['u_a']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_a']);
});
});

/**
* [#14547] The UNIT screen is null-inclusive — the divergence from
* `plugin-approvals` (#3807) is CLOSED.
*
* The `[divergence]` test that used to live in the block above pinned the
* opposite posture on the grounds that it could not fire. It fired: the
* external report is an org admin creating a rule at runtime against a unit
* the app seeded.
*/
describe('BusinessUnitGraphService — the UNIT screen (#14547)', () => {
const STAMPED_MEMBERS: MemberRow[] = SEEDED_MEMBERS.map((m) => ({
...m,
organization_id: 'org_a',
}));

it('an org-scoped rule DOES see an env-wide (null-org) seeded unit', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_root']);
});

it('the seed check and the subtree walk BOTH admit the seeded rows', async () => {
// `seedIsUsable` and the `descendants` BFS are two separate reads through
// the same screen; a widening applied to one and not the other would still
// answer `[]` for the subtree width.
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.descendants('bu_root')).sort()).toEqual(['bu_child', 'bu_root']);
});

it('`headOf` resolves the manager of a seeded unit too', async () => {
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: null, active: true, manager_user_id: 'u_head' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, []),
organizationId: 'org_a',
});
expect(await g.headOf('bu_root')).toBe('u_head');
});

it('ONLY the NULL arm widened — another org’s unit is still invisible', async () => {
// The control that separates "null-inclusive" from "unscoped". Without it
// a screen that had simply been deleted would pass every assertion above.
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: 'org_b', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_b', active: true },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
expect(await g.descendants('bu_root')).toEqual([]);
expect(await g.headOf('bu_root')).toBeNull();
});

it('an INACTIVE seeded unit still contributes nobody', async () => {
const units: UnitRow[] = SEEDED_UNITS.map((u) =>
u.id === 'bu_root' ? { ...u, active: false } : u,
);
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
});
});

/**
* [#14547] The MEMBER screen is STRICT — the leak the unit widening would
* otherwise have opened.
*
* ⚠️ These are the SECURITY half and they are pinned apart from the functional
* half on purpose: a change that expands the right members while also
* expanding another organization's members passes every assertion in the block
* above.
*/
describe('BusinessUnitGraphService — the MEMBER screen (#14547)', () => {
/**
* One SEEDED unit id with two tenants' memberships hanging off it — the
* shape that exists on every deployment whose org chart came from a seed,
* and the one the widened unit screen makes reachable.
*/
const SHARED_SEED_UNITS: UnitRow[] = [
{ id: 'bu_market', organization_id: null, active: true },
{ id: 'bu_market_west', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
];
const TWO_TENANT_MEMBERS: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_market', user_id: 'u_b', organization_id: 'org_b' },
{ business_unit_id: 'bu_market_west', user_id: 'u_a_west', organization_id: 'org_a' },
{ business_unit_id: 'bu_market_west', user_id: 'u_b_west', organization_id: 'org_b' },
];

it('WIDE — a subtree expansion never crosses into another organization', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
const users = await g.expandUsers('bu_market');
expect(users.sort()).toEqual(['u_a', 'u_a_west']);
expect(users).not.toContain('u_b');
expect(users).not.toContain('u_b_west');
});

it('NARROW — the single-unit expansion does not cross either', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual(['u_a']);
});

it('an org-LESS membership row is NOT a member of an org-scoped rule', async () => {
// Unknown tenancy, not platform-global: `sys_business_unit_member` is not
// organization-stamped by seed replay or by an elevated system write, so a
// NULL here cannot be read the way a NULL on the UNIT row is read. The
// grant fails closed, and `SharingRuleService` warns rather than staying
// silent about it.
const members: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_seeded', organization_id: null },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual([]);
expect(await g.expandUsers('bu_market')).toEqual([]);
});

it('an org-LESS rule is unmoved — both screens stay no-ops', async () => {
// The dominant shape today (declared rules bootstrap org-less). #14547
// must not change what they expand to, in either direction.
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: null,
});
expect((await g.expandUsers('bu_market')).sort()).toEqual([
'u_a', 'u_a_west', 'u_b', 'u_b_west',
]);
});
});
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bu-tenant-screen-relanding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Fix: a sharing rule with a business-unit recipient granted nothing when the unit came from seed data — and tenant-screen the member reads that widening exposes.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own read-side chokepoint (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`, because a NULL organization marks a platform/seeded row every tenant may see. A sharing rule always carries the caller's organization, but a business unit written by seed data carries none — a seed cannot know the id the runtime mints at boot — so the two never matched. The seed check read the unit as "does not exist", both recipient widths (`business_unit` and `unit_and_subordinates`) expanded to zero users, and the rule stayed active having materialised no `sys_record_share` row and logged nothing. `orgScope` now applies the platform's null-inclusive screen, the same predicate `plugin-approvals` already applies to these very rows and `SharingRuleService.adminOrgScope` applies to the rule table.

The member reads are now tenant-screened, which they were not before. Both `expandUnitMembers` and `expandUsers` queried `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either, so the strict unit screen was the only thing keeping an org-stamped rule away from that unscoped query. Widening the unit screen alone would have turned a silent under-grant into a silent cross-tenant over-grant, since a seeded unit id exists identically in every tenant. The member screen is strict rather than null-inclusive on purpose: seed replay and elevated system writes both leave `sys_business_unit_member.organization_id` NULL, so a NULL there means unknown tenancy rather than platform-global, and an org-scoped rule does not grant to it. The sibling recipient widths already read their membership rows this way.

An active business-unit rule that expands to no recipients now warns once per rule per process, naming the rule, the object, the recipient kind, the unit and the organization. That case — a rule whose unit and membership rows were both seeded — is the one combination that still grants nobody, and it is no longer silent.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,7 +137,7 @@ The largest single consumer — **20 of the 109 sites**.
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:165`, `:390` |

### 4. Approvals, reports, attachments, comments, knowledge

Expand Down
265 changes: 228 additions & 37 deletions packages/plugins/plugin-sharing/src/business-unit-graph.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,45 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* BusinessUnitGraphService — org scoping of the unit tree.
* BusinessUnitGraphService — the TWO tenant screens, pinned as a pair.
*
* These pin the ORG-SCOPE behaviour specifically, because it is the exact
* shape that broke approvals in #3807: `orgScope()` AND-composes a strict
* `organization_id = <rule org>` equality, so a unit written with no
* organization at all (a seeded / file-layer / bootstrap row — a seed cannot
* know the org id the runtime mints at boot) matches nothing, the seed check
* fails, and the expansion returns zero members. In approvals that produced a
* dead `department:<id>` approver slot; here it would produce a sharing rule
* that silently grants nobody.
* ## What this file used to say, and why it changed
*
* It is NOT reachable today: every materialized `sys_sharing_rule` row carries
* `organization_id = null` (verified on a live showcase stack), so
* `expandRecipient` passes `null` and `orgScope` is skipped entirely. The
* moment rules start carrying an org — a multi-tenant deployment — a BU
* subtree rule against a seeded unit stops granting, and the symptom is
* "the right people cannot see the record", which is far quieter than a stuck
* approval.
* Until #14547 `orgScope()` AND-composed a strict `organization_id = <rule
* org>` equality onto the UNIT read. A unit written with no organization at
* all (a seeded / file-layer / bootstrap row — a seed cannot know the org id
* the runtime mints at boot) therefore matched nothing, the seed check failed,
* and BOTH widths expanded to zero members. #3807 had already fixed exactly
* that on the approvals side; this file recorded the sharing side's divergence
* as deliberate on the grounds that it was unreachable, because every
* materialized `sys_sharing_rule` row carried `organization_id = null`.
*
* So this file locks BOTH sides down:
* - the reachable paths (null-org rule) keep working, and
* - the divergence from approvals is written down as an executable fact
* rather than a comment, so flipping it is a deliberate edit to a named
* test and never a silent behaviour change.
* It was reachable. #14547 is the external report: an org admin creating a
* rule at runtime gets an org-stamped rule, the seeded unit carries none, and
* the rule is accepted, stays active, materialises zero `sys_record_share`
* rows and logs nothing. The `[divergence]` test that pinned the old posture
* is gone — replaced, not merely flipped, because an assertion that keeps
* passing while the mechanism under it changes is worse than no assertion.
*
* If the platform decides null-org means "env-wide, visible to every org" for
* sharing too — the way `plugin-approvals` and `sys_metadata` already read it —
* the test named `[divergence]` below is the one to flip, and `orgScope` grows
* the same `$or: [{ organization_id }, { organization_id: null }]` predicate.
* ## The pair this file now pins
*
* The fix is ASYMMETRIC and both halves have to be pinned, because each one
* alone is a defect:
*
* - the UNIT screen (`orgScope`) is NULL-INCLUSIVE — the platform's own
* `(organization_id = ? OR organization_id IS NULL)`, the predicate
* `SqlDriver.applyTenantScope` writes and `plugin-approvals` already
* applies to these very rows;
* - the MEMBER screen (`memberScope`) is STRICT. Both member reads used to
* carry no organization predicate at all, and the strict unit screen was
* the only thing holding an org-stamped rule away from that unscoped
* query. Widening the unit screen ALONE turns a silent under-grant into a
* silent CROSS-TENANT OVER-GRANT, since a seeded unit id exists
* identically in every tenant.
*
* So the security half is pinned separately from the functional half below: a
* change that expands the right members while also expanding another
* organization's members satisfies the functional pin completely.
*/

import { describe, it, expect } from 'vitest';
Expand All@@ -40,8 +50,9 @@ interface UnitRow {
parent_business_unit_id?: string | null;
organization_id?: string | null;
active?: boolean;
manager_user_id?: string | null;
}
interface MemberRow { business_unit_id: string; user_id: string }
interface MemberRow { business_unit_id: string; user_id: string; organization_id?: string | null }

/**
* Minimal engine over `sys_business_unit` + `sys_business_unit_member`.
Expand DownExpand Up@@ -168,13 +179,33 @@ describe('BusinessUnitGraphService — the two widths are actually two widths (#
});

it('the narrow width is org-predicated exactly like the wide one', async () => {
// [#14547] Same fixture, new mechanism — and the mechanism is spelled out
// because the ASSERTION did not move. `DIV_MEMBERS` carry no organization,
// so before #14547 this returned `[]` because the strict UNIT screen hid
// the seeded unit, and after it returns `[]` because the strict MEMBER
// screen refuses membership rows of unknown tenancy. An unchanged
// expectation over a changed cause is exactly the kind of pin that stops
// guarding anything, so the two causes are separated below: the unit is
// now visible (`descendants` sees the whole seeded tree), and it is the
// members that are refused.
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, DIV_MEMBERS),
organizationId: 'org_a',
});
// Seeded (null-org) units are not visible to an org-scoped rule — the
// same `[divergence]` posture the wide width holds below.
expect(await g.expandUnitMembers('bu_div')).toEqual([]);
expect((await g.descendants('bu_div')).sort()).toEqual(['bu_dept', 'bu_div', 'bu_office']);
});

it('[#14547] both widths reach org-stamped members of a SEEDED unit tree', async () => {
// The one change that flips the outcome: the membership rows are stamped,
// exactly as a REST/session write stamps them. The units stay seeded.
const members: MemberRow[] = DIV_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_div')).toEqual(['u_div']);
expect((await g.expandUsers('bu_div')).sort()).toEqual(['u_dept', 'u_div', 'u_office']);
});

it('the two widths do NOT share a cache entry for the same unit id', async () => {
Expand DownExpand Up@@ -208,8 +239,14 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
{ id: 'bu_root', organization_id: 'org_a', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_a', active: true },
];
// [#14547] The membership rows are stamped now. They used to be org-less
// here and still expanded, because the member read carried no organization
// predicate whatever — the gap #14547 closed. Units created through the
// API by org_a have memberships created the same way, so this is the
// fixture becoming faithful, not the assertion being relaxed.
const members: MemberRow[] = SEEDED_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(units, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
Expand All@@ -224,17 +261,171 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
expect(await g.expandUsers('bu_root')).toEqual([]);
});

it('[divergence] an org-scoped rule does NOT see an env-wide (null-org) unit — approvals does (#3807)', async () => {
// Same inputs that #3807 fixed on the approvals side. Sharing still reads
// a null-org unit as "belongs to no org, therefore not mine" and grants
// nobody. Unreachable today (rules are null-org), deliberate until the
// platform rules on null-org semantics for AUTHORIZATION paths — widening
// who can SEE a record is not a change to make on a defect that cannot
// currently fire.
it('an org-scoped rule never reaches another org’s MEMBER of a unit it can see', async () => {
// [#14547] The unit is org_a's and visible; the membership row is org_b's.
// The member screen is the only thing that answers here, so this fails if
// `memberScope` is dropped even while every unit-level assertion passes.
const units: UnitRow[] = [{ id: 'bu_root', organization_id: 'org_a', active: true }];
const members: MemberRow[] = [
{ business_unit_id: 'bu_root', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_root', user_id: 'u_b', organization_id: 'org_b' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual(['u_a']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_a']);
});
});

/**
* [#14547] The UNIT screen is null-inclusive — the divergence from
* `plugin-approvals` (#3807) is CLOSED.
*
* The `[divergence]` test that used to live in the block above pinned the
* opposite posture on the grounds that it could not fire. It fired: the
* external report is an org admin creating a rule at runtime against a unit
* the app seeded.
*/
describe('BusinessUnitGraphService — the UNIT screen (#14547)', () => {
const STAMPED_MEMBERS: MemberRow[] = SEEDED_MEMBERS.map((m) => ({
...m,
organization_id: 'org_a',
}));

it('an org-scoped rule DOES see an env-wide (null-org) seeded unit', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_root']);
});

it('the seed check and the subtree walk BOTH admit the seeded rows', async () => {
// `seedIsUsable` and the `descendants` BFS are two separate reads through
// the same screen; a widening applied to one and not the other would still
// answer `[]` for the subtree width.
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.descendants('bu_root')).sort()).toEqual(['bu_child', 'bu_root']);
});

it('`headOf` resolves the manager of a seeded unit too', async () => {
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: null, active: true, manager_user_id: 'u_head' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, []),
organizationId: 'org_a',
});
expect(await g.headOf('bu_root')).toBe('u_head');
});

it('ONLY the NULL arm widened — another org’s unit is still invisible', async () => {
// The control that separates "null-inclusive" from "unscoped". Without it
// a screen that had simply been deleted would pass every assertion above.
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: 'org_b', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_b', active: true },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
expect(await g.descendants('bu_root')).toEqual([]);
expect(await g.headOf('bu_root')).toBeNull();
});

it('an INACTIVE seeded unit still contributes nobody', async () => {
const units: UnitRow[] = SEEDED_UNITS.map((u) =>
u.id === 'bu_root' ? { ...u, active: false } : u,
);
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
});
});

/**
* [#14547] The MEMBER screen is STRICT — the leak the unit widening would
* otherwise have opened.
*
* ⚠️ These are the SECURITY half and they are pinned apart from the functional
* half on purpose: a change that expands the right members while also
* expanding another organization's members passes every assertion in the block
* above.
*/
describe('BusinessUnitGraphService — the MEMBER screen (#14547)', () => {
/**
* One SEEDED unit id with two tenants' memberships hanging off it — the
* shape that exists on every deployment whose org chart came from a seed,
* and the one the widened unit screen makes reachable.
*/
const SHARED_SEED_UNITS: UnitRow[] = [
{ id: 'bu_market', organization_id: null, active: true },
{ id: 'bu_market_west', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
];
const TWO_TENANT_MEMBERS: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_market', user_id: 'u_b', organization_id: 'org_b' },
{ business_unit_id: 'bu_market_west', user_id: 'u_a_west', organization_id: 'org_a' },
{ business_unit_id: 'bu_market_west', user_id: 'u_b_west', organization_id: 'org_b' },
];

it('WIDE — a subtree expansion never crosses into another organization', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
const users = await g.expandUsers('bu_market');
expect(users.sort()).toEqual(['u_a', 'u_a_west']);
expect(users).not.toContain('u_b');
expect(users).not.toContain('u_b_west');
});

it('NARROW — the single-unit expansion does not cross either', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual(['u_a']);
});

it('an org-LESS membership row is NOT a member of an org-scoped rule', async () => {
// Unknown tenancy, not platform-global: `sys_business_unit_member` is not
// organization-stamped by seed replay or by an elevated system write, so a
// NULL here cannot be read the way a NULL on the UNIT row is read. The
// grant fails closed, and `SharingRuleService` warns rather than staying
// silent about it.
const members: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_seeded', organization_id: null },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual([]);
expect(await g.expandUsers('bu_market')).toEqual([]);
});

it('an org-LESS rule is unmoved — both screens stay no-ops', async () => {
// The dominant shape today (declared rules bootstrap org-less). #14547
// must not change what they expand to, in either direction.
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: null,
});
expect((await g.expandUsers('bu_market')).sort()).toEqual([
'u_a', 'u_a_west', 'u_b', 'u_b_west',
]);
});
});
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bu-tenant-screen-relanding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Fix: a sharing rule with a business-unit recipient granted nothing when the unit came from seed data — and tenant-screen the member reads that widening exposes.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own read-side chokepoint (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`, because a NULL organization marks a platform/seeded row every tenant may see. A sharing rule always carries the caller's organization, but a business unit written by seed data carries none — a seed cannot know the id the runtime mints at boot — so the two never matched. The seed check read the unit as "does not exist", both recipient widths (`business_unit` and `unit_and_subordinates`) expanded to zero users, and the rule stayed active having materialised no `sys_record_share` row and logged nothing. `orgScope` now applies the platform's null-inclusive screen, the same predicate `plugin-approvals` already applies to these very rows and `SharingRuleService.adminOrgScope` applies to the rule table.

The member reads are now tenant-screened, which they were not before. Both `expandUnitMembers` and `expandUsers` queried `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either, so the strict unit screen was the only thing keeping an org-stamped rule away from that unscoped query. Widening the unit screen alone would have turned a silent under-grant into a silent cross-tenant over-grant, since a seeded unit id exists identically in every tenant. The member screen is strict rather than null-inclusive on purpose: seed replay and elevated system writes both leave `sys_business_unit_member.organization_id` NULL, so a NULL there means unknown tenancy rather than platform-global, and an org-scoped rule does not grant to it. The sibling recipient widths already read their membership rows this way.

An active business-unit rule that expands to no recipients now warns once per rule per process, naming the rule, the object, the recipient kind, the unit and the organization. That case — a rule whose unit and membership rows were both seeded — is the one combination that still grants nobody, and it is no longer silent.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,7 +137,7 @@ The largest single consumer — **20 of the 109 sites**.
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:165`, `:390` |

### 4. Approvals, reports, attachments, comments, knowledge

Expand Down
265 changes: 228 additions & 37 deletions packages/plugins/plugin-sharing/src/business-unit-graph.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,45 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* BusinessUnitGraphService — org scoping of the unit tree.
* BusinessUnitGraphService — the TWO tenant screens, pinned as a pair.
*
* These pin the ORG-SCOPE behaviour specifically, because it is the exact
* shape that broke approvals in #3807: `orgScope()` AND-composes a strict
* `organization_id = <rule org>` equality, so a unit written with no
* organization at all (a seeded / file-layer / bootstrap row — a seed cannot
* know the org id the runtime mints at boot) matches nothing, the seed check
* fails, and the expansion returns zero members. In approvals that produced a
* dead `department:<id>` approver slot; here it would produce a sharing rule
* that silently grants nobody.
* ## What this file used to say, and why it changed
*
* It is NOT reachable today: every materialized `sys_sharing_rule` row carries
* `organization_id = null` (verified on a live showcase stack), so
* `expandRecipient` passes `null` and `orgScope` is skipped entirely. The
* moment rules start carrying an org — a multi-tenant deployment — a BU
* subtree rule against a seeded unit stops granting, and the symptom is
* "the right people cannot see the record", which is far quieter than a stuck
* approval.
* Until #14547 `orgScope()` AND-composed a strict `organization_id = <rule
* org>` equality onto the UNIT read. A unit written with no organization at
* all (a seeded / file-layer / bootstrap row — a seed cannot know the org id
* the runtime mints at boot) therefore matched nothing, the seed check failed,
* and BOTH widths expanded to zero members. #3807 had already fixed exactly
* that on the approvals side; this file recorded the sharing side's divergence
* as deliberate on the grounds that it was unreachable, because every
* materialized `sys_sharing_rule` row carried `organization_id = null`.
*
* So this file locks BOTH sides down:
* - the reachable paths (null-org rule) keep working, and
* - the divergence from approvals is written down as an executable fact
* rather than a comment, so flipping it is a deliberate edit to a named
* test and never a silent behaviour change.
* It was reachable. #14547 is the external report: an org admin creating a
* rule at runtime gets an org-stamped rule, the seeded unit carries none, and
* the rule is accepted, stays active, materialises zero `sys_record_share`
* rows and logs nothing. The `[divergence]` test that pinned the old posture
* is gone — replaced, not merely flipped, because an assertion that keeps
* passing while the mechanism under it changes is worse than no assertion.
*
* If the platform decides null-org means "env-wide, visible to every org" for
* sharing too — the way `plugin-approvals` and `sys_metadata` already read it —
* the test named `[divergence]` below is the one to flip, and `orgScope` grows
* the same `$or: [{ organization_id }, { organization_id: null }]` predicate.
* ## The pair this file now pins
*
* The fix is ASYMMETRIC and both halves have to be pinned, because each one
* alone is a defect:
*
* - the UNIT screen (`orgScope`) is NULL-INCLUSIVE — the platform's own
* `(organization_id = ? OR organization_id IS NULL)`, the predicate
* `SqlDriver.applyTenantScope` writes and `plugin-approvals` already
* applies to these very rows;
* - the MEMBER screen (`memberScope`) is STRICT. Both member reads used to
* carry no organization predicate at all, and the strict unit screen was
* the only thing holding an org-stamped rule away from that unscoped
* query. Widening the unit screen ALONE turns a silent under-grant into a
* silent CROSS-TENANT OVER-GRANT, since a seeded unit id exists
* identically in every tenant.
*
* So the security half is pinned separately from the functional half below: a
* change that expands the right members while also expanding another
* organization's members satisfies the functional pin completely.
*/

import { describe, it, expect } from 'vitest';
Expand All@@ -40,8 +50,9 @@ interface UnitRow {
parent_business_unit_id?: string | null;
organization_id?: string | null;
active?: boolean;
manager_user_id?: string | null;
}
interface MemberRow { business_unit_id: string; user_id: string }
interface MemberRow { business_unit_id: string; user_id: string; organization_id?: string | null }

/**
* Minimal engine over `sys_business_unit` + `sys_business_unit_member`.
Expand DownExpand Up@@ -168,13 +179,33 @@ describe('BusinessUnitGraphService — the two widths are actually two widths (#
});

it('the narrow width is org-predicated exactly like the wide one', async () => {
// [#14547] Same fixture, new mechanism — and the mechanism is spelled out
// because the ASSERTION did not move. `DIV_MEMBERS` carry no organization,
// so before #14547 this returned `[]` because the strict UNIT screen hid
// the seeded unit, and after it returns `[]` because the strict MEMBER
// screen refuses membership rows of unknown tenancy. An unchanged
// expectation over a changed cause is exactly the kind of pin that stops
// guarding anything, so the two causes are separated below: the unit is
// now visible (`descendants` sees the whole seeded tree), and it is the
// members that are refused.
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, DIV_MEMBERS),
organizationId: 'org_a',
});
// Seeded (null-org) units are not visible to an org-scoped rule — the
// same `[divergence]` posture the wide width holds below.
expect(await g.expandUnitMembers('bu_div')).toEqual([]);
expect((await g.descendants('bu_div')).sort()).toEqual(['bu_dept', 'bu_div', 'bu_office']);
});

it('[#14547] both widths reach org-stamped members of a SEEDED unit tree', async () => {
// The one change that flips the outcome: the membership rows are stamped,
// exactly as a REST/session write stamps them. The units stay seeded.
const members: MemberRow[] = DIV_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(DIV_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_div')).toEqual(['u_div']);
expect((await g.expandUsers('bu_div')).sort()).toEqual(['u_dept', 'u_div', 'u_office']);
});

it('the two widths do NOT share a cache entry for the same unit id', async () => {
Expand DownExpand Up@@ -208,8 +239,14 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
{ id: 'bu_root', organization_id: 'org_a', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_a', active: true },
];
// [#14547] The membership rows are stamped now. They used to be org-less
// here and still expanded, because the member read carried no organization
// predicate whatever — the gap #14547 closed. Units created through the
// API by org_a have memberships created the same way, so this is the
// fixture becoming faithful, not the assertion being relaxed.
const members: MemberRow[] = SEEDED_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
const g = new BusinessUnitGraphService({
engine: makeEngine(units, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
Expand All@@ -224,17 +261,171 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
expect(await g.expandUsers('bu_root')).toEqual([]);
});

it('[divergence] an org-scoped rule does NOT see an env-wide (null-org) unit — approvals does (#3807)', async () => {
// Same inputs that #3807 fixed on the approvals side. Sharing still reads
// a null-org unit as "belongs to no org, therefore not mine" and grants
// nobody. Unreachable today (rules are null-org), deliberate until the
// platform rules on null-org semantics for AUTHORIZATION paths — widening
// who can SEE a record is not a change to make on a defect that cannot
// currently fire.
it('an org-scoped rule never reaches another org’s MEMBER of a unit it can see', async () => {
// [#14547] The unit is org_a's and visible; the membership row is org_b's.
// The member screen is the only thing that answers here, so this fails if
// `memberScope` is dropped even while every unit-level assertion passes.
const units: UnitRow[] = [{ id: 'bu_root', organization_id: 'org_a', active: true }];
const members: MemberRow[] = [
{ business_unit_id: 'bu_root', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_root', user_id: 'u_b', organization_id: 'org_b' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, SEEDED_MEMBERS),
engine: makeEngine(units, members),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual(['u_a']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_a']);
});
});

/**
* [#14547] The UNIT screen is null-inclusive — the divergence from
* `plugin-approvals` (#3807) is CLOSED.
*
* The `[divergence]` test that used to live in the block above pinned the
* opposite posture on the grounds that it could not fire. It fired: the
* external report is an org admin creating a rule at runtime against a unit
* the app seeded.
*/
describe('BusinessUnitGraphService — the UNIT screen (#14547)', () => {
const STAMPED_MEMBERS: MemberRow[] = SEEDED_MEMBERS.map((m) => ({
...m,
organization_id: 'org_a',
}));

it('an org-scoped rule DOES see an env-wide (null-org) seeded unit', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_root']);
});

it('the seed check and the subtree walk BOTH admit the seeded rows', async () => {
// `seedIsUsable` and the `descendants` BFS are two separate reads through
// the same screen; a widening applied to one and not the other would still
// answer `[]` for the subtree width.
const g = new BusinessUnitGraphService({
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect((await g.descendants('bu_root')).sort()).toEqual(['bu_child', 'bu_root']);
});

it('`headOf` resolves the manager of a seeded unit too', async () => {
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: null, active: true, manager_user_id: 'u_head' },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, []),
organizationId: 'org_a',
});
expect(await g.headOf('bu_root')).toBe('u_head');
});

it('ONLY the NULL arm widened — another org’s unit is still invisible', async () => {
// The control that separates "null-inclusive" from "unscoped". Without it
// a screen that had simply been deleted would pass every assertion above.
const units: UnitRow[] = [
{ id: 'bu_root', organization_id: 'org_b', active: true },
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_b', active: true },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
expect(await g.descendants('bu_root')).toEqual([]);
expect(await g.headOf('bu_root')).toBeNull();
});

it('an INACTIVE seeded unit still contributes nobody', async () => {
const units: UnitRow[] = SEEDED_UNITS.map((u) =>
u.id === 'bu_root' ? { ...u, active: false } : u,
);
const g = new BusinessUnitGraphService({
engine: makeEngine(units, STAMPED_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUsers('bu_root')).toEqual([]);
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
});
});

/**
* [#14547] The MEMBER screen is STRICT — the leak the unit widening would
* otherwise have opened.
*
* ⚠️ These are the SECURITY half and they are pinned apart from the functional
* half on purpose: a change that expands the right members while also
* expanding another organization's members passes every assertion in the block
* above.
*/
describe('BusinessUnitGraphService — the MEMBER screen (#14547)', () => {
/**
* One SEEDED unit id with two tenants' memberships hanging off it — the
* shape that exists on every deployment whose org chart came from a seed,
* and the one the widened unit screen makes reachable.
*/
const SHARED_SEED_UNITS: UnitRow[] = [
{ id: 'bu_market', organization_id: null, active: true },
{ id: 'bu_market_west', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
];
const TWO_TENANT_MEMBERS: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_a', organization_id: 'org_a' },
{ business_unit_id: 'bu_market', user_id: 'u_b', organization_id: 'org_b' },
{ business_unit_id: 'bu_market_west', user_id: 'u_a_west', organization_id: 'org_a' },
{ business_unit_id: 'bu_market_west', user_id: 'u_b_west', organization_id: 'org_b' },
];

it('WIDE — a subtree expansion never crosses into another organization', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
const users = await g.expandUsers('bu_market');
expect(users.sort()).toEqual(['u_a', 'u_a_west']);
expect(users).not.toContain('u_b');
expect(users).not.toContain('u_b_west');
});

it('NARROW — the single-unit expansion does not cross either', async () => {
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual(['u_a']);
});

it('an org-LESS membership row is NOT a member of an org-scoped rule', async () => {
// Unknown tenancy, not platform-global: `sys_business_unit_member` is not
// organization-stamped by seed replay or by an elevated system write, so a
// NULL here cannot be read the way a NULL on the UNIT row is read. The
// grant fails closed, and `SharingRuleService` warns rather than staying
// silent about it.
const members: MemberRow[] = [
{ business_unit_id: 'bu_market', user_id: 'u_seeded', organization_id: null },
];
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, members),
organizationId: 'org_a',
});
expect(await g.expandUnitMembers('bu_market')).toEqual([]);
expect(await g.expandUsers('bu_market')).toEqual([]);
});

it('an org-LESS rule is unmoved — both screens stay no-ops', async () => {
// The dominant shape today (declared rules bootstrap org-less). #14547
// must not change what they expand to, in either direction.
const g = new BusinessUnitGraphService({
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
organizationId: null,
});
expect((await g.expandUsers('bu_market')).sort()).toEqual([
'u_a', 'u_a_west', 'u_b', 'u_b_west',
]);
});
});
Loading
Loading