Skip to content

Commit 4b45c54

Browse files
hotlongCopilot
andcommitted
feat(seed): per-organization seed replay for multi-tenant SaaS
Every newly created sys_organization now gets its own private copy of the artifact's demo data (Salesforce-sandbox style) — independent of other tenants, populated automatically on org creation regardless of whether the org was auto-created by signup or manually via the better-auth createOrganization API. Pipeline: - SeedLoaderConfig gains optional 'organizationId' — when set, each record is stamped with that org and existing-record lookups (loadExistingRecords / resolveFromDatabase) are scoped to that org so upsert produces an independent per-tenant copy. - AppPlugin registers two kernel services at start: 'seed-datasets' (the parsed array) and 'seed-replayer' (a (orgId) => summary closure that wraps SeedLoaderService). In multi-tenant mode it also SKIPS the inline AppPlugin seed-insert — replay happens per-org instead, eliminating NULL-org rows that previously needed the orphan-claim hack. - SecurityPlugin's sys_organization insert middleware now calls the 'seed-replayer' service for every new org (first or Nth). When the replayer is unavailable it falls back to the legacy claimOrphanTenantRows (first org) or cloneTenantSeedData (Nth org) paths for backwards compatibility. Fixes the reported issue where signing up a second user on crm.objectos.app produced an empty CRM dashboard — the demo accounts/contacts/leads/etc. were claimed by the first org and invisible to all subsequent tenants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1a14174 commit 4b45c54

4 files changed

Lines changed: 239 additions & 22 deletions

File tree

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 107 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -402,13 +402,30 @@ export class SecurityPlugin implements Plugin {
402402
}
403403
});
404404

