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
19 changes: 19 additions & 0 deletions .changeset/multitenant-seed-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/plugin-org-scoping": minor
---

feat(org-scoping): hand a default org's seeded records to its admin (multi-tenant ownership handoff)

The multi-tenant companion to plugin-security's single-tenant `claimSeedOwnership`.
Seeded rows land `owner_id` NULL (the author leaves it unset; `cel`os.user.id``
resolves to NULL at seed time). In multi-tenant mode `claimOrphanOrgRows`
back-fills their `organization_id`, but `owner_id` stayed NULL — so "My" views,
owner reports and owner notifications were empty for the org's members.

- New `claimOrgSeedOwnership(ql, organizationId, ownerUserId)` — assigns
`owner_id = ownerUserId` to an org's NULL-owned seed rows. Scoped to a single
org (never touches another tenant), idempotent, skips `managedBy` / `sys_*`,
and requires both `owner_id` and `organization_id` columns.
- `ensureDefaultOrganization` now calls it after binding the platform admin as
the default org's owner, so the default org's demo data is owned by the admin
out of the box — symmetric with the single-tenant first-admin handoff.
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi } from 'vitest';
import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js';

const ORG = 'org_1';
const OWNER = 'usr_admin';

function makeQL(schemas: any[], rowsByObject: Record<string, any[]>) {
const updates: { object: string; data: any }[] = [];
const ql: any = {
registry: { getAllObjects: () => schemas },
find: vi.fn(async (object: string, query: any) => {
const all = rowsByObject[object] ?? [];
const w = query?.where ?? {};
return all.filter((r) => {
if ('organization_id' in w && (r.organization_id ?? null) !== (w.organization_id ?? null)) return false;
if ('owner_id' in w && (r.owner_id ?? null) !== (w.owner_id ?? null)) return false;
return true;
});
}),
update: vi.fn(async (object: string, data: any) => {
updates.push({ object, data });
const row = (rowsByObject[object] ?? []).find((r) => r.id === data.id);
if (row) row.owner_id = data.owner_id;
return row;
}),
};
return { ql, updates };
}

describe('claimOrgSeedOwnership', () => {
it('returns [] when registry is unavailable', async () => {
const ql: any = { find: vi.fn(), update: vi.fn() };
expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]);
});

it('no-ops without an org or owner', async () => {
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }];
const { ql, updates } = makeQL(schemas, { crm_lead: [{ id: 'l1', organization_id: ORG, owner_id: null }] });
expect(await claimOrgSeedOwnership(ql, '', OWNER)).toEqual([]);
expect(await claimOrgSeedOwnership(ql, ORG, '')).toEqual([]);
expect(updates).toHaveLength(0);
});

it('skips managedBy / sys_* and objects missing owner_id or organization_id', async () => {
const schemas = [
{ name: 'sys_user', managedBy: 'better-auth', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] },
{ name: 'sys_widget', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] },
{ name: 'crm_pricebook', fields: [{ name: 'organization_id' }] }, // no owner_id
{ name: 'crm_global', fields: [{ name: 'owner_id' }] }, // no organization_id
];
const { ql, updates } = makeQL(schemas, {
sys_user: [{ id: 'u1', organization_id: ORG, owner_id: null }],
sys_widget: [{ id: 'w1', organization_id: ORG, owner_id: null }],
crm_pricebook: [{ id: 'p1', organization_id: ORG }],
crm_global: [{ id: 'g1', owner_id: null }],
});
expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]);
expect(updates).toHaveLength(0);
});

it('claims this org\'s NULL-owner rows only, leaving other orgs and human-owned rows untouched', async () => {
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }];
const rows = [
{ id: 'l1', organization_id: ORG, owner_id: null }, // claimed
{ id: 'l2', organization_id: ORG, owner_id: 'usr_someone' }, // already owned — untouched
{ id: 'l3', organization_id: 'org_2', owner_id: null }, // other org — untouched
];
const { ql, updates } = makeQL(schemas, { crm_lead: rows });
const result = await claimOrgSeedOwnership(ql, ORG, OWNER);

expect(result).toEqual([{ object: 'crm_lead', count: 1 }]);
expect(updates).toHaveLength(1);
expect(updates[0].data).toMatchObject({ id: 'l1', owner_id: OWNER });
expect(rows.find((r) => r.id === 'l2')!.owner_id).toBe('usr_someone');
expect(rows.find((r) => r.id === 'l3')!.owner_id).toBeNull();
});

it('is idempotent — a second run claims nothing', async () => {
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }];
const { ql } = makeQL(schemas, { crm_lead: [{ id: 'l1', organization_id: ORG, owner_id: null }] });
await claimOrgSeedOwnership(ql, ORG, OWNER);
expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]);
});
});
116 changes: 116 additions & 0 deletions packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* claimOrgSeedOwnership — hand an organization's seeded records to its owner.
*
* The multi-tenant twin of plugin-security's `claimSeedOwnership` (single-tenant
* first-admin handoff). Seeded rows land `owner_id = NULL` (the author leaves it
* unset and `cel`os.user.id`` resolves to NULL at seed time, since the owning
* admin does not exist yet). In multi-tenant mode those rows are scoped to an
* org by `claimOrphanOrgRows` / per-org replay, but their `owner_id` stays NULL
* — so "My" views, owner reports and owner notifications are empty for the org's
* members until ownership is assigned.
*
* This runs when the org's owner is established (e.g. `ensureDefaultOrganization`
* binds the platform admin as the default org's `owner`) and assigns
* `owner_id = ownerUserId` to that org's NULL-owned rows — the ownership
* companion to `claimOrphanOrgRows`'s `organization_id` back-fill.
*
* Scoped to a single org (`organization_id = organizationId`) so it never
* touches another tenant's rows. Idempotent: only NULL-owned rows are updated.
* `managedBy` and `sys_*` tables are skipped.
*/

