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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/fix-single-tenant-followup.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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({
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/plugins/plugin-sharing/src/sharing-rule.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand Down