405-
// After a sys_organization insert, if this is the FIRST organization
406-
// in the system, back-fill `organization_id` on every seed-loaded
407-
// user-defined row that landed with `organization_id IS NULL`. Seeds
408-
// run as `isSystem` (no auto-fill), so without this hook, demo data
409-
// shipped with `defineDataset()` would be invisible to anyone with
410-
// an active organization. Idempotent: only fires when row count
411-
// before this insert was zero, then never again.
405+
// After a sys_organization insert, give the new org its own private
406+
// copy of the artifact's demo data (Salesforce-sandbox style):
407+
//
408+
// 1. PRIMARY PATH — replay the seed datasets registered on the
409+
// kernel's `seed-datasets` service (populated by AppPlugin at
410+
// start) with `organizationId: <newOrgId>`. SeedLoader scopes
411+
// both existing-record lookups and reference resolution to
412+
// that org, so upsert mode produces an independent copy per
413+
// tenant. This works for the FIRST org and EVERY subsequent
414+
// org, whether the org was auto-created by signup
415+
// (`ensureUserHasOrganization`) or manually via the
416+
// better-auth `createOrganization` API.
417+
//
418+
// 2. FALLBACK A — when no `seed-datasets` service is registered
419+
// (e.g. a plugin-shaped deployment with no AppPlugin), and
420+
// this is the FIRST org, fall back to the legacy
421+
// `claimOrphanTenantRows` path that adopts any NULL-org rows
422+
// a previous AppPlugin inline-seed may have inserted.
423+
//
424+
// 3. FALLBACK B — when no `seed-datasets` service is registered
425+
// and this is NOT the first org, fall back to
426+
// `cloneTenantSeedData` (donor-based row copy from the very
427+
// first org). Useful for upgrade paths where the new
428+
// service-based flow hasn't been wired yet.
412429
if (this.multiTenant) {
413430
ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
414431
await next();
@@ -420,6 +437,20 @@ export class SecurityPlugin implements Plugin {
420437
}
421438
const newOrgId = opCtx?.result?.id ?? opCtx?.data?.id;
422439
if (!newOrgId) return;
440+
441+
// Locate the kernel via ctx — most kernel impls expose either
442+
// `getService` on PluginContext directly or attach the kernel
443+
// ref. Anything we can't resolve becomes `undefined` and we
444+
// gracefully fall back.
445+
const kernel: any = (ctx as any).kernel ?? ctx;
446+
let datasets: any[] | undefined;
447+
try {
448+
const raw = kernel?.getService?.('seed-datasets');
449+
if (Array.isArray(raw) && raw.length > 0) datasets = raw;
450+
} catch { /* service not registered */ }
451+
452+
// Count existing orgs to pick the right fallback path.
453+
let orgCount = 0;
423454
try {
424455
const allOrgs = await ql.find(
425456
'sys_organization',
@@ -431,20 +462,82 @@ export class SecurityPlugin implements Plugin {
431462
: Array.isArray(allOrgs?.records)
432463
? allOrgs.records
433464
: [];
434-
if (list.length !== 1) return;
435-
const claims = await claimOrphanTenantRows(ql, newOrgId, { logger: ctx.logger });
436-
if (claims.length > 0) {
437-
const total = claims.reduce((s, c) => s + c.count, 0);
465+
orgCount = list.length;
466+
} catch (e) {
467+
ctx.logger.warn('[security] failed to count organizations', {
468+
error: (e as Error).message,
469+
});
470+
}
471+
472+
// ── Primary path: SeedLoader replay scoped to newOrgId ─────
473+
// Uses the `seed-replayer` callable that AppPlugin registers
474+
// on the kernel (keeps plugin-security free of @objectstack/runtime
475+
// import — runtime already depends on us, so the reverse would
476+
// be circular).
477+
let replayed = false;
478+
try {
479+
const replayer: any = kernel?.getService?.('seed-replayer');
480+
if (typeof replayer === 'function') {
481+
const summary = await replayer(newOrgId);
482+
const total = (summary?.inserted ?? 0) + (summary?.updated ?? 0);
438483
ctx.logger.info(
439-
`[security] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`,
440-
{ breakdown: claims },
484+
`[security] per-org seed replay for ${newOrgId}: +${summary?.inserted ?? 0} inserted, ${summary?.updated ?? 0} updated, ${summary?.errors?.length ?? 0} error(s)`,
485+
{
486+
organizationId: newOrgId,
487+
errors: summary?.errors?.slice?.(0, 5),
488+
},
441489
);
490+
if (total > 0) replayed = true;
491+
} else if (datasets) {
492+
ctx.logger.warn('[security] per-org seed: datasets present but no replayer registered', {
493+
organizationId: newOrgId,
494+
});
442495
}
443496
} catch (e) {
444-
ctx.logger.warn('[security] claim-orphan-tenant-rows failed', {
497+
ctx.logger.warn('[security] per-org seed replay failed, falling back', {
498+
organizationId: newOrgId,
445499
error: (e as Error).message,
446500
});
447501
}
502+
if (replayed) return;
503+
504+
// ── Fallback A: legacy claim for first org ─────────────────
505+
if (orgCount === 1) {
506+
try {
507+
const claims = await claimOrphanTenantRows(ql, newOrgId, { logger: ctx.logger });
508+
if (claims.length > 0) {
509+
const total = claims.reduce((s, c) => s + c.count, 0);
510+
ctx.logger.info(
511+
`[security] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`,
512+
{ breakdown: claims },
513+
);
514+
return;
515+
}
516+
} catch (e) {
517+
ctx.logger.warn('[security] claim-orphan-tenant-rows failed', {
518+
error: (e as Error).message,
519+
});
520+
}
521+
}
522+
523+
// ── Fallback B: clone from donor org for subsequent orgs ───
524+
if (orgCount > 1) {
525+
try {
526+
const summary = await cloneTenantSeedData(ql, newOrgId, { logger: ctx.logger });
527+
if (summary.length > 0) {
528+
const total = summary.reduce((s, c) => s + c.count, 0);
529+
ctx.logger.info(
530+
`[security] cloned ${total} seed row(s) for new organization ${newOrgId}`,
531+
{ breakdown: summary },
532+
);
533+
}
534+
} catch (e) {
535+
ctx.logger.warn('[security] clone-tenant-seed-data failed', {
536+
organizationId: newOrgId,
537+
error: (e as Error).message,
538+
});
539+
}
540+
}
448541
});
449542
}
450543
}

packages/runtime/src/app-plugin.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,80 @@ export class AppPlugin implements Plugin {
276276
object: d.object,
277277
}));
278278

