diff --git a/.changeset/fix-single-tenant-followup.md b/.changeset/fix-single-tenant-followup.md new file mode 100644 index 0000000000..9ffff2d7eb --- /dev/null +++ b/.changeset/fix-single-tenant-followup.md @@ -0,0 +1,16 @@ +--- +"@objectstack/platform-objects": patch +"@objectstack/plugin-sharing": patch +--- + +Single-tenant audit follow-ups (ADR-0057): + +- **`sys_member` / `sys_invitation`**: make `organization_id` optional (same class as the + sys_business_unit/sys_team fix #2178). Single-tenant has no org row and no auto-stamp; + multi-tenant still auto-stamps via OrgScopingPlugin with null-org rows hidden by + tenant-isolation RLS (fail-closed). Completes the org-scoped identity graph's + single-tenant consistency. +- **`BusinessUnitGraphService.headOf()`**: add the missing `orgScope()` org filter (it + queries under SYSTEM_CTX, bypassing RLS, so the scope is the only isolation). Previously + `headOf(buId)` read a business unit's `manager_user_id` by id alone — a cross-organization + leak in multi-tenant. Now consistent with `descendants()`. +regression test. diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index fcf235af77..6f6937d6e9 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -172,7 +172,10 @@ export const SysInvitation = ObjectSchema.create({ organization_id: Field.lookup('sys_organization', { label: 'Organization', - required: true, + // Optional: single-tenant has no sys_organization row and no auto-stamp + // (org-scoping is multi-tenant-only). Multi-tenant: OrgScopingPlugin stamps it + // and tenant-isolation RLS hides null-org rows (fail-closed). ADR-0057 addendum. + required: false, }), email: Field.email({ diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 1bb8c4e4db..16d070e4fd 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -141,7 +141,10 @@ export const SysMember = ObjectSchema.create({ organization_id: Field.lookup('sys_organization', { label: 'Organization', - required: true, + // Optional: single-tenant has no sys_organization row and no auto-stamp + // (org-scoping is multi-tenant-only). Multi-tenant: OrgScopingPlugin stamps it + // and tenant-isolation RLS hides null-org rows (fail-closed). ADR-0057 addendum. + required: false, }), user_id: Field.lookup('sys_user', { diff --git a/packages/plugins/plugin-sharing/src/business-unit-graph.ts b/packages/plugins/plugin-sharing/src/business-unit-graph.ts index 21f1c0a47b..b590674ce0 100644 --- a/packages/plugins/plugin-sharing/src/business-unit-graph.ts +++ b/packages/plugins/plugin-sharing/src/business-unit-graph.ts @@ -139,7 +139,7 @@ export class BusinessUnitGraphService implements IBusinessUnitGraphService { let row: any = null; try { const rows = await this.engine.find('sys_business_unit', { - where: { id: businessUnitId }, + where: this.orgScope({ id: businessUnitId }), fields: ['id', 'manager_user_id'], limit: 1, context: SYSTEM_CTX, diff --git a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts index 9bd65ba1f9..c00d322b5f 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts @@ -170,6 +170,14 @@ describe('BusinessUnitGraphService (recursive sys_business_unit)', () => { expect(await d.headOf('emea_sales')).toEqual('alice'); expect(await d.headOf('emea_marketing')).toBeNull(); }); + + it('headOf is org-scoped — does not leak a manager across organizations', async () => { + // 'foreign' is in org2; an org1-scoped service must not read its head. + const foreign = engine._tables.sys_business_unit.find((r: any) => r.id === 'foreign'); + foreign.manager_user_id = 'mallory'; + const d = new BusinessUnitGraphService({ engine: engine as any, organizationId: 'org1' }); + expect(await d.headOf('foreign')).toBeNull(); + }); }); describe('SharingRuleService', () => {