Skip to content

Commit 4b6430e

Browse files
authored
Merge pull request #2031 from objectstack-ai/feat/multitenant-seed-ownership
feat(org-scoping): multi-tenant seed ownership handoff to default-org admin
2 parents 39603a8 + f169558 commit 4b6430e

5 files changed

Lines changed: 243 additions & 1 deletion

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/plugin-org-scoping": minor
3+
---
4+
5+
feat(org-scoping): hand a default org's seeded records to its admin (multi-tenant ownership handoff)
6+
7+
The multi-tenant companion to plugin-security's single-tenant `claimSeedOwnership`.
8+
Seeded rows land `owner_id` NULL (the author leaves it unset; `cel`os.user.id``
9+
resolves to NULL at seed time). In multi-tenant mode `claimOrphanOrgRows`
10+
back-fills their `organization_id`, but `owner_id` stayed NULL — so "My" views,
11+
owner reports and owner notifications were empty for the org's members.
12+
13+
- New `claimOrgSeedOwnership(ql, organizationId, ownerUserId)` — assigns
14+
`owner_id = ownerUserId` to an org's NULL-owned seed rows. Scoped to a single
15+
org (never touches another tenant), idempotent, skips `managedBy` / `sys_*`,
16+
and requires both `owner_id` and `organization_id` columns.
17+
- `ensureDefaultOrganization` now calls it after binding the platform admin as
18+
the default org's owner, so the default org's demo data is owned by the admin
19+
out of the box — symmetric with the single-tenant first-admin handoff.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js';
5+
6+
const ORG = 'org_1';
7+
const OWNER = 'usr_admin';
8+
9+
function makeQL(schemas: any[], rowsByObject: Record<string, any[]>) {
10+
const updates: { object: string; data: any }[] = [];
11+
const ql: any = {
12+
registry: { getAllObjects: () => schemas },
13+
find: vi.fn(async (object: string, query: any) => {
14+
const all = rowsByObject[object] ?? [];
15+
const w = query?.where ?? {};
16+
return all.filter((r) => {
17+
if ('organization_id' in w && (r.organization_id ?? null) !== (w.organization_id ?? null)) return false;
18+
if ('owner_id' in w && (r.owner_id ?? null) !== (w.owner_id ?? null)) return false;
19+
return true;
20+
});
21+
}),
22+
update: vi.fn(async (object: string, data: any) => {
23+
updates.push({ object, data });
24+
const row = (rowsByObject[object] ?? []).find((r) => r.id === data.id);
25+
if (row) row.owner_id = data.owner_id;
26+
return row;
27+
}),
28+
};
29+
return { ql, updates };
30+
}
31+
32+
describe('claimOrgSeedOwnership', () => {
33+
it('returns [] when registry is unavailable', async () => {
34+
const ql: any = { find: vi.fn(), update: vi.fn() };
35+
expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]);
36+
});
37+
38+
it('no-ops without an org or owner', async () => {
39+
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }];
40+
const { ql, updates } = makeQL(schemas, { crm_lead: [{ id: 'l1', organization_id: ORG, owner_id: null }] });
41+
expect(await claimOrgSeedOwnership(ql, '', OWNER)).toEqual([]);
42+
expect(await claimOrgSeedOwnership(ql, ORG, '')).toEqual([]);
43+
expect(updates).toHaveLength(0);
44+
});
45+
46+
it('skips managedBy / sys_* and objects missing owner_id or organization_id', async () => {
47+
const schemas = [
48+
{ name: 'sys_user', managedBy: 'better-auth', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] },
49+
{ name: 'sys_widget', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] },
50+
{ name: 'crm_pricebook', fields: [{ name: 'organization_id' }] }, // no owner_id
51+
{ name: 'crm_global', fields: [{ name: 'owner_id' }] }, // no organization_id
52+
];
53+
const { ql, updates } = makeQL(schemas, {
54+
sys_user: [{ id: 'u1', organization_id: ORG, owner_id: null }],
55+
sys_widget: [{ id: 'w1', organization_id: ORG, owner_id: null }],
56+
crm_pricebook: [{ id: 'p1', organization_id: ORG }],
57+
crm_global: [{ id: 'g1', owner_id: null }],
58+
});
59+
expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]);
60+
expect(updates).toHaveLength(0);
61+
});
62+
63+
it('claims this org\'s NULL-owner rows only, leaving other orgs and human-owned rows untouched', async () => {
64+
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }];
65+
const rows = [
66+
{ id: 'l1', organization_id: ORG, owner_id: null }, // claimed
67+
{ id: 'l2', organization_id: ORG, owner_id: 'usr_someone' }, // already owned — untouched
68+
{ id: 'l3', organization_id: 'org_2', owner_id: null }, // other org — untouched
69+
];
70+
const { ql, updates } = makeQL(schemas, { crm_lead: rows });
71+
const result = await claimOrgSeedOwnership(ql, ORG, OWNER);
72+
73+
expect(result).toEqual([{ object: 'crm_lead', count: 1 }]);
74+
expect(updates).toHaveLength(1);
75+
expect(updates[0].data).toMatchObject({ id: 'l1', owner_id: OWNER });
76+
expect(rows.find((r) => r.id === 'l2')!.owner_id).toBe('usr_someone');
77+
expect(rows.find((r) => r.id === 'l3')!.owner_id).toBeNull();
78+
});
79+
80+
it('is idempotent — a second run claims nothing', async () => {
81+
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }];
82+
const { ql } = makeQL(schemas, { crm_lead: [{ id: 'l1', organization_id: ORG, owner_id: null }] });
83+
await claimOrgSeedOwnership(ql, ORG, OWNER);
84+
expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]);
85+
});
86+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* claimOrgSeedOwnership — hand an organization's seeded records to its owner.
5+
*
6+
* The multi-tenant twin of plugin-security's `claimSeedOwnership` (single-tenant
7+
* first-admin handoff). Seeded rows land `owner_id = NULL` (the author leaves it
8+
* unset and `cel`os.user.id`` resolves to NULL at seed time, since the owning
9+
* admin does not exist yet). In multi-tenant mode those rows are scoped to an
10+
* org by `claimOrphanOrgRows` / per-org replay, but their `owner_id` stays NULL
11+
* — so "My" views, owner reports and owner notifications are empty for the org's
12+
* members until ownership is assigned.
13+
*
14+
* This runs when the org's owner is established (e.g. `ensureDefaultOrganization`
15+
* binds the platform admin as the default org's `owner`) and assigns
16+
* `owner_id = ownerUserId` to that org's NULL-owned rows — the ownership
17+
* companion to `claimOrphanOrgRows`'s `organization_id` back-fill.
18+
*
19+
* Scoped to a single org (`organization_id = organizationId`) so it never
20+
* touches another tenant's rows. Idempotent: only NULL-owned rows are updated.
21+
* `managedBy` and `sys_*` tables are skipped.
22+
*/
23+
24+
import type { ServiceObject } from '@objectstack/spec/data';
25+
26+
interface ClaimOwnershipOptions {
27+
logger?: {
28+
info: (message: string, meta?: Record<string, any>) => void;
29+
warn: (message: string, meta?: Record<string, any>) => void;
30+
};
31+
}
32+
33+
const SYSTEM_CTX = { isSystem: true };
34+
35+
function hasField(schema: ServiceObject, field: string): boolean {
36+
const fields: any = (schema as any)?.fields;
37+
if (!fields) return false;
38+
if (Array.isArray(fields)) return fields.some((f) => f?.name === field);
39+
return Object.prototype.hasOwnProperty.call(fields, field);
40+
}
41+
42+
/**
43+
* Assign `owner_id = ownerUserId` to every NULL-owned seed row of `organizationId`.
44+
*
45+
* Walks `ql.registry.getAllObjects()`, filters to schemas that
46+
* (a) are not `managedBy` (skip sys_/auth/platform tables),
47+
* (b) are not `sys_*`-namespaced,
48+
* (c) declare BOTH `owner_id` and `organization_id`,
49+
* and updates the org's unowned rows as `isSystem`. Returns a per-object summary.
50+
*/
51+
export async function claimOrgSeedOwnership(
52+
ql: any,
53+
organizationId: string,
54+
ownerUserId: string,
55+
options: ClaimOwnershipOptions = {},
56+
): Promise<{ object: string; count: number }[]> {
57+
const logger = options.logger;
58+
if (!organizationId || !ownerUserId) return [];
59+
if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') return [];
60+
const registry = (ql as any).registry;
61+
if (!registry || typeof registry.getAllObjects !== 'function') {
62+
logger?.warn?.('[org-scoping] claimOrgSeedOwnership: registry unavailable');
63+
return [];
64+
}
65+
66+
const schemas: ServiceObject[] = registry.getAllObjects();
67+
const results: { object: string; count: number }[] = [];
68+
69+
for (const schema of schemas) {
70+
if (!schema?.name) continue;
71+
if ((schema as any).managedBy) continue;
72+
if (schema.name.startsWith('sys_')) continue;
73+
// Both columns are required: owner_id to assign, organization_id to scope.
74+
if (!hasField(schema, 'owner_id') || !hasField(schema, 'organization_id')) continue;
75+
76+
try {
77+
const orphans = await ql.find(
78+
schema.name,
79+
{ where: { organization_id: organizationId, owner_id: null }, limit: 10_000, fields: ['id'] },
80+
{ context: SYSTEM_CTX },
81+
);
82+
const list: any[] = Array.isArray(orphans)
83+
? orphans
84+
: Array.isArray(orphans?.records)
85+
? orphans.records
86+
: [];
87+
if (list.length === 0) continue;
88+
89+
let updated = 0;
90+
for (const row of list) {
91+
if (!row?.id) continue;
92+
try {
93+
await ql.update(schema.name, { id: row.id, owner_id: ownerUserId }, { context: SYSTEM_CTX });
94+
updated += 1;
95+
} catch (e) {
96+
logger?.warn?.(`[org-scoping] claimOrgSeedOwnership failed for ${schema.name}:${row.id}`, {
97+
error: (e as Error).message,
98+
});
99+
}
100+
}
101+
if (updated > 0) results.push({ object: schema.name, count: updated });
102+
} catch (e) {
103+
logger?.warn?.(`[org-scoping] claimOrgSeedOwnership scan failed for ${schema.name}`, {
104+
error: (e as Error).message,
105+
});
106+
}
107+
}
108+
109+
if (results.length > 0) {
110+
const total = results.reduce((s, r) => s + r.count, 0);
111+
logger?.info?.(`[org-scoping] handed ${total} seeded row(s) of org ${organizationId} to owner ${ownerUserId}`, {
112+
breakdown: results,
113+
});
114+
}
115+
return results;
116+
}