279+
// Stash datasets on a kernel service so SecurityPlugin's
280+
// sys_organization insert hook can replay them per-tenant
281+
// (Salesforce-sandbox style: every new org gets its own
282+
// private copy of the artifact's demo data).
283+
//
284+
// We also register a `seed-replayer` callable so the
285+
// SecurityPlugin doesn't need to import @objectstack/runtime
286+
// (would create a circular workspace dep). The replayer
287+
// captures the SeedLoaderService closure and exposes a
288+
// narrow `(orgId) => Promise<summary>` surface.
289+
try {
290+
const kernel: any = (ctx as any).kernel;
291+
const existing = (() => {
292+
try { return kernel?.getService?.('seed-datasets'); } catch { return undefined; }
293+
})();
294+
const merged = Array.isArray(existing)
295+
? [...existing, ...normalizedDatasets]
296+
: normalizedDatasets;
297+
const registerSvc = (name: string, value: any) => {
298+
if (kernel?.registerService) kernel.registerService(name, value);
299+
else if (typeof (ctx as any).registerService === 'function') (ctx as any).registerService(name, value);
300+
};
301+
registerSvc('seed-datasets', merged);
302+
303+
const metadataNow = ctx.getService('metadata') as IMetadataService | undefined;
304+
const loggerRef = ctx.logger;
305+
const replayer = async (organizationId: string) => {
306+
if (!organizationId) return { inserted: 0, updated: 0, errors: [] as any[] };
307+
const md = metadataNow ?? (ctx.getService('metadata') as IMetadataService | undefined);
308+
if (!md) {
309+
loggerRef.warn('[seed-replayer] metadata service unavailable');
310+
return { inserted: 0, updated: 0, errors: [] as any[] };
311+
}
312+
const datasetsNow = (() => {
313+
try { return kernel?.getService?.('seed-datasets'); } catch { return merged; }
314+
})() ?? merged;
315+
if (!Array.isArray(datasetsNow) || datasetsNow.length === 0) {
316+
return { inserted: 0, updated: 0, errors: [] as any[] };
317+
}
318+
const seedLoader = new SeedLoaderService(ql, md, loggerRef);
319+
const { SeedLoaderRequestSchema } = await import('@objectstack/spec/data');
320+
const request = SeedLoaderRequestSchema.parse({
321+
datasets: datasetsNow,
322+
config: {
323+
defaultMode: 'upsert',
324+
multiPass: true,
325+
organizationId,
326+
},
327+
});
328+
const result = await seedLoader.load(request);
329+
return {
330+
inserted: result.summary.totalInserted,
331+
updated: result.summary.totalUpdated,
332+
errors: result.errors,
333+
};
334+
};
335+
registerSvc('seed-replayer', replayer);
336+
ctx.logger.info(`[Seeder] Registered ${normalizedDatasets.length} datasets + replayer on kernel (total datasets: ${merged.length})`);
337+
} catch (e: any) {
338+
ctx.logger.warn('[Seeder] Failed to register seed-datasets/seed-replayer service', { error: e?.message });
339+
}
340+
341+
// Decide whether to also run the seed inline at AppPlugin
342+
// start. In multi-tenant mode, the per-org replay (driven
343+
// by SecurityPlugin's sys_organization middleware) is the
344+
// source of truth — running it here too would create NULL-
345+
// org rows that pollute reads and need a separate claim
346+
// step. So we skip it. Single-tenant deployments keep the
347+
// legacy behaviour: seed immediately at boot so there's
348+
// always demo data without needing an org insert.
349+
const multiTenant = String(process.env.OS_MULTI_TENANT ?? 'true').toLowerCase() !== 'false';
350+
if (multiTenant) {
351+
ctx.logger.info('[Seeder] multi-tenant mode — skipping inline seed; per-org replay will run on sys_organization insert');
352+
} else {
279353
// Use SeedLoaderService for metadata-driven loading with reference resolution
280354
try {
281355
const metadata = ctx.getService('metadata') as IMetadataService | undefined;
@@ -321,6 +395,7 @@ export class AppPlugin implements Plugin {
321395
}
322396
ctx.logger.info('[Seeder] Data seeding complete (fallback).');
323397
}
398+
}
324399
}
325400
}
326401

packages/runtime/src/seed-loader.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export class SeedLoaderService implements ISeedLoaderService {
102102
this.logger.info('[SeedLoader] Pass 2: resolving deferred references', {
103103
count: deferredUpdates.length,
104104
});
105-
await this.resolveDeferredUpdates(deferredUpdates, insertedRecords, allResults, allErrors);
105+
await this.resolveDeferredUpdates(deferredUpdates, insertedRecords, allResults, allErrors, config.organizationId);
106106
}
107107

108108
// 7. Build final result
@@ -187,10 +187,17 @@ export class SeedLoaderService implements ISeedLoaderService {
187187
insertedRecords.set(objectName, new Map());
188188
}
189189

190-
// Pre-load existing records for upsert matching
190+
// Pre-load existing records for upsert matching. When a target
191+
// organization is set, scope the lookup so each tenant gets its
192+
// own copy (otherwise upsert would clobber other tenants' rows
193+
// that share the same natural key — e.g. `name: 'Acme Corp'`).
191194
let existingRecords: Map<string, any> | undefined;
192195
if ((mode === 'upsert' || mode === 'update' || mode === 'ignore') && !config.dryRun) {
193-
existingRecords = await this.loadExistingRecords(objectName, externalId);
196+
existingRecords = await this.loadExistingRecords(
197+
objectName,
198+
externalId,
199+
config.organizationId,
200+
);
194201
}
195202

196203
// Get reference resolutions for this object
@@ -216,6 +223,15 @@ export class SeedLoaderService implements ISeedLoaderService {
216223
);
217224
}
218225