import type { ServiceObject } from '@objectstack/spec/data';

interface ClaimOwnershipOptions {
logger?: {
info: (message: string, meta?: Record<string, any>) => void;
warn: (message: string, meta?: Record<string, any>) => void;
};
}

const SYSTEM_CTX = { isSystem: true };

function hasField(schema: ServiceObject, field: string): boolean {
const fields: any = (schema as any)?.fields;
if (!fields) return false;
if (Array.isArray(fields)) return fields.some((f) => f?.name === field);
return Object.prototype.hasOwnProperty.call(fields, field);
}

/**
* Assign `owner_id = ownerUserId` to every NULL-owned seed row of `organizationId`.
*
* Walks `ql.registry.getAllObjects()`, filters to schemas that
* (a) are not `managedBy` (skip sys_/auth/platform tables),
* (b) are not `sys_*`-namespaced,
* (c) declare BOTH `owner_id` and `organization_id`,
* and updates the org's unowned rows as `isSystem`. Returns a per-object summary.
*/
export async function claimOrgSeedOwnership(
ql: any,
organizationId: string,
ownerUserId: string,
options: ClaimOwnershipOptions = {},
): Promise<{ object: string; count: number }[]> {
const logger = options.logger;
if (!organizationId || !ownerUserId) return [];
if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') return [];
const registry = (ql as any).registry;
if (!registry || typeof registry.getAllObjects !== 'function') {
logger?.warn?.('[org-scoping] claimOrgSeedOwnership: registry unavailable');
return [];
}

const schemas: ServiceObject[] = registry.getAllObjects();
const results: { object: string; count: number }[] = [];

for (const schema of schemas) {
if (!schema?.name) continue;
if ((schema as any).managedBy) continue;
if (schema.name.startsWith('sys_')) continue;
// Both columns are required: owner_id to assign, organization_id to scope.
if (!hasField(schema, 'owner_id') || !hasField(schema, 'organization_id')) continue;

try {
const orphans = await ql.find(
schema.name,
{ where: { organization_id: organizationId, owner_id: null }, limit: 10_000, fields: ['id'] },
{ context: SYSTEM_CTX },
);
const list: any[] = Array.isArray(orphans)
? orphans
: Array.isArray(orphans?.records)
? orphans.records
: [];
if (list.length === 0) continue;

let updated = 0;
for (const row of list) {
if (!row?.id) continue;
try {
await ql.update(schema.name, { id: row.id, owner_id: ownerUserId }, { context: SYSTEM_CTX });
updated += 1;
} catch (e) {
logger?.warn?.(`[org-scoping] claimOrgSeedOwnership failed for ${schema.name}:${row.id}`, {
error: (e as Error).message,
});
}
}
if (updated > 0) results.push({ object: schema.name, count: updated });
} catch (e) {
logger?.warn?.(`[org-scoping] claimOrgSeedOwnership scan failed for ${schema.name}`, {
error: (e as Error).message,
});
}
}

if (results.length > 0) {
const total = results.reduce((s, r) => s + r.count, 0);
logger?.info?.(`[org-scoping] handed ${total} seeded row(s) of org ${organizationId} to owner ${ownerUserId}`, {
breakdown: results,
});
}
return results;
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
* handle the seed-data side for those flows.
*/

import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js';

interface EnsureOptions {
logger?: {
info: (message: string, meta?: Record<string, any>) => void;
Expand Down Expand Up @@ -73,6 +75,8 @@ export interface EnsureDefaultOrganizationResult {
memberCreated: boolean;
/** Human-readable reason when the helper short-circuited. */
reason?: 'no_admin' | 'admin_already_in_org' | 'org_insert_failed' | 'member_insert_failed';
/** Count of the default org's seeded rows re-owned to the platform admin. */
ownershipClaimed?: number;
}

/**
Expand Down Expand Up @@ -170,5 +174,21 @@ export async function ensureDefaultOrganization(
`[org-scoping] bound platform admin to default organization (${defaultOrgId})`,
{ userId: adminUserId, defaultOrgId },
);
return { defaultOrgCreated, defaultOrgId, memberCreated: true };

// 6. Hand the default org's seeded rows (owner_id NULL) to the admin so
// owner-keyed UX works out of the box — the multi-tenant companion to the
// single-tenant first-admin handoff. Best-effort; never undoes the bind.
let ownershipClaimed = 0;
if (defaultOrgId) {
try {
const claims = await claimOrgSeedOwnership(ql, defaultOrgId, adminUserId, { logger });
ownershipClaimed = claims.reduce((s, c) => s + c.count, 0);
} catch (e) {
logger?.warn?.('[org-scoping] default-org seed ownership handoff failed', {
error: (e as Error).message,
});
}
}

return { defaultOrgCreated, defaultOrgId, memberCreated: true, ownershipClaimed };
}
1 change: 1 addition & 0 deletions packages/plugins/plugin-org-scoping/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
export { OrgScopingPlugin } from './org-scoping-plugin.js';
export type { OrgScopingPluginOptions } from './org-scoping-plugin.js';
export { claimOrphanOrgRows } from './claim-orphan-org-rows.js';
export { claimOrgSeedOwnership } from './claim-org-seed-ownership.js';
export { cloneOrgSeedData } from './clone-org-seed-data.js';
export {
ensureDefaultOrganization,
Expand Down
Loading