Closed
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-graph-tenant-screen.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Sharing rules with a business-unit recipient no longer grant nothing when the unit was created by seed data.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own tenant screen (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`. 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, matching what `plugin-approvals` already did for the same rows.

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; 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.

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 memberships 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:440`, `:494`, `:498`, `:571`, `:601` |
| 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:164`, `:389` |

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

Expand Down
281 changes: 281 additions & 0 deletions packages/plugins/plugin-sharing/src/bu-rule-tenant-screen.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14547] A business-unit sharing rule against a SEEDED unit, end to end.
*
* `business-unit-graph.test.ts` pins the two screens at the graph service.
* This file drives the whole rule path — `evaluateRule` → `expandRecipient` →
* `reconcile` → `sys_record_share` — because that is the layer the defect was
* reported at and the layer at which it was silent: the rule was accepted, it
* stayed `active: true`, it materialised zero grants, and nothing was logged.
* Asserting the graph's return value alone would leave every one of those
* observable facts unpinned.
*
* The fixture is the reported reproduction's shape, not an invented one:
*
* - `sys_business_unit` rows come from app SEED data and carry
* `organization_id = NULL` — a seed cannot know the id the runtime mints
* at boot;
* - `sys_business_unit_member` rows are POSTed through the REST data API and
* ARE organization-stamped (the engine threads the caller's tenant and the
* SQL driver stamps the injected column);
* - the `sys_sharing_rule` row is created by an organization admin and is
* org-stamped too (an explicit `organization_id: null` in the payload is
* overridden).
*
* Two of those three carry an organization and one does not, which is exactly
* the combination the strict unit screen turned into zero grants.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
import { SharingService } from './sharing-service.js';
import { SharingRuleService } from './sharing-rule-service.js';

interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const ORG_A = 'org_a';
const ORG_B = 'org_b';

/**
* Filter matcher over the operators this path actually emits.
*
* `organization_id: null` must match a row that OMITS the column, because that
* is what a NULL column reads back as and the whole `$or` arm exists for it. A
* fake that answered otherwise would report the widened screen as still broken
* — or, worse, report a screen that never widened as fixed.
*/
function matches(row: Row, f: any): boolean {
if (!f || typeof f !== 'object') return true;
for (const [k, v] of Object.entries(f)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k === '$and') {
if (!(v as any[]).every((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
const rv = row[k];
if (v === null) {
if (rv != null) return false;
continue;
}
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
const op: any = v;
if ('$in' in op) { if (!op.$in.includes(rv)) return false; continue; }
// `descendants()` filters children with `active: { $ne: false }`, so an
// undefined `active` must PASS — the graph treats absent as active.
if ('$ne' in op) { if (rv === op.$ne) return false; continue; }
if ('$gte' in op) { if (!(rv >= op.$gte)) return false; continue; }
}
if (rv !== v) return false;
}
return true;
}

function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
let seq = 0;
return {
_tables: tables,
getSchema() { return undefined; },
seed(object: string, rows: Row[]) { ensure(object).push(...rows.map((r) => ({ ...r }))); },
async find(o: string, opts?: any) {
const f = opts?.filter ?? opts?.where;
return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000);
},
async insert(o: string, data: any) {
const row = { id: data.id ?? `${o}_${++seq}`, ...data };
ensure(o).push(row);
return row;
},
// The PRODUCER's own dispatch predicates, so a fixture that drifts to a
// call shape `ObjectQL` would refuse fails here instead of going green.
async update(o: string, data: any, options?: any) {
const verdict = assertEngineUpdateDispatch(data, options);
const t = ensure(o);
const targets = verdict.kind === 'by-id'
? t.filter((r) => r.id === verdict.id)
: t.filter((r) => matches(r, options?.where));
for (const r of targets) Object.assign(r, data);
return verdict.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
},
async delete(o: string, opts?: any) {
assertEngineDeleteDispatch(opts);
const t = ensure(o);
const where = opts?.where ?? (opts?.id != null ? { id: opts.id } : {});
for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
return { ok: true };
},
};
}

const RULE = 'kpi_sheet_to_market_unit';

describe('#14547 — an org-stamped rule against a SEEDED business unit', () => {
let engine: ReturnType<typeof makeEngine>;
let rules: SharingRuleService;
let warn: ReturnType<typeof vi.fn<(msg: any, ...rest: any[]) => void>>;

/** Who currently holds a rule-materialised grant on `recordId`. */
const granteesOf = (recordId: string): string[] =>
(engine._tables.sys_record_share ?? [])
.filter((r) => r.record_id === recordId && r.source === 'rule')
.map((r) => String(r.recipient_id))
.sort();

/** The warn lines this run emitted, as one searchable string each. */
const warnLines = (): string[] => warn.mock.calls.map((c) => String(c[0]));
const emptyExpansionWarns = (): any[][] =>
warn.mock.calls.filter((c) => String(c[0]).includes('expands to NO recipients'));

beforeEach(() => {
engine = makeEngine();
warn = vi.fn();
const sharing = new SharingService({ engine: engine as any });
rules = new SharingRuleService({ engine: engine as any, sharing, logger: { warn } });

// Seed data: units written before any organization existed.
engine.seed('sys_business_unit', [
{ id: 'bu_market', name: 'Market', parent_business_unit_id: null, organization_id: null, active: true },
{ id: 'bu_market_west', name: 'Market West', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
]);
engine.seed('kpi_entry_sheet', [{ id: 'kpi_1', subject: 'bu_market', owner_id: 'author' }]);
});

/** Create the rule the reproduction created, org-stamped like a real one. */
const seedRule = (recipientType: 'business_unit' | 'unit_and_subordinates', organizationId: string | null = ORG_A) => {
engine.seed('sys_sharing_rule', [{
id: 'srule_kpi', organization_id: organizationId, name: RULE,
label: 'KPI sheet → Market', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: recipientType, recipient_id: 'bu_market',
access_level: 'edit', active: true, managed_by: 'package',
}]);
};

describe('the reported defect: 201, active, zero shares, no log', () => {
it('WIDE — `unit_and_subordinates` now materialises the grants', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
const result = await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
expect(result.expandedUsers).toBe(2);
// …and it did so QUIETLY: the new warn is for the empty case only.
expect(emptyExpansionWarns()).toHaveLength(0);
});

it('NARROW — `business_unit` materialises the anchor unit only', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
// The two widths stay two widths (#7807) — the tenant screen moved, the
// subtree boundary did not.
expect(granteesOf('kpi_1')).toEqual(['u_1']);
});
});

describe('the leak the same change would have opened', () => {
it('another organization’s members are never granted through the shared seeded unit', async () => {
// ONE seeded unit id, two tenants' memberships hanging off it — the
// shape that exists on any deployment whose org chart came from a seed.
engine.seed('sys_business_unit_member', [
{ id: 'bum_a', business_unit_id: 'bu_market', user_id: 'u_a', organization_id: ORG_A },
{ id: 'bum_b', business_unit_id: 'bu_market', user_id: 'u_b', organization_id: ORG_B },
]);
seedRule('unit_and_subordinates', ORG_A);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_a']);
expect(granteesOf('kpi_1')).not.toContain('u_b');
});
});

describe('an active rule that grants nobody is LOUD', () => {
it('warns naming the rule, the recipient kind and the unit', async () => {
// Unit and memberships BOTH seeded: the unit resolves now, but org-less
// membership rows are of unknown tenancy and are not members of an
// org-stamped rule. The residual empty expansion is the case this warn
// exists for.
engine.seed('sys_business_unit_member', [
{ id: 'bum_seeded', business_unit_id: 'bu_market', user_id: 'u_seeded' },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);

expect(granteesOf('kpi_1')).toEqual([]);
const calls = emptyExpansionWarns();
expect(calls).toHaveLength(1);
expect(String(calls[0][0])).toContain('organization_id');
expect(calls[0][1]).toMatchObject({
rule: RULE,
object: 'kpi_entry_sheet',
recipientType: 'unit_and_subordinates',
businessUnit: 'bu_market',
organization: ORG_A,
});
});

it('warns for the NARROW width too', async () => {
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(emptyExpansionWarns()[0][1]).toMatchObject({ recipientType: 'business_unit' });
});

it('warns ONCE per rule per process, not once per evaluation', async () => {
// The reconcilers call `expandRecipient` on every matched write. Without
// the dedup one misconfigured rule dominates the deployment's log —
// the same reasoning the inert-criteria warn already carries.
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(rules.emptyUnitExpansionRuleKeys).toEqual(['srule_kpi::bu_market']);
});

it('says nothing when the rule grants somebody', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
expect(warnLines().join('\n')).not.toContain('expands to NO recipients');
expect(rules.emptyUnitExpansionRuleKeys).toEqual([]);
});

it('an INACTIVE rule is not warned about — it is meant to grant nobody', async () => {
engine.seed('sys_sharing_rule', [{
id: 'srule_off', organization_id: ORG_A, name: 'off_rule',
label: 'Off', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: 'unit_and_subordinates', recipient_id: 'bu_market',
access_level: 'edit', active: false, managed_by: 'package',
}]);
await rules.evaluateRule('off_rule', SYS);
expect(emptyExpansionWarns()).toHaveLength(0);
});
});

describe('the org-less rule — the dominant shape today — is unmoved', () => {
it('still expands every member of the seeded tree, stamped or not', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2' },
]);
seedRule('unit_and_subordinates', null);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
});
});
});
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
Closed
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-graph-tenant-screen.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Sharing rules with a business-unit recipient no longer grant nothing when the unit was created by seed data.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own tenant screen (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`. 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, matching what `plugin-approvals` already did for the same rows.

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; 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.

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 memberships 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:440`, `:494`, `:498`, `:571`, `:601` |
| 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:164`, `:389` |

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

Expand Down
281 changes: 281 additions & 0 deletions packages/plugins/plugin-sharing/src/bu-rule-tenant-screen.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14547] A business-unit sharing rule against a SEEDED unit, end to end.
*
* `business-unit-graph.test.ts` pins the two screens at the graph service.
* This file drives the whole rule path — `evaluateRule` → `expandRecipient` →
* `reconcile` → `sys_record_share` — because that is the layer the defect was
* reported at and the layer at which it was silent: the rule was accepted, it
* stayed `active: true`, it materialised zero grants, and nothing was logged.
* Asserting the graph's return value alone would leave every one of those
* observable facts unpinned.
*
* The fixture is the reported reproduction's shape, not an invented one:
*
* - `sys_business_unit` rows come from app SEED data and carry
* `organization_id = NULL` — a seed cannot know the id the runtime mints
* at boot;
* - `sys_business_unit_member` rows are POSTed through the REST data API and
* ARE organization-stamped (the engine threads the caller's tenant and the
* SQL driver stamps the injected column);
* - the `sys_sharing_rule` row is created by an organization admin and is
* org-stamped too (an explicit `organization_id: null` in the payload is
* overridden).
*
* Two of those three carry an organization and one does not, which is exactly
* the combination the strict unit screen turned into zero grants.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
import { SharingService } from './sharing-service.js';
import { SharingRuleService } from './sharing-rule-service.js';

interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const ORG_A = 'org_a';
const ORG_B = 'org_b';

/**
* Filter matcher over the operators this path actually emits.
*
* `organization_id: null` must match a row that OMITS the column, because that
* is what a NULL column reads back as and the whole `$or` arm exists for it. A
* fake that answered otherwise would report the widened screen as still broken
* — or, worse, report a screen that never widened as fixed.
*/
function matches(row: Row, f: any): boolean {
if (!f || typeof f !== 'object') return true;
for (const [k, v] of Object.entries(f)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k === '$and') {
if (!(v as any[]).every((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
const rv = row[k];
if (v === null) {
if (rv != null) return false;
continue;
}
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
const op: any = v;
if ('$in' in op) { if (!op.$in.includes(rv)) return false; continue; }
// `descendants()` filters children with `active: { $ne: false }`, so an
// undefined `active` must PASS — the graph treats absent as active.
if ('$ne' in op) { if (rv === op.$ne) return false; continue; }
if ('$gte' in op) { if (!(rv >= op.$gte)) return false; continue; }
}
if (rv !== v) return false;
}
return true;
}

function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
let seq = 0;
return {
_tables: tables,
getSchema() { return undefined; },
seed(object: string, rows: Row[]) { ensure(object).push(...rows.map((r) => ({ ...r }))); },
async find(o: string, opts?: any) {
const f = opts?.filter ?? opts?.where;
return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000);
},
async insert(o: string, data: any) {
const row = { id: data.id ?? `${o}_${++seq}`, ...data };
ensure(o).push(row);
return row;
},
// The PRODUCER's own dispatch predicates, so a fixture that drifts to a
// call shape `ObjectQL` would refuse fails here instead of going green.
async update(o: string, data: any, options?: any) {
const verdict = assertEngineUpdateDispatch(data, options);
const t = ensure(o);
const targets = verdict.kind === 'by-id'
? t.filter((r) => r.id === verdict.id)
: t.filter((r) => matches(r, options?.where));
for (const r of targets) Object.assign(r, data);
return verdict.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
},
async delete(o: string, opts?: any) {
assertEngineDeleteDispatch(opts);
const t = ensure(o);
const where = opts?.where ?? (opts?.id != null ? { id: opts.id } : {});
for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
return { ok: true };
},
};
}

const RULE = 'kpi_sheet_to_market_unit';