packages/plugins/plugin-org-scoping/src/ensure-default-organization.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
* handle the seed-data side for those flows.
3333
*/
3434

35+
import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js';
36+
3537
interface EnsureOptions {
3638
logger?: {
3739
info: (message: string, meta?: Record<string, any>) => void;
@@ -73,6 +75,8 @@ export interface EnsureDefaultOrganizationResult {
7375
memberCreated: boolean;
7476
/** Human-readable reason when the helper short-circuited. */
7577
reason?: 'no_admin' | 'admin_already_in_org' | 'org_insert_failed' | 'member_insert_failed';
78+
/** Count of the default org's seeded rows re-owned to the platform admin. */
79+
ownershipClaimed?: number;
7680
}
7781

7882
/**
@@ -170,5 +174,21 @@ export async function ensureDefaultOrganization(
170174
`[org-scoping] bound platform admin to default organization (${defaultOrgId})`,
171175
{ userId: adminUserId, defaultOrgId },
172176
);
173-
return { defaultOrgCreated, defaultOrgId, memberCreated: true };
177+
178+
// 6. Hand the default org's seeded rows (owner_id NULL) to the admin so
179+
// owner-keyed UX works out of the box — the multi-tenant companion to the
180+
// single-tenant first-admin handoff. Best-effort; never undoes the bind.
181+
let ownershipClaimed = 0;
182+
if (defaultOrgId) {
183+
try {
184+
const claims = await claimOrgSeedOwnership(ql, defaultOrgId, adminUserId, { logger });
185+
ownershipClaimed = claims.reduce((s, c) => s + c.count, 0);
186+
} catch (e) {
187+
logger?.warn?.('[org-scoping] default-org seed ownership handoff failed', {
188+
error: (e as Error).message,
189+
});
190+
}
191+
}
192+
193+
return { defaultOrgCreated, defaultOrgId, memberCreated: true, ownershipClaimed };
174194
}

packages/plugins/plugin-org-scoping/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
export { OrgScopingPlugin } from './org-scoping-plugin.js';
2121
export type { OrgScopingPluginOptions } from './org-scoping-plugin.js';
2222
export { claimOrphanOrgRows } from './claim-orphan-org-rows.js';
23+
export { claimOrgSeedOwnership } from './claim-org-seed-ownership.js';
2324
export { cloneOrgSeedData } from './clone-org-seed-data.js';
2425
export {
2526
ensureDefaultOrganization,

0 commit comments

Comments
 (0)