226+
// Per-tenant tagging: when a target org is set, stamp every
227+
// seeded row with it (unless the record itself already supplies
228+
// an explicit organization_id — respect dataset author overrides).
229+
// Skipped objects that don't declare `organization_id` will have
230+
// the extra key silently ignored by the engine.
231+
if (config.organizationId && record['organization_id'] == null) {
232+
record['organization_id'] = config.organizationId;
233+
}
234+
219235
// Resolve references
220236
for (const ref of objectRefs) {
221237
const fieldValue = record[ref.field];
@@ -233,7 +249,7 @@ export class SeedLoaderService implements ISeedLoaderService {
233249
referencesResolved++;
234250
} else if (!config.dryRun) {
235251
// Try to resolve from existing data in the database
236-
const dbId = await this.resolveFromDatabase(ref.targetObject, ref.targetField, fieldValue);
252+
const dbId = await this.resolveFromDatabase(ref.targetObject, ref.targetField, fieldValue, config.organizationId);
237253
if (dbId) {
238254
record[ref.field] = dbId;
239255
referencesResolved++;
@@ -339,10 +355,17 @@ export class SeedLoaderService implements ISeedLoaderService {
339355
targetObject: string,
340356
targetField: string,
341357
value: unknown,
358+
organizationId?: string,
342359
): Promise<string | null> {
343360
try {
361+
const where: Record<string, unknown> = { [targetField]: value };
362+
// Per-tenant replay: when scoping is requested, only consider
363+
// rows that belong to the target tenant so cross-tenant rows
364+
// never get borrowed as a "resolved" reference (would silently
365+
// create a cross-org FK).
366+
if (organizationId) where.organization_id = organizationId;
344367
const records = await this.engine.find(targetObject, {
345-
where: { [targetField]: value },
368+
where,
346369
fields: ['id'],
347370
limit: 1,
348371
context: { isSystem: true },
@@ -361,6 +384,7 @@ export class SeedLoaderService implements ISeedLoaderService {
361384
insertedRecords: Map<string, Map<string, string>>,
362385
allResults: DatasetLoadResult[],
363386
allErrors: ReferenceResolutionError[],
387+
organizationId?: string,
364388
): Promise<void> {
365389
for (const deferred of deferredUpdates) {
366390
// Try to resolve from inserted records
@@ -370,7 +394,7 @@ export class SeedLoaderService implements ISeedLoaderService {
370394
// Try database fallback
371395
if (!resolvedId) {
372396
resolvedId = (await this.resolveFromDatabase(
373-
deferred.targetObject, deferred.targetField, deferred.attemptedValue
397+
deferred.targetObject, deferred.targetField, deferred.attemptedValue, organizationId
374398
)) ?? undefined;
375399
}
376400

@@ -637,13 +661,19 @@ export class SeedLoaderService implements ISeedLoaderService {
637661
private async loadExistingRecords(
638662
objectName: string,
639663
externalId: string,
664+
organizationId?: string,
640665
): Promise<Map<string, any>> {
641666
const map = new Map<string, any>();
642667
try {
643-
const records = await this.engine.find(objectName, {
668+
const findArgs: Record<string, unknown> = {
644669
fields: ['id', externalId],
645670
context: { isSystem: true },
646-
} as any);
671+
};
672+
// Per-tenant replay: restrict to the target tenant's own rows
673+
// so upsert key matching never returns another tenant's record
674+
// (would silently steal/overwrite rows across orgs).
675+
if (organizationId) findArgs.where = { organization_id: organizationId };
676+
const records = await this.engine.find(objectName, findArgs as any);
647677
for (const record of records || []) {
648678
const key = String(record[externalId] ?? '');
649679
if (key) {

packages/spec/src/data/seed-loader.zod.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,25 @@ export const SeedLoaderConfigSchema = lazySchema(() => z.object({
212212
*/
213213
env: z.enum(['prod', 'dev', 'test']).optional()
214214
.describe('Only load datasets matching this environment'),
215+
216+
/**
217+
* Target organization for per-tenant seed loading.
218+
*
219+
* When set, the loader:
220+
* - Injects `organization_id: <id>` into every record before write
221+
* (unless the record already supplies one).
222+
* - Scopes existing-record lookups (`loadExistingRecords`,
223+
* `resolveFromDatabase`) to `organization_id = <id>`, so `upsert`
224+
* mode finds the per-org copy rather than another tenant's row.
225+
*
226+
* Use this from a `sys_organization` insert hook to give every new
227+
* tenant a private copy of the artifact-shipped demo data (Salesforce
228+
* sandbox style). Omit for cross-tenant / platform-wide seeds (e.g.
229+
* `sys_permission_set`) — those rows should have `organization_id`
230+
* NULL and are not filtered by the default tenant RLS.
231+
*/
232+
organizationId: z.string().min(1).optional()
233+
.describe('Target organization id for per-tenant seed replay'),
215234
}).describe('Seed data loader configuration'));
216235

217236
export type SeedLoaderConfig = z.infer<typeof SeedLoaderConfigSchema>;

0 commit comments

Comments
 (0)