describe('#14547 — an org-stamped rule against a SEEDED business unit', () => {
let engine: ReturnType<typeof makeEngine>;
let rules: SharingRuleService;
let warn: ReturnType<typeof vi.fn<(msg: any, ...rest: any[]) => void>>;

/** Who currently holds a rule-materialised grant on `recordId`. */
const granteesOf = (recordId: string): string[] =>
(engine._tables.sys_record_share ?? [])
.filter((r) => r.record_id === recordId && r.source === 'rule')
.map((r) => String(r.recipient_id))
.sort();

/** The warn lines this run emitted, as one searchable string each. */
const warnLines = (): string[] => warn.mock.calls.map((c) => String(c[0]));
const emptyExpansionWarns = (): any[][] =>
warn.mock.calls.filter((c) => String(c[0]).includes('expands to NO recipients'));

beforeEach(() => {
engine = makeEngine();
warn = vi.fn();
const sharing = new SharingService({ engine: engine as any });
rules = new SharingRuleService({ engine: engine as any, sharing, logger: { warn } });

// Seed data: units written before any organization existed.
engine.seed('sys_business_unit', [
{ id: 'bu_market', name: 'Market', parent_business_unit_id: null, organization_id: null, active: true },
{ id: 'bu_market_west', name: 'Market West', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
]);
engine.seed('kpi_entry_sheet', [{ id: 'kpi_1', subject: 'bu_market', owner_id: 'author' }]);
});

/** Create the rule the reproduction created, org-stamped like a real one. */
const seedRule = (recipientType: 'business_unit' | 'unit_and_subordinates', organizationId: string | null = ORG_A) => {
engine.seed('sys_sharing_rule', [{
id: 'srule_kpi', organization_id: organizationId, name: RULE,
label: 'KPI sheet → Market', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: recipientType, recipient_id: 'bu_market',
access_level: 'edit', active: true, managed_by: 'package',
}]);
};

describe('the reported defect: 201, active, zero shares, no log', () => {
it('WIDE — `unit_and_subordinates` now materialises the grants', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
const result = await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
expect(result.expandedUsers).toBe(2);
// …and it did so QUIETLY: the new warn is for the empty case only.
expect(emptyExpansionWarns()).toHaveLength(0);
});

it('NARROW — `business_unit` materialises the anchor unit only', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
// The two widths stay two widths (#7807) — the tenant screen moved, the
// subtree boundary did not.
expect(granteesOf('kpi_1')).toEqual(['u_1']);
});
});

describe('the leak the same change would have opened', () => {
it('another organization’s members are never granted through the shared seeded unit', async () => {
// ONE seeded unit id, two tenants' memberships hanging off it — the
// shape that exists on any deployment whose org chart came from a seed.
engine.seed('sys_business_unit_member', [
{ id: 'bum_a', business_unit_id: 'bu_market', user_id: 'u_a', organization_id: ORG_A },
{ id: 'bum_b', business_unit_id: 'bu_market', user_id: 'u_b', organization_id: ORG_B },
]);
seedRule('unit_and_subordinates', ORG_A);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_a']);
expect(granteesOf('kpi_1')).not.toContain('u_b');
});
});

describe('an active rule that grants nobody is LOUD', () => {
it('warns naming the rule, the recipient kind and the unit', async () => {
// Unit and memberships BOTH seeded: the unit resolves now, but org-less
// membership rows are of unknown tenancy and are not members of an
// org-stamped rule. The residual empty expansion is the case this warn
// exists for.
engine.seed('sys_business_unit_member', [
{ id: 'bum_seeded', business_unit_id: 'bu_market', user_id: 'u_seeded' },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);

expect(granteesOf('kpi_1')).toEqual([]);
const calls = emptyExpansionWarns();
expect(calls).toHaveLength(1);
expect(String(calls[0][0])).toContain('organization_id');
expect(calls[0][1]).toMatchObject({
rule: RULE,
object: 'kpi_entry_sheet',
recipientType: 'unit_and_subordinates',
businessUnit: 'bu_market',
organization: ORG_A,
});
});

it('warns for the NARROW width too', async () => {
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(emptyExpansionWarns()[0][1]).toMatchObject({ recipientType: 'business_unit' });
});

it('warns ONCE per rule per process, not once per evaluation', async () => {
// The reconcilers call `expandRecipient` on every matched write. Without
// the dedup one misconfigured rule dominates the deployment's log —
// the same reasoning the inert-criteria warn already carries.
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(rules.emptyUnitExpansionRuleKeys).toEqual(['srule_kpi::bu_market']);
});

it('says nothing when the rule grants somebody', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
expect(warnLines().join('\n')).not.toContain('expands to NO recipients');
expect(rules.emptyUnitExpansionRuleKeys).toEqual([]);
});

it('an INACTIVE rule is not warned about — it is meant to grant nobody', async () => {
engine.seed('sys_sharing_rule', [{
id: 'srule_off', organization_id: ORG_A, name: 'off_rule',
label: 'Off', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: 'unit_and_subordinates', recipient_id: 'bu_market',
access_level: 'edit', active: false, managed_by: 'package',
}]);
await rules.evaluateRule('off_rule', SYS);
expect(emptyExpansionWarns()).toHaveLength(0);
});
});

describe('the org-less rule — the dominant shape today — is unmoved', () => {
it('still expands every member of the seeded tree, stamped or not', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2' },
]);
seedRule('unit_and_subordinates', null);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
});
});
});
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
Closed
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-graph-tenant-screen.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Sharing rules with a business-unit recipient no longer grant nothing when the unit was created by seed data.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own tenant screen (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`. 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, matching what `plugin-approvals` already did for the same rows.

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; 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.

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 memberships 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:440`, `:494`, `:498`, `:571`, `:601` |
| 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:164`, `:389` |

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

Expand Down
281 changes: 281 additions & 0 deletions packages/plugins/plugin-sharing/src/bu-rule-tenant-screen.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14547] A business-unit sharing rule against a SEEDED unit, end to end.
*
* `business-unit-graph.test.ts` pins the two screens at the graph service.
* This file drives the whole rule path — `evaluateRule` → `expandRecipient` →
* `reconcile` → `sys_record_share` — because that is the layer the defect was
* reported at and the layer at which it was silent: the rule was accepted, it
* stayed `active: true`, it materialised zero grants, and nothing was logged.
* Asserting the graph's return value alone would leave every one of those
* observable facts unpinned.
*
* The fixture is the reported reproduction's shape, not an invented one:
*
* - `sys_business_unit` rows come from app SEED data and carry
* `organization_id = NULL` — a seed cannot know the id the runtime mints
* at boot;
* - `sys_business_unit_member` rows are POSTed through the REST data API and
* ARE organization-stamped (the engine threads the caller's tenant and the
* SQL driver stamps the injected column);
* - the `sys_sharing_rule` row is created by an organization admin and is
* org-stamped too (an explicit `organization_id: null` in the payload is
* overridden).
*
* Two of those three carry an organization and one does not, which is exactly
* the combination the strict unit screen turned into zero grants.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
import { SharingService } from './sharing-service.js';
import { SharingRuleService } from './sharing-rule-service.js';

interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const ORG_A = 'org_a';
const ORG_B = 'org_b';

/**
* Filter matcher over the operators this path actually emits.
*
* `organization_id: null` must match a row that OMITS the column, because that
* is what a NULL column reads back as and the whole `$or` arm exists for it. A
* fake that answered otherwise would report the widened screen as still broken
* — or, worse, report a screen that never widened as fixed.
*/
function matches(row: Row, f: any): boolean {
if (!f || typeof f !== 'object') return true;
for (const [k, v] of Object.entries(f)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k === '$and') {
if (!(v as any[]).every((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
const rv = row[k];
if (v === null) {
if (rv != null) return false;
continue;
}
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
const op: any = v;
if ('$in' in op) { if (!op.$in.includes(rv)) return false; continue; }
// `descendants()` filters children with `active: { $ne: false }`, so an
// undefined `active` must PASS — the graph treats absent as active.
if ('$ne' in op) { if (rv === op.$ne) return false; continue; }
if ('$gte' in op) { if (!(rv >= op.$gte)) return false; continue; }
}
if (rv !== v) return false;
}
return true;
}

function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
let seq = 0;
return {
_tables: tables,
getSchema() { return undefined; },
seed(object: string, rows: Row[]) { ensure(object).push(...rows.map((r) => ({ ...r }))); },
async find(o: string, opts?: any) {
const f = opts?.filter ?? opts?.where;
return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000);
},
async insert(o: string, data: any) {
const row = { id: data.id ?? `${o}_${++seq}`, ...data };
ensure(o).push(row);
return row;
},
// The PRODUCER's own dispatch predicates, so a fixture that drifts to a
// call shape `ObjectQL` would refuse fails here instead of going green.
async update(o: string, data: any, options?: any) {
const verdict = assertEngineUpdateDispatch(data, options);
const t = ensure(o);
const targets = verdict.kind === 'by-id'
? t.filter((r) => r.id === verdict.id)
: t.filter((r) => matches(r, options?.where));
for (const r of targets) Object.assign(r, data);
return verdict.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
},
async delete(o: string, opts?: any) {
assertEngineDeleteDispatch(opts);
const t = ensure(o);
const where = opts?.where ?? (opts?.id != null ? { id: opts.id } : {});
for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
return { ok: true };
},
};
}

const RULE = 'kpi_sheet_to_market_unit';

describe('#14547 — an org-stamped rule against a SEEDED business unit', () => {
let engine: ReturnType<typeof makeEngine>;
let rules: SharingRuleService;
let warn: ReturnType<typeof vi.fn<(msg: any, ...rest: any[]) => void>>;

/** Who currently holds a rule-materialised grant on `recordId`. */
const granteesOf = (recordId: string): string[] =>
(engine._tables.sys_record_share ?? [])
.filter((r) => r.record_id === recordId && r.source === 'rule')
.map((r) => String(r.recipient_id))
.sort();

/** The warn lines this run emitted, as one searchable string each. */
const warnLines = (): string[] => warn.mock.calls.map((c) => String(c[0]));
const emptyExpansionWarns = (): any[][] =>
warn.mock.calls.filter((c) => String(c[0]).includes('expands to NO recipients'));

beforeEach(() => {
engine = makeEngine();
warn = vi.fn();
const sharing = new SharingService({ engine: engine as any });
rules = new SharingRuleService({ engine: engine as any, sharing, logger: { warn } });

// Seed data: units written before any organization existed.
engine.seed('sys_business_unit', [
{ id: 'bu_market', name: 'Market', parent_business_unit_id: null, organization_id: null, active: true },
{ id: 'bu_market_west', name: 'Market West', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
]);
engine.seed('kpi_entry_sheet', [{ id: 'kpi_1', subject: 'bu_market', owner_id: 'author' }]);
});

/** Create the rule the reproduction created, org-stamped like a real one. */
const seedRule = (recipientType: 'business_unit' | 'unit_and_subordinates', organizationId: string | null = ORG_A) => {
engine.seed('sys_sharing_rule', [{
id: 'srule_kpi', organization_id: organizationId, name: RULE,
label: 'KPI sheet → Market', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: recipientType, recipient_id: 'bu_market',
access_level: 'edit', active: true, managed_by: 'package',
}]);
};

describe('the reported defect: 201, active, zero shares, no log', () => {
it('WIDE — `unit_and_subordinates` now materialises the grants', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
const result = await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
expect(result.expandedUsers).toBe(2);
// …and it did so QUIETLY: the new warn is for the empty case only.
expect(emptyExpansionWarns()).toHaveLength(0);
});

it('NARROW — `business_unit` materialises the anchor unit only', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
// The two widths stay two widths (#7807) — the tenant screen moved, the
// subtree boundary did not.
expect(granteesOf('kpi_1')).toEqual(['u_1']);
});
});

describe('the leak the same change would have opened', () => {
it('another organization’s members are never granted through the shared seeded unit', async () => {
// ONE seeded unit id, two tenants' memberships hanging off it — the
// shape that exists on any deployment whose org chart came from a seed.
engine.seed('sys_business_unit_member', [
{ id: 'bum_a', business_unit_id: 'bu_market', user_id: 'u_a', organization_id: ORG_A },
{ id: 'bum_b', business_unit_id: 'bu_market', user_id: 'u_b', organization_id: ORG_B },
]);
seedRule('unit_and_subordinates', ORG_A);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_a']);
expect(granteesOf('kpi_1')).not.toContain('u_b');
});
});

describe('an active rule that grants nobody is LOUD', () => {
it('warns naming the rule, the recipient kind and the unit', async () => {
// Unit and memberships BOTH seeded: the unit resolves now, but org-less
// membership rows are of unknown tenancy and are not members of an
// org-stamped rule. The residual empty expansion is the case this warn
// exists for.
engine.seed('sys_business_unit_member', [
{ id: 'bum_seeded', business_unit_id: 'bu_market', user_id: 'u_seeded' },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);

expect(granteesOf('kpi_1')).toEqual([]);
const calls = emptyExpansionWarns();
expect(calls).toHaveLength(1);
expect(String(calls[0][0])).toContain('organization_id');
expect(calls[0][1]).toMatchObject({
rule: RULE,
object: 'kpi_entry_sheet',
recipientType: 'unit_and_subordinates',
businessUnit: 'bu_market',
organization: ORG_A,
});
});

it('warns for the NARROW width too', async () => {
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(emptyExpansionWarns()[0][1]).toMatchObject({ recipientType: 'business_unit' });
});

it('warns ONCE per rule per process, not once per evaluation', async () => {
// The reconcilers call `expandRecipient` on every matched write. Without
// the dedup one misconfigured rule dominates the deployment's log —
// the same reasoning the inert-criteria warn already carries.
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(rules.emptyUnitExpansionRuleKeys).toEqual(['srule_kpi::bu_market']);
});

it('says nothing when the rule grants somebody', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
expect(warnLines().join('\n')).not.toContain('expands to NO recipients');
expect(rules.emptyUnitExpansionRuleKeys).toEqual([]);
});

it('an INACTIVE rule is not warned about — it is meant to grant nobody', async () => {
engine.seed('sys_sharing_rule', [{
id: 'srule_off', organization_id: ORG_A, name: 'off_rule',
label: 'Off', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: 'unit_and_subordinates', recipient_id: 'bu_market',
access_level: 'edit', active: false, managed_by: 'package',
}]);
await rules.evaluateRule('off_rule', SYS);
expect(emptyExpansionWarns()).toHaveLength(0);
});
});

describe('the org-less rule — the dominant shape today — is unmoved', () => {
it('still expands every member of the seeded tree, stamped or not', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2' },
]);
seedRule('unit_and_subordinates', null);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
});
});
});
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
Closed
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-graph-tenant-screen.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Sharing rules with a business-unit recipient no longer grant nothing when the unit was created by seed data.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own tenant screen (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`. 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, matching what `plugin-approvals` already did for the same rows.

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; 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.

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 memberships 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:440`, `:494`, `:498`, `:571`, `:601` |
| 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:164`, `:389` |

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

Expand Down
281 changes: 281 additions & 0 deletions packages/plugins/plugin-sharing/src/bu-rule-tenant-screen.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14547] A business-unit sharing rule against a SEEDED unit, end to end.
*
* `business-unit-graph.test.ts` pins the two screens at the graph service.
* This file drives the whole rule path — `evaluateRule` → `expandRecipient` →
* `reconcile` → `sys_record_share` — because that is the layer the defect was
* reported at and the layer at which it was silent: the rule was accepted, it
* stayed `active: true`, it materialised zero grants, and nothing was logged.
* Asserting the graph's return value alone would leave every one of those
* observable facts unpinned.
*
* The fixture is the reported reproduction's shape, not an invented one:
*
* - `sys_business_unit` rows come from app SEED data and carry
* `organization_id = NULL` — a seed cannot know the id the runtime mints
* at boot;
* - `sys_business_unit_member` rows are POSTed through the REST data API and
* ARE organization-stamped (the engine threads the caller's tenant and the
* SQL driver stamps the injected column);
* - the `sys_sharing_rule` row is created by an organization admin and is
* org-stamped too (an explicit `organization_id: null` in the payload is
* overridden).
*
* Two of those three carry an organization and one does not, which is exactly
* the combination the strict unit screen turned into zero grants.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
import { SharingService } from './sharing-service.js';
import { SharingRuleService } from './sharing-rule-service.js';

interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const ORG_A = 'org_a';
const ORG_B = 'org_b';

/**
* Filter matcher over the operators this path actually emits.
*
* `organization_id: null` must match a row that OMITS the column, because that
* is what a NULL column reads back as and the whole `$or` arm exists for it. A
* fake that answered otherwise would report the widened screen as still broken
* — or, worse, report a screen that never widened as fixed.
*/
function matches(row: Row, f: any): boolean {
if (!f || typeof f !== 'object') return true;
for (const [k, v] of Object.entries(f)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k === '$and') {
if (!(v as any[]).every((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
const rv = row[k];
if (v === null) {
if (rv != null) return false;
continue;
}
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
const op: any = v;
if ('$in' in op) { if (!op.$in.includes(rv)) return false; continue; }
// `descendants()` filters children with `active: { $ne: false }`, so an
// undefined `active` must PASS — the graph treats absent as active.
if ('$ne' in op) { if (rv === op.$ne) return false; continue; }
if ('$gte' in op) { if (!(rv >= op.$gte)) return false; continue; }
}
if (rv !== v) return false;
}
return true;
}

function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
let seq = 0;
return {
_tables: tables,
getSchema() { return undefined; },
seed(object: string, rows: Row[]) { ensure(object).push(...rows.map((r) => ({ ...r }))); },
async find(o: string, opts?: any) {
const f = opts?.filter ?? opts?.where;
return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000);
},
async insert(o: string, data: any) {
const row = { id: data.id ?? `${o}_${++seq}`, ...data };
ensure(o).push(row);
return row;
},
// The PRODUCER's own dispatch predicates, so a fixture that drifts to a
// call shape `ObjectQL` would refuse fails here instead of going green.
async update(o: string, data: any, options?: any) {
const verdict = assertEngineUpdateDispatch(data, options);
const t = ensure(o);
const targets = verdict.kind === 'by-id'
? t.filter((r) => r.id === verdict.id)
: t.filter((r) => matches(r, options?.where));
for (const r of targets) Object.assign(r, data);
return verdict.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
},
async delete(o: string, opts?: any) {
assertEngineDeleteDispatch(opts);
const t = ensure(o);
const where = opts?.where ?? (opts?.id != null ? { id: opts.id } : {});
for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
return { ok: true };
},
};
}

const RULE = 'kpi_sheet_to_market_unit';

