From f1695580e620c6871d0a1296f0f3829928c1dcf9 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 18 Jun 2026 20:11:24 +0800 Subject: [PATCH] feat(org-scoping): hand default org's seeded records to its admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-tenant companion to plugin-security's single-tenant claimSeedOwnership. Seeded rows land owner_id NULL; claimOrphanOrgRows back-fills organization_id but owner_id stayed NULL, so owner-keyed UX was empty for org members. - New claimOrgSeedOwnership(ql, orgId, ownerUserId): assigns owner_id to an org's NULL-owned seed rows; org-scoped, idempotent, skips managedBy/sys_*, requires both owner_id + organization_id columns. - ensureDefaultOrganization calls it after binding the admin as the default org's owner — symmetric with the single-tenant first-admin handoff. Tests: claim-org-seed-ownership suite (+5). org-scoping 20 green. Co-Authored-By: Claude Opus 4.8 --- .changeset/multitenant-seed-ownership.md | 19 +++ .../src/claim-org-seed-ownership.test.ts | 86 +++++++++++++ .../src/claim-org-seed-ownership.ts | 116 ++++++++++++++++++ .../src/ensure-default-organization.ts | 22 +++- .../plugins/plugin-org-scoping/src/index.ts | 1 + 5 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 .changeset/multitenant-seed-ownership.md create mode 100644 packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.test.ts create mode 100644 packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.ts diff --git a/.changeset/multitenant-seed-ownership.md b/.changeset/multitenant-seed-ownership.md new file mode 100644 index 0000000000..e1bbde3e19 --- /dev/null +++ b/.changeset/multitenant-seed-ownership.md @@ -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. diff --git a/packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.test.ts b/packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.test.ts new file mode 100644 index 0000000000..8ad6cbee68 --- /dev/null +++ b/packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.test.ts @@ -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) { + 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([]); + }); +}); diff --git a/packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.ts b/packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.ts new file mode 100644 index 0000000000..3b8d03f67a --- /dev/null +++ b/packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.ts @@ -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) => void; + warn: (message: string, meta?: Record) => 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; +} diff --git a/packages/plugins/plugin-org-scoping/src/ensure-default-organization.ts b/packages/plugins/plugin-org-scoping/src/ensure-default-organization.ts index 404055eb6d..b9bd417511 100644 --- a/packages/plugins/plugin-org-scoping/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-org-scoping/src/ensure-default-organization.ts @@ -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) => void; @@ -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; } /** @@ -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 }; } diff --git a/packages/plugins/plugin-org-scoping/src/index.ts b/packages/plugins/plugin-org-scoping/src/index.ts index 1fd336f2bc..52137bcf70 100644 --- a/packages/plugins/plugin-org-scoping/src/index.ts +++ b/packages/plugins/plugin-org-scoping/src/index.ts @@ -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,