describe('#14547 — an org-stamped rule against a SEEDED business unit', () => {
let engine: ReturnType<typeof makeEngine>;
let rules: SharingRuleService;
let warn: ReturnType<typeof vi.fn<(msg: any, ...rest: any[]) => void>>;

/** Who currently holds a rule-materialised grant on `recordId`. */
const granteesOf = (recordId: string): string[] =>
(engine._tables.sys_record_share ?? [])
.filter((r) => r.record_id === recordId && r.source === 'rule')
.map((r) => String(r.recipient_id))
.sort();

/** The warn lines this run emitted, as one searchable string each. */
const warnLines = (): string[] => warn.mock.calls.map((c) => String(c[0]));
const emptyExpansionWarns = (): any[][] =>
warn.mock.calls.filter((c) => String(c[0]).includes('expands to NO recipients'));

beforeEach(() => {
engine = makeEngine();
warn = vi.fn();
const sharing = new SharingService({ engine: engine as any });
rules = new SharingRuleService({ engine: engine as any, sharing, logger: { warn } });

// Seed data: units written before any organization existed.
engine.seed('sys_business_unit', [
{ id: 'bu_market', name: 'Market', parent_business_unit_id: null, organization_id: null, active: true },
{ id: 'bu_market_west', name: 'Market West', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
]);
engine.seed('kpi_entry_sheet', [{ id: 'kpi_1', subject: 'bu_market', owner_id: 'author' }]);
});

/** Create the rule the reproduction created, org-stamped like a real one. */
const seedRule = (recipientType: 'business_unit' | 'unit_and_subordinates', organizationId: string | null = ORG_A) => {
engine.seed('sys_sharing_rule', [{
id: 'srule_kpi', organization_id: organizationId, name: RULE,
label: 'KPI sheet → Market', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: recipientType, recipient_id: 'bu_market',
access_level: 'edit', active: true, managed_by: 'package',
}]);
};

describe('the reported defect: 201, active, zero shares, no log', () => {
it('WIDE — `unit_and_subordinates` now materialises the grants', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
const result = await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
expect(result.expandedUsers).toBe(2);
// …and it did so QUIETLY: the new warn is for the empty case only.
expect(emptyExpansionWarns()).toHaveLength(0);
});

it('NARROW — `business_unit` materialises the anchor unit only', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
// The two widths stay two widths (#7807) — the tenant screen moved, the
// subtree boundary did not.
expect(granteesOf('kpi_1')).toEqual(['u_1']);
});
});

describe('the leak the same change would have opened', () => {
it('another organization’s members are never granted through the shared seeded unit', async () => {
// ONE seeded unit id, two tenants' memberships hanging off it — the
// shape that exists on any deployment whose org chart came from a seed.
engine.seed('sys_business_unit_member', [
{ id: 'bum_a', business_unit_id: 'bu_market', user_id: 'u_a', organization_id: ORG_A },
{ id: 'bum_b', business_unit_id: 'bu_market', user_id: 'u_b', organization_id: ORG_B },
]);
seedRule('unit_and_subordinates', ORG_A);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_a']);
expect(granteesOf('kpi_1')).not.toContain('u_b');
});
});

describe('an active rule that grants nobody is LOUD', () => {
it('warns naming the rule, the recipient kind and the unit', async () => {
// Unit and memberships BOTH seeded: the unit resolves now, but org-less
// membership rows are of unknown tenancy and are not members of an
// org-stamped rule. The residual empty expansion is the case this warn
// exists for.
engine.seed('sys_business_unit_member', [
{ id: 'bum_seeded', business_unit_id: 'bu_market', user_id: 'u_seeded' },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);

expect(granteesOf('kpi_1')).toEqual([]);
const calls = emptyExpansionWarns();
expect(calls).toHaveLength(1);
expect(String(calls[0][0])).toContain('organization_id');
expect(calls[0][1]).toMatchObject({
rule: RULE,
object: 'kpi_entry_sheet',
recipientType: 'unit_and_subordinates',
businessUnit: 'bu_market',
organization: ORG_A,
});
});

it('warns for the NARROW width too', async () => {
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(emptyExpansionWarns()[0][1]).toMatchObject({ recipientType: 'business_unit' });
});

it('warns ONCE per rule per process, not once per evaluation', async () => {
// The reconcilers call `expandRecipient` on every matched write. Without
// the dedup one misconfigured rule dominates the deployment's log —
// the same reasoning the inert-criteria warn already carries.
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(rules.emptyUnitExpansionRuleKeys).toEqual(['srule_kpi::bu_market']);
});

it('says nothing when the rule grants somebody', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
expect(warnLines().join('\n')).not.toContain('expands to NO recipients');
expect(rules.emptyUnitExpansionRuleKeys).toEqual([]);
});

it('an INACTIVE rule is not warned about — it is meant to grant nobody', async () => {
engine.seed('sys_sharing_rule', [{
id: 'srule_off', organization_id: ORG_A, name: 'off_rule',
label: 'Off', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: 'unit_and_subordinates', recipient_id: 'bu_market',
access_level: 'edit', active: false, managed_by: 'package',
}]);
await rules.evaluateRule('off_rule', SYS);
expect(emptyExpansionWarns()).toHaveLength(0);
});
});

describe('the org-less rule — the dominant shape today — is unmoved', () => {
it('still expands every member of the seeded tree, stamped or not', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2' },
]);
seedRule('unit_and_subordinates', null);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
});
});
});
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
Closed
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-graph-tenant-screen.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Sharing rules with a business-unit recipient no longer grant nothing when the unit was created by seed data.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own tenant screen (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`. 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, matching what `plugin-approvals` already did for the same rows.

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; 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.

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 memberships 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:440`, `:494`, `:498`, `:571`, `:601` |
| 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:164`, `:389` |

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

Expand Down
281 changes: 281 additions & 0 deletions packages/plugins/plugin-sharing/src/bu-rule-tenant-screen.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14547] A business-unit sharing rule against a SEEDED unit, end to end.
*
* `business-unit-graph.test.ts` pins the two screens at the graph service.
* This file drives the whole rule path — `evaluateRule` → `expandRecipient` →
* `reconcile` → `sys_record_share` — because that is the layer the defect was
* reported at and the layer at which it was silent: the rule was accepted, it
* stayed `active: true`, it materialised zero grants, and nothing was logged.
* Asserting the graph's return value alone would leave every one of those
* observable facts unpinned.
*
* The fixture is the reported reproduction's shape, not an invented one:
*
* - `sys_business_unit` rows come from app SEED data and carry
* `organization_id = NULL` — a seed cannot know the id the runtime mints
* at boot;
* - `sys_business_unit_member` rows are POSTed through the REST data API and
* ARE organization-stamped (the engine threads the caller's tenant and the
* SQL driver stamps the injected column);
* - the `sys_sharing_rule` row is created by an organization admin and is
* org-stamped too (an explicit `organization_id: null` in the payload is
* overridden).
*
* Two of those three carry an organization and one does not, which is exactly
* the combination the strict unit screen turned into zero grants.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
import { SharingService } from './sharing-service.js';
import { SharingRuleService } from './sharing-rule-service.js';

interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const ORG_A = 'org_a';
const ORG_B = 'org_b';

/**
* Filter matcher over the operators this path actually emits.
*
* `organization_id: null` must match a row that OMITS the column, because that
* is what a NULL column reads back as and the whole `$or` arm exists for it. A
* fake that answered otherwise would report the widened screen as still broken
* — or, worse, report a screen that never widened as fixed.
*/
function matches(row: Row, f: any): boolean {
if (!f || typeof f !== 'object') return true;
for (const [k, v] of Object.entries(f)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k === '$and') {
if (!(v as any[]).every((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
const rv = row[k];
if (v === null) {
if (rv != null) return false;
continue;
}
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
const op: any = v;
if ('$in' in op) { if (!op.$in.includes(rv)) return false; continue; }
// `descendants()` filters children with `active: { $ne: false }`, so an
// undefined `active` must PASS — the graph treats absent as active.
if ('$ne' in op) { if (rv === op.$ne) return false; continue; }
if ('$gte' in op) { if (!(rv >= op.$gte)) return false; continue; }
}
if (rv !== v) return false;
}
return true;
}

function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
let seq = 0;
return {
_tables: tables,
getSchema() { return undefined; },
seed(object: string, rows: Row[]) { ensure(object).push(...rows.map((r) => ({ ...r }))); },
async find(o: string, opts?: any) {
const f = opts?.filter ?? opts?.where;
return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000);
},
async insert(o: string, data: any) {
const row = { id: data.id ?? `${o}_${++seq}`, ...data };
ensure(o).push(row);
return row;
},
// The PRODUCER's own dispatch predicates, so a fixture that drifts to a
// call shape `ObjectQL` would refuse fails here instead of going green.
async update(o: string, data: any, options?: any) {
const verdict = assertEngineUpdateDispatch(data, options);
const t = ensure(o);
const targets = verdict.kind === 'by-id'
? t.filter((r) => r.id === verdict.id)
: t.filter((r) => matches(r, options?.where));
for (const r of targets) Object.assign(r, data);
return verdict.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
},
async delete(o: string, opts?: any) {
assertEngineDeleteDispatch(opts);
const t = ensure(o);
const where = opts?.where ?? (opts?.id != null ? { id: opts.id } : {});
for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
return { ok: true };
},
};
}

const RULE = 'kpi_sheet_to_market_unit';

describe('#14547 — an org-stamped rule against a SEEDED business unit', () => {
let engine: ReturnType<typeof makeEngine>;
let rules: SharingRuleService;
let warn: ReturnType<typeof vi.fn<(msg: any, ...rest: any[]) => void>>;

/** Who currently holds a rule-materialised grant on `recordId`. */
const granteesOf = (recordId: string): string[] =>
(engine._tables.sys_record_share ?? [])
.filter((r) => r.record_id === recordId && r.source === 'rule')
.map((r) => String(r.recipient_id))
.sort();

/** The warn lines this run emitted, as one searchable string each. */
const warnLines = (): string[] => warn.mock.calls.map((c) => String(c[0]));
const emptyExpansionWarns = (): any[][] =>
warn.mock.calls.filter((c) => String(c[0]).includes('expands to NO recipients'));

beforeEach(() => {
engine = makeEngine();
warn = vi.fn();
const sharing = new SharingService({ engine: engine as any });
rules = new SharingRuleService({ engine: engine as any, sharing, logger: { warn } });

// Seed data: units written before any organization existed.
engine.seed('sys_business_unit', [
{ id: 'bu_market', name: 'Market', parent_business_unit_id: null, organization_id: null, active: true },
{ id: 'bu_market_west', name: 'Market West', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
]);
engine.seed('kpi_entry_sheet', [{ id: 'kpi_1', subject: 'bu_market', owner_id: 'author' }]);
});

/** Create the rule the reproduction created, org-stamped like a real one. */
const seedRule = (recipientType: 'business_unit' | 'unit_and_subordinates', organizationId: string | null = ORG_A) => {
engine.seed('sys_sharing_rule', [{
id: 'srule_kpi', organization_id: organizationId, name: RULE,
label: 'KPI sheet → Market', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: recipientType, recipient_id: 'bu_market',
access_level: 'edit', active: true, managed_by: 'package',
}]);
};

describe('the reported defect: 201, active, zero shares, no log', () => {
it('WIDE — `unit_and_subordinates` now materialises the grants', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
const result = await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
expect(result.expandedUsers).toBe(2);
// …and it did so QUIETLY: the new warn is for the empty case only.
expect(emptyExpansionWarns()).toHaveLength(0);
});

it('NARROW — `business_unit` materialises the anchor unit only', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
// The two widths stay two widths (#7807) — the tenant screen moved, the
// subtree boundary did not.
expect(granteesOf('kpi_1')).toEqual(['u_1']);
});
});

describe('the leak the same change would have opened', () => {
it('another organization’s members are never granted through the shared seeded unit', async () => {
// ONE seeded unit id, two tenants' memberships hanging off it — the
// shape that exists on any deployment whose org chart came from a seed.
engine.seed('sys_business_unit_member', [
{ id: 'bum_a', business_unit_id: 'bu_market', user_id: 'u_a', organization_id: ORG_A },
{ id: 'bum_b', business_unit_id: 'bu_market', user_id: 'u_b', organization_id: ORG_B },
]);
seedRule('unit_and_subordinates', ORG_A);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_a']);
expect(granteesOf('kpi_1')).not.toContain('u_b');
});
});

describe('an active rule that grants nobody is LOUD', () => {
it('warns naming the rule, the recipient kind and the unit', async () => {
// Unit and memberships BOTH seeded: the unit resolves now, but org-less
// membership rows are of unknown tenancy and are not members of an
// org-stamped rule. The residual empty expansion is the case this warn
// exists for.
engine.seed('sys_business_unit_member', [
{ id: 'bum_seeded', business_unit_id: 'bu_market', user_id: 'u_seeded' },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);

expect(granteesOf('kpi_1')).toEqual([]);
const calls = emptyExpansionWarns();
expect(calls).toHaveLength(1);
expect(String(calls[0][0])).toContain('organization_id');
expect(calls[0][1]).toMatchObject({
rule: RULE,
object: 'kpi_entry_sheet',
recipientType: 'unit_and_subordinates',
businessUnit: 'bu_market',
organization: ORG_A,
});
});

it('warns for the NARROW width too', async () => {
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(emptyExpansionWarns()[0][1]).toMatchObject({ recipientType: 'business_unit' });
});

it('warns ONCE per rule per process, not once per evaluation', async () => {
// The reconcilers call `expandRecipient` on every matched write. Without
// the dedup one misconfigured rule dominates the deployment's log —
// the same reasoning the inert-criteria warn already carries.
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(rules.emptyUnitExpansionRuleKeys).toEqual(['srule_kpi::bu_market']);
});

it('says nothing when the rule grants somebody', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
expect(warnLines().join('\n')).not.toContain('expands to NO recipients');
expect(rules.emptyUnitExpansionRuleKeys).toEqual([]);
});

it('an INACTIVE rule is not warned about — it is meant to grant nobody', async () => {
engine.seed('sys_sharing_rule', [{
id: 'srule_off', organization_id: ORG_A, name: 'off_rule',
label: 'Off', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: 'unit_and_subordinates', recipient_id: 'bu_market',
access_level: 'edit', active: false, managed_by: 'package',
}]);
await rules.evaluateRule('off_rule', SYS);
expect(emptyExpansionWarns()).toHaveLength(0);
});
});

describe('the org-less rule — the dominant shape today — is unmoved', () => {
it('still expands every member of the seeded tree, stamped or not', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2' },
]);
seedRule('unit_and_subordinates', null);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
});
});
});
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
Closed
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-graph-tenant-screen.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Sharing rules with a business-unit recipient no longer grant nothing when the unit was created by seed data.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own tenant screen (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`. 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, matching what `plugin-approvals` already did for the same rows.

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; 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.

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 memberships 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:440`, `:494`, `:498`, `:571`, `:601` |
| 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:164`, `:389` |

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

Expand Down
281 changes: 281 additions & 0 deletions packages/plugins/plugin-sharing/src/bu-rule-tenant-screen.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14547] A business-unit sharing rule against a SEEDED unit, end to end.
*
* `business-unit-graph.test.ts` pins the two screens at the graph service.
* This file drives the whole rule path — `evaluateRule` → `expandRecipient` →
* `reconcile` → `sys_record_share` — because that is the layer the defect was
* reported at and the layer at which it was silent: the rule was accepted, it
* stayed `active: true`, it materialised zero grants, and nothing was logged.
* Asserting the graph's return value alone would leave every one of those
* observable facts unpinned.
*
* The fixture is the reported reproduction's shape, not an invented one:
*
* - `sys_business_unit` rows come from app SEED data and carry
* `organization_id = NULL` — a seed cannot know the id the runtime mints
* at boot;
* - `sys_business_unit_member` rows are POSTed through the REST data API and
* ARE organization-stamped (the engine threads the caller's tenant and the
* SQL driver stamps the injected column);
* - the `sys_sharing_rule` row is created by an organization admin and is
* org-stamped too (an explicit `organization_id: null` in the payload is
* overridden).
*
* Two of those three carry an organization and one does not, which is exactly
* the combination the strict unit screen turned into zero grants.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
import { SharingService } from './sharing-service.js';
import { SharingRuleService } from './sharing-rule-service.js';

interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const ORG_A = 'org_a';
const ORG_B = 'org_b';

/**
* Filter matcher over the operators this path actually emits.
*
* `organization_id: null` must match a row that OMITS the column, because that
* is what a NULL column reads back as and the whole `$or` arm exists for it. A
* fake that answered otherwise would report the widened screen as still broken
* — or, worse, report a screen that never widened as fixed.
*/
function matches(row: Row, f: any): boolean {
if (!f || typeof f !== 'object') return true;
for (const [k, v] of Object.entries(f)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k === '$and') {
if (!(v as any[]).every((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
const rv = row[k];
if (v === null) {
if (rv != null) return false;
continue;
}
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
const op: any = v;
if ('$in' in op) { if (!op.$in.includes(rv)) return false; continue; }
// `descendants()` filters children with `active: { $ne: false }`, so an
// undefined `active` must PASS — the graph treats absent as active.
if ('$ne' in op) { if (rv === op.$ne) return false; continue; }
if ('$gte' in op) { if (!(rv >= op.$gte)) return false; continue; }
}
if (rv !== v) return false;
}
return true;
}

function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
let seq = 0;
return {
_tables: tables,
getSchema() { return undefined; },
seed(object: string, rows: Row[]) { ensure(object).push(...rows.map((r) => ({ ...r }))); },
async find(o: string, opts?: any) {
const f = opts?.filter ?? opts?.where;
return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000);
},
async insert(o: string, data: any) {
const row = { id: data.id ?? `${o}_${++seq}`, ...data };
ensure(o).push(row);
return row;
},
// The PRODUCER's own dispatch predicates, so a fixture that drifts to a
// call shape `ObjectQL` would refuse fails here instead of going green.
async update(o: string, data: any, options?: any) {
const verdict = assertEngineUpdateDispatch(data, options);
const t = ensure(o);
const targets = verdict.kind === 'by-id'
? t.filter((r) => r.id === verdict.id)
: t.filter((r) => matches(r, options?.where));
for (const r of targets) Object.assign(r, data);
return verdict.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
},
async delete(o: string, opts?: any) {
assertEngineDeleteDispatch(opts);
const t = ensure(o);
const where = opts?.where ?? (opts?.id != null ? { id: opts.id } : {});
for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
return { ok: true };
},
};
}

const RULE = 'kpi_sheet_to_market_unit';

describe('#14547 — an org-stamped rule against a SEEDED business unit', () => {
let engine: ReturnType<typeof makeEngine>;
let rules: SharingRuleService;
let warn: ReturnType<typeof vi.fn<(msg: any, ...rest: any[]) => void>>;

/** Who currently holds a rule-materialised grant on `recordId`. */
const granteesOf = (recordId: string): string[] =>
(engine._tables.sys_record_share ?? [])
.filter((r) => r.record_id === recordId && r.source === 'rule')
.map((r) => String(r.recipient_id))
.sort();

/** The warn lines this run emitted, as one searchable string each. */
const warnLines = (): string[] => warn.mock.calls.map((c) => String(c[0]));
const emptyExpansionWarns = (): any[][] =>
warn.mock.calls.filter((c) => String(c[0]).includes('expands to NO recipients'));

beforeEach(() => {
engine = makeEngine();
warn = vi.fn();
const sharing = new SharingService({ engine: engine as any });
rules = new SharingRuleService({ engine: engine as any, sharing, logger: { warn } });

// Seed data: units written before any organization existed.
engine.seed('sys_business_unit', [
{ id: 'bu_market', name: 'Market', parent_business_unit_id: null, organization_id: null, active: true },
{ id: 'bu_market_west', name: 'Market West', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
]);
engine.seed('kpi_entry_sheet', [{ id: 'kpi_1', subject: 'bu_market', owner_id: 'author' }]);
});

/** Create the rule the reproduction created, org-stamped like a real one. */
const seedRule = (recipientType: 'business_unit' | 'unit_and_subordinates', organizationId: string | null = ORG_A) => {
engine.seed('sys_sharing_rule', [{
id: 'srule_kpi', organization_id: organizationId, name: RULE,
label: 'KPI sheet → Market', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: recipientType, recipient_id: 'bu_market',
access_level: 'edit', active: true, managed_by: 'package',
}]);
};

describe('the reported defect: 201, active, zero shares, no log', () => {
it('WIDE — `unit_and_subordinates` now materialises the grants', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
const result = await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
expect(result.expandedUsers).toBe(2);
// …and it did so QUIETLY: the new warn is for the empty case only.
expect(emptyExpansionWarns()).toHaveLength(0);
});

it('NARROW — `business_unit` materialises the anchor unit only', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
// The two widths stay two widths (#7807) — the tenant screen moved, the
// subtree boundary did not.
expect(granteesOf('kpi_1')).toEqual(['u_1']);
});
});

describe('the leak the same change would have opened', () => {
it('another organization’s members are never granted through the shared seeded unit', async () => {
// ONE seeded unit id, two tenants' memberships hanging off it — the
// shape that exists on any deployment whose org chart came from a seed.
engine.seed('sys_business_unit_member', [
{ id: 'bum_a', business_unit_id: 'bu_market', user_id: 'u_a', organization_id: ORG_A },
{ id: 'bum_b', business_unit_id: 'bu_market', user_id: 'u_b', organization_id: ORG_B },
]);
seedRule('unit_and_subordinates', ORG_A);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_a']);
expect(granteesOf('kpi_1')).not.toContain('u_b');
});
});

describe('an active rule that grants nobody is LOUD', () => {
it('warns naming the rule, the recipient kind and the unit', async () => {
// Unit and memberships BOTH seeded: the unit resolves now, but org-less
// membership rows are of unknown tenancy and are not members of an
// org-stamped rule. The residual empty expansion is the case this warn
// exists for.
engine.seed('sys_business_unit_member', [
{ id: 'bum_seeded', business_unit_id: 'bu_market', user_id: 'u_seeded' },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);

expect(granteesOf('kpi_1')).toEqual([]);
const calls = emptyExpansionWarns();
expect(calls).toHaveLength(1);
expect(String(calls[0][0])).toContain('organization_id');
expect(calls[0][1]).toMatchObject({
rule: RULE,
object: 'kpi_entry_sheet',
recipientType: 'unit_and_subordinates',
businessUnit: 'bu_market',
organization: ORG_A,
});
});

it('warns for the NARROW width too', async () => {
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(emptyExpansionWarns()[0][1]).toMatchObject({ recipientType: 'business_unit' });
});

it('warns ONCE per rule per process, not once per evaluation', async () => {
// The reconcilers call `expandRecipient` on every matched write. Without
// the dedup one misconfigured rule dominates the deployment's log —
// the same reasoning the inert-criteria warn already carries.
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(rules.emptyUnitExpansionRuleKeys).toEqual(['srule_kpi::bu_market']);
});

it('says nothing when the rule grants somebody', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
expect(warnLines().join('\n')).not.toContain('expands to NO recipients');
expect(rules.emptyUnitExpansionRuleKeys).toEqual([]);
});

it('an INACTIVE rule is not warned about — it is meant to grant nobody', async () => {
engine.seed('sys_sharing_rule', [{
id: 'srule_off', organization_id: ORG_A, name: 'off_rule',
label: 'Off', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: 'unit_and_subordinates', recipient_id: 'bu_market',
access_level: 'edit', active: false, managed_by: 'package',
}]);
await rules.evaluateRule('off_rule', SYS);
expect(emptyExpansionWarns()).toHaveLength(0);
});
});

describe('the org-less rule — the dominant shape today — is unmoved', () => {
it('still expands every member of the seeded tree, stamped or not', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2' },
]);
seedRule('unit_and_subordinates', null);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
});
});
});
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
Closed
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-graph-tenant-screen.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Sharing rules with a business-unit recipient no longer grant nothing when the unit was created by seed data.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own tenant screen (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`. 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, matching what `plugin-approvals` already did for the same rows.

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; 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.

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 memberships 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:440`, `:494`, `:498`, `:571`, `:601` |
| 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:164`, `:389` |

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

Expand Down
281 changes: 281 additions & 0 deletions packages/plugins/plugin-sharing/src/bu-rule-tenant-screen.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14547] A business-unit sharing rule against a SEEDED unit, end to end.
*
* `business-unit-graph.test.ts` pins the two screens at the graph service.
* This file drives the whole rule path — `evaluateRule` → `expandRecipient` →
* `reconcile` → `sys_record_share` — because that is the layer the defect was
* reported at and the layer at which it was silent: the rule was accepted, it
* stayed `active: true`, it materialised zero grants, and nothing was logged.
* Asserting the graph's return value alone would leave every one of those
* observable facts unpinned.
*
* The fixture is the reported reproduction's shape, not an invented one:
*
* - `sys_business_unit` rows come from app SEED data and carry
* `organization_id = NULL` — a seed cannot know the id the runtime mints
* at boot;
* - `sys_business_unit_member` rows are POSTed through the REST data API and
* ARE organization-stamped (the engine threads the caller's tenant and the
* SQL driver stamps the injected column);
* - the `sys_sharing_rule` row is created by an organization admin and is
* org-stamped too (an explicit `organization_id: null` in the payload is
* overridden).
*
* Two of those three carry an organization and one does not, which is exactly
* the combination the strict unit screen turned into zero grants.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
import { SharingService } from './sharing-service.js';
import { SharingRuleService } from './sharing-rule-service.js';

interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const ORG_A = 'org_a';
const ORG_B = 'org_b';

/**
* Filter matcher over the operators this path actually emits.
*
* `organization_id: null` must match a row that OMITS the column, because that
* is what a NULL column reads back as and the whole `$or` arm exists for it. A
* fake that answered otherwise would report the widened screen as still broken
* — or, worse, report a screen that never widened as fixed.
*/
function matches(row: Row, f: any): boolean {
if (!f || typeof f !== 'object') return true;
for (const [k, v] of Object.entries(f)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k === '$and') {
if (!(v as any[]).every((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
const rv = row[k];
if (v === null) {
if (rv != null) return false;
continue;
}
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
const op: any = v;
if ('$in' in op) { if (!op.$in.includes(rv)) return false; continue; }
// `descendants()` filters children with `active: { $ne: false }`, so an
// undefined `active` must PASS — the graph treats absent as active.
if ('$ne' in op) { if (rv === op.$ne) return false; continue; }
if ('$gte' in op) { if (!(rv >= op.$gte)) return false; continue; }
}
if (rv !== v) return false;
}
return true;
}

function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
let seq = 0;
return {
_tables: tables,
getSchema() { return undefined; },
seed(object: string, rows: Row[]) { ensure(object).push(...rows.map((r) => ({ ...r }))); },
async find(o: string, opts?: any) {
const f = opts?.filter ?? opts?.where;
return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000);
},
async insert(o: string, data: any) {
const row = { id: data.id ?? `${o}_${++seq}`, ...data };
ensure(o).push(row);
return row;
},
// The PRODUCER's own dispatch predicates, so a fixture that drifts to a
// call shape `ObjectQL` would refuse fails here instead of going green.
async update(o: string, data: any, options?: any) {
const verdict = assertEngineUpdateDispatch(data, options);
const t = ensure(o);
const targets = verdict.kind === 'by-id'
? t.filter((r) => r.id === verdict.id)
: t.filter((r) => matches(r, options?.where));
for (const r of targets) Object.assign(r, data);
return verdict.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
},
async delete(o: string, opts?: any) {
assertEngineDeleteDispatch(opts);
const t = ensure(o);
const where = opts?.where ?? (opts?.id != null ? { id: opts.id } : {});
for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
return { ok: true };
},
};
}

const RULE = 'kpi_sheet_to_market_unit';

describe('#14547 — an org-stamped rule against a SEEDED business unit', () => {
let engine: ReturnType<typeof makeEngine>;
let rules: SharingRuleService;
let warn: ReturnType<typeof vi.fn<(msg: any, ...rest: any[]) => void>>;

/** Who currently holds a rule-materialised grant on `recordId`. */
const granteesOf = (recordId: string): string[] =>
(engine._tables.sys_record_share ?? [])
.filter((r) => r.record_id === recordId && r.source === 'rule')
.map((r) => String(r.recipient_id))
.sort();

/** The warn lines this run emitted, as one searchable string each. */
const warnLines = (): string[] => warn.mock.calls.map((c) => String(c[0]));
const emptyExpansionWarns = (): any[][] =>
warn.mock.calls.filter((c) => String(c[0]).includes('expands to NO recipients'));

beforeEach(() => {
engine = makeEngine();
warn = vi.fn();
const sharing = new SharingService({ engine: engine as any });
rules = new SharingRuleService({ engine: engine as any, sharing, logger: { warn } });

// Seed data: units written before any organization existed.
engine.seed('sys_business_unit', [
{ id: 'bu_market', name: 'Market', parent_business_unit_id: null, organization_id: null, active: true },
{ id: 'bu_market_west', name: 'Market West', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
]);
engine.seed('kpi_entry_sheet', [{ id: 'kpi_1', subject: 'bu_market', owner_id: 'author' }]);
});

/** Create the rule the reproduction created, org-stamped like a real one. */
const seedRule = (recipientType: 'business_unit' | 'unit_and_subordinates', organizationId: string | null = ORG_A) => {
engine.seed('sys_sharing_rule', [{
id: 'srule_kpi', organization_id: organizationId, name: RULE,
label: 'KPI sheet → Market', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: recipientType, recipient_id: 'bu_market',
access_level: 'edit', active: true, managed_by: 'package',
}]);
};

describe('the reported defect: 201, active, zero shares, no log', () => {
it('WIDE — `unit_and_subordinates` now materialises the grants', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
const result = await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
expect(result.expandedUsers).toBe(2);
// …and it did so QUIETLY: the new warn is for the empty case only.
expect(emptyExpansionWarns()).toHaveLength(0);
});

it('NARROW — `business_unit` materialises the anchor unit only', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
// The two widths stay two widths (#7807) — the tenant screen moved, the
// subtree boundary did not.
expect(granteesOf('kpi_1')).toEqual(['u_1']);
});
});

describe('the leak the same change would have opened', () => {
it('another organization’s members are never granted through the shared seeded unit', async () => {
// ONE seeded unit id, two tenants' memberships hanging off it — the
// shape that exists on any deployment whose org chart came from a seed.
engine.seed('sys_business_unit_member', [
{ id: 'bum_a', business_unit_id: 'bu_market', user_id: 'u_a', organization_id: ORG_A },
{ id: 'bum_b', business_unit_id: 'bu_market', user_id: 'u_b', organization_id: ORG_B },
]);
seedRule('unit_and_subordinates', ORG_A);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_a']);
expect(granteesOf('kpi_1')).not.toContain('u_b');
});
});

describe('an active rule that grants nobody is LOUD', () => {
it('warns naming the rule, the recipient kind and the unit', async () => {
// Unit and memberships BOTH seeded: the unit resolves now, but org-less
// membership rows are of unknown tenancy and are not members of an
// org-stamped rule. The residual empty expansion is the case this warn
// exists for.
engine.seed('sys_business_unit_member', [
{ id: 'bum_seeded', business_unit_id: 'bu_market', user_id: 'u_seeded' },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);

expect(granteesOf('kpi_1')).toEqual([]);
const calls = emptyExpansionWarns();
expect(calls).toHaveLength(1);
expect(String(calls[0][0])).toContain('organization_id');
expect(calls[0][1]).toMatchObject({
rule: RULE,
object: 'kpi_entry_sheet',
recipientType: 'unit_and_subordinates',
businessUnit: 'bu_market',
organization: ORG_A,
});
});

it('warns for the NARROW width too', async () => {
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(emptyExpansionWarns()[0][1]).toMatchObject({ recipientType: 'business_unit' });
});

it('warns ONCE per rule per process, not once per evaluation', async () => {
// The reconcilers call `expandRecipient` on every matched write. Without
// the dedup one misconfigured rule dominates the deployment's log —
// the same reasoning the inert-criteria warn already carries.
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(rules.emptyUnitExpansionRuleKeys).toEqual(['srule_kpi::bu_market']);
});

it('says nothing when the rule grants somebody', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
expect(warnLines().join('\n')).not.toContain('expands to NO recipients');
expect(rules.emptyUnitExpansionRuleKeys).toEqual([]);
});

it('an INACTIVE rule is not warned about — it is meant to grant nobody', async () => {
engine.seed('sys_sharing_rule', [{
id: 'srule_off', organization_id: ORG_A, name: 'off_rule',
label: 'Off', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: 'unit_and_subordinates', recipient_id: 'bu_market',
access_level: 'edit', active: false, managed_by: 'package',
}]);
await rules.evaluateRule('off_rule', SYS);
expect(emptyExpansionWarns()).toHaveLength(0);
});
});

describe('the org-less rule — the dominant shape today — is unmoved', () => {
it('still expands every member of the seeded tree, stamped or not', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2' },
]);
seedRule('unit_and_subordinates', null);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
});
});
});
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
Closed
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-graph-tenant-screen.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
---

Sharing rules with a business-unit recipient no longer grant nothing when the unit was created by seed data.

`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own tenant screen (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`. 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, matching what `plugin-approvals` already did for the same rows.

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; 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.

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 memberships 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:440`, `:494`, `:498`, `:571`, `:601` |
| 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:164`, `:389` |

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

Expand Down
281 changes: 281 additions & 0 deletions packages/plugins/plugin-sharing/src/bu-rule-tenant-screen.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14547] A business-unit sharing rule against a SEEDED unit, end to end.
*
* `business-unit-graph.test.ts` pins the two screens at the graph service.
* This file drives the whole rule path — `evaluateRule` → `expandRecipient` →
* `reconcile` → `sys_record_share` — because that is the layer the defect was
* reported at and the layer at which it was silent: the rule was accepted, it
* stayed `active: true`, it materialised zero grants, and nothing was logged.
* Asserting the graph's return value alone would leave every one of those
* observable facts unpinned.
*
* The fixture is the reported reproduction's shape, not an invented one:
*
* - `sys_business_unit` rows come from app SEED data and carry
* `organization_id = NULL` — a seed cannot know the id the runtime mints
* at boot;
* - `sys_business_unit_member` rows are POSTed through the REST data API and
* ARE organization-stamped (the engine threads the caller's tenant and the
* SQL driver stamps the injected column);
* - the `sys_sharing_rule` row is created by an organization admin and is
* org-stamped too (an explicit `organization_id: null` in the payload is
* overridden).
*
* Two of those three carry an organization and one does not, which is exactly
* the combination the strict unit screen turned into zero grants.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
import { SharingService } from './sharing-service.js';
import { SharingRuleService } from './sharing-rule-service.js';

interface Row { [k: string]: any }

const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const ORG_A = 'org_a';
const ORG_B = 'org_b';

/**
* Filter matcher over the operators this path actually emits.
*
* `organization_id: null` must match a row that OMITS the column, because that
* is what a NULL column reads back as and the whole `$or` arm exists for it. A
* fake that answered otherwise would report the widened screen as still broken
* — or, worse, report a screen that never widened as fixed.
*/
function matches(row: Row, f: any): boolean {
if (!f || typeof f !== 'object') return true;
for (const [k, v] of Object.entries(f)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k === '$and') {
if (!(v as any[]).every((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
const rv = row[k];
if (v === null) {
if (rv != null) return false;
continue;
}
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
const op: any = v;
if ('$in' in op) { if (!op.$in.includes(rv)) return false; continue; }
// `descendants()` filters children with `active: { $ne: false }`, so an
// undefined `active` must PASS — the graph treats absent as active.
if ('$ne' in op) { if (rv === op.$ne) return false; continue; }
if ('$gte' in op) { if (!(rv >= op.$gte)) return false; continue; }
}
if (rv !== v) return false;
}
return true;
}

function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
let seq = 0;
return {
_tables: tables,
getSchema() { return undefined; },
seed(object: string, rows: Row[]) { ensure(object).push(...rows.map((r) => ({ ...r }))); },
async find(o: string, opts?: any) {
const f = opts?.filter ?? opts?.where;
return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000);
},
async insert(o: string, data: any) {
const row = { id: data.id ?? `${o}_${++seq}`, ...data };
ensure(o).push(row);
return row;
},
// The PRODUCER's own dispatch predicates, so a fixture that drifts to a
// call shape `ObjectQL` would refuse fails here instead of going green.
async update(o: string, data: any, options?: any) {
const verdict = assertEngineUpdateDispatch(data, options);
const t = ensure(o);
const targets = verdict.kind === 'by-id'
? t.filter((r) => r.id === verdict.id)
: t.filter((r) => matches(r, options?.where));
for (const r of targets) Object.assign(r, data);
return verdict.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
},
async delete(o: string, opts?: any) {
assertEngineDeleteDispatch(opts);
const t = ensure(o);
const where = opts?.where ?? (opts?.id != null ? { id: opts.id } : {});
for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
return { ok: true };
},
};
}

const RULE = 'kpi_sheet_to_market_unit';

describe('#14547 — an org-stamped rule against a SEEDED business unit', () => {
let engine: ReturnType<typeof makeEngine>;
let rules: SharingRuleService;
let warn: ReturnType<typeof vi.fn<(msg: any, ...rest: any[]) => void>>;

/** Who currently holds a rule-materialised grant on `recordId`. */
const granteesOf = (recordId: string): string[] =>
(engine._tables.sys_record_share ?? [])
.filter((r) => r.record_id === recordId && r.source === 'rule')
.map((r) => String(r.recipient_id))
.sort();

/** The warn lines this run emitted, as one searchable string each. */
const warnLines = (): string[] => warn.mock.calls.map((c) => String(c[0]));
const emptyExpansionWarns = (): any[][] =>
warn.mock.calls.filter((c) => String(c[0]).includes('expands to NO recipients'));

beforeEach(() => {
engine = makeEngine();
warn = vi.fn();
const sharing = new SharingService({ engine: engine as any });
rules = new SharingRuleService({ engine: engine as any, sharing, logger: { warn } });

// Seed data: units written before any organization existed.
engine.seed('sys_business_unit', [
{ id: 'bu_market', name: 'Market', parent_business_unit_id: null, organization_id: null, active: true },
{ id: 'bu_market_west', name: 'Market West', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
]);
engine.seed('kpi_entry_sheet', [{ id: 'kpi_1', subject: 'bu_market', owner_id: 'author' }]);
});

/** Create the rule the reproduction created, org-stamped like a real one. */
const seedRule = (recipientType: 'business_unit' | 'unit_and_subordinates', organizationId: string | null = ORG_A) => {
engine.seed('sys_sharing_rule', [{
id: 'srule_kpi', organization_id: organizationId, name: RULE,
label: 'KPI sheet → Market', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: recipientType, recipient_id: 'bu_market',
access_level: 'edit', active: true, managed_by: 'package',
}]);
};

describe('the reported defect: 201, active, zero shares, no log', () => {
it('WIDE — `unit_and_subordinates` now materialises the grants', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
const result = await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
expect(result.expandedUsers).toBe(2);
// …and it did so QUIETLY: the new warn is for the empty case only.
expect(emptyExpansionWarns()).toHaveLength(0);
});

it('NARROW — `business_unit` materialises the anchor unit only', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
]);
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
// The two widths stay two widths (#7807) — the tenant screen moved, the
// subtree boundary did not.
expect(granteesOf('kpi_1')).toEqual(['u_1']);
});
});

describe('the leak the same change would have opened', () => {
it('another organization’s members are never granted through the shared seeded unit', async () => {
// ONE seeded unit id, two tenants' memberships hanging off it — the
// shape that exists on any deployment whose org chart came from a seed.
engine.seed('sys_business_unit_member', [
{ id: 'bum_a', business_unit_id: 'bu_market', user_id: 'u_a', organization_id: ORG_A },
{ id: 'bum_b', business_unit_id: 'bu_market', user_id: 'u_b', organization_id: ORG_B },
]);
seedRule('unit_and_subordinates', ORG_A);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_a']);
expect(granteesOf('kpi_1')).not.toContain('u_b');
});
});

describe('an active rule that grants nobody is LOUD', () => {
it('warns naming the rule, the recipient kind and the unit', async () => {
// Unit and memberships BOTH seeded: the unit resolves now, but org-less
// membership rows are of unknown tenancy and are not members of an
// org-stamped rule. The residual empty expansion is the case this warn
// exists for.
engine.seed('sys_business_unit_member', [
{ id: 'bum_seeded', business_unit_id: 'bu_market', user_id: 'u_seeded' },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);

expect(granteesOf('kpi_1')).toEqual([]);
const calls = emptyExpansionWarns();
expect(calls).toHaveLength(1);
expect(String(calls[0][0])).toContain('organization_id');
expect(calls[0][1]).toMatchObject({
rule: RULE,
object: 'kpi_entry_sheet',
recipientType: 'unit_and_subordinates',
businessUnit: 'bu_market',
organization: ORG_A,
});
});

it('warns for the NARROW width too', async () => {
seedRule('business_unit');
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(emptyExpansionWarns()[0][1]).toMatchObject({ recipientType: 'business_unit' });
});

it('warns ONCE per rule per process, not once per evaluation', async () => {
// The reconcilers call `expandRecipient` on every matched write. Without
// the dedup one misconfigured rule dominates the deployment's log —
// the same reasoning the inert-criteria warn already carries.
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
await rules.evaluateRule(RULE, SYS);
expect(emptyExpansionWarns()).toHaveLength(1);
expect(rules.emptyUnitExpansionRuleKeys).toEqual(['srule_kpi::bu_market']);
});

it('says nothing when the rule grants somebody', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
]);
seedRule('unit_and_subordinates');
await rules.evaluateRule(RULE, SYS);
expect(warnLines().join('\n')).not.toContain('expands to NO recipients');
expect(rules.emptyUnitExpansionRuleKeys).toEqual([]);
});

it('an INACTIVE rule is not warned about — it is meant to grant nobody', async () => {
engine.seed('sys_sharing_rule', [{
id: 'srule_off', organization_id: ORG_A, name: 'off_rule',
label: 'Off', object_name: 'kpi_entry_sheet',
criteria_json: JSON.stringify({ subject: 'bu_market' }),
recipient_type: 'unit_and_subordinates', recipient_id: 'bu_market',
access_level: 'edit', active: false, managed_by: 'package',
}]);
await rules.evaluateRule('off_rule', SYS);
expect(emptyExpansionWarns()).toHaveLength(0);
});
});

describe('the org-less rule — the dominant shape today — is unmoved', () => {
it('still expands every member of the seeded tree, stamped or not', async () => {
engine.seed('sys_business_unit_member', [
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2' },
]);
seedRule('unit_and_subordinates', null);
await rules.evaluateRule(RULE, SYS);
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
});
});
});
Loading
Loading