Skip to content

Commit 1707fe5

Browse files
authored
Merge pull request #2021 from objectstack-ai/feat/seed-ownership-handoff
feat(ownership): auto-provision canonical owner_id + hand seeded records to first admin
2 parents 1d352d3 + efac8e8 commit 1707fe5

12 files changed

Lines changed: 477 additions & 100 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
feat(ownership): auto-provision a canonical `owner_id` and hand seeded records to the first admin
8+
9+
Ownership is now correct-by-default instead of opt-in — closing the gap where
10+
seeded demo data ended up owned by nobody a human can log in as (so "My" views,
11+
owner reports and owner notifications were empty out of the box) and where
12+
author-written objects silently shipped with no working ownership at all.
13+
14+
- **`applySystemFields` (objectql)** now auto-injects a canonical, reassignable
15+
`owner_id` lookup (→ `sys_user`) on user-authored business objects, alongside
16+
the existing tenant/audit fields. Unlike the audit `*_by` lookups it is NOT
17+
readonly — ownership transfers. Withheld for `managedBy` / `sys_*` tables and
18+
for objects that opt out via `ownership: 'org' | 'none'` (Dataverse-style). The
19+
safe default direction: forgetting the opt-out leaves a harmless spare column,
20+
whereas the old opt-IN model let authors ship objects with broken ownership.
21+
Once present, the existing machinery engages automatically (insert auto-stamp,
22+
owner-scoped RLS, owner-keyed views/reports).
23+
24+
- **`claimSeedOwnership` (plugin-security)**, invoked from `bootstrapPlatformAdmin`
25+
right after the first human is promoted to platform admin, transfers ownership
26+
of seeded rows (`owner_id` NULL or `usr_system`) to that admin. The ownership
27+
twin of org-scoping's `claimOrphanOrgRows`. Idempotent; skips `managedBy` /
28+
`sys_*`. Authors write plain seed records (no `owner_id`) and the platform —
29+
not the author — performs the handoff, so there is nothing to remember or
30+
mistype.
31+
32+
- **`usr_system` is never minted (runtime + objectql).** The seed loader binds
33+
`os.user` to a NULL identity, so `cel`os.user.id`` resolves to NULL at seed
34+
time (the owning admin does not exist yet) and the row seeds NULL-owned — then
35+
the handoff above fills it. The runtime's `ensureSeedIdentity` (the only code
36+
that inserted a `usr_system` row) is removed. `SystemUserId.SYSTEM` survives
37+
only as a reserved id so legacy DBs' exclusion guards / ownership handoff still
38+
recognize a pre-existing row. `os.org` is unaffected (derived from
39+
`organizationId`).
40+
41+
Also hardens `bootstrapPlatformAdmin` against a latent dts typecheck error
42+
(defensive read of the untyped `description` on seed permission sets).

packages/objectql/src/registry.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,49 @@ describe('applySystemFields', () => {
573573
expect(out.fields.updated_at).toMatchObject({ system: true });
574574
});
575575

576+
// ── owner_id — canonical, reassignable record owner ──────────────────
577+
it('injects owner_id by default on a business object (reassignable, not readonly)', () => {
578+
const out = applySystemFields(baseLead, { multiTenant: false });
579+
expect(out.fields.owner_id).toMatchObject({
580+
type: 'lookup', reference: 'sys_user', system: true, readonly: false,
581+
});
582+
// Unlike the audit *_by lookups, owner is editable (ownership transfers).
583+
expect(out.fields.owner_id.readonly).toBe(false);
584+
});
585+
586+
it('does NOT inject owner_id for managedBy tables', () => {
587+
const platform: any = { name: 'sys_audit_log', managedBy: 'platform', fields: { msg: { type: 'text' } } };
588+
const out = applySystemFields(platform, { multiTenant: false });
589+
expect(out.fields.owner_id).toBeUndefined();
590+
});
591+
592+
it('does NOT inject owner_id for sys_* objects', () => {
593+
const sysish: any = { name: 'sys_widget', fields: { msg: { type: 'text' } } };
594+
const out = applySystemFields(sysish, { multiTenant: false });
595+
expect(out.fields.owner_id).toBeUndefined();
596+
// audit/tenant fields are still injected for non-managed sys_ objects
597+
expect(out.fields.created_at).toBeDefined();
598+
});
599+
600+
it('respects ownership: "org" / "none" opt-out (audit + tenant still injected)', () => {
601+
for (const ownership of ['org', 'none'] as const) {
602+
const opted: any = { ...baseLead, ownership };
603+
const out = applySystemFields(opted, { multiTenant: false });
604+
expect(out.fields.owner_id).toBeUndefined();
605+
expect(out.fields.created_at).toBeDefined();
606+
}
607+
});
608+
609+
it('does NOT overwrite an author-declared owner_id', () => {
610+
const declared: any = {
611+
name: 'lead',
612+
fields: { owner_id: { type: 'lookup', reference: 'sys_user', label: 'Custom Owner', readonly: true } },
613+
};
614+
const out = applySystemFields(declared, { multiTenant: false });
615+
expect(out.fields.owner_id.label).toBe('Custom Owner');
616+
expect(out.fields.owner_id.readonly).toBe(true);
617+
});
618+
576619
it('SchemaRegistry({ multiTenant: true }) auto-injects on registerObject', () => {
577620
const reg = new SchemaRegistry({ multiTenant: true });
578621
reg.registerObject({ name: 'lead', fields: { first_name: { type: 'text' } } } as any, 'crm', 'crm', 'own');

packages/objectql/src/registry.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,22 @@ export interface SchemaRegistryOptions {
186186
* timestamps; the `*_by` lookups are filled by the runtime when an
187187
* authenticated session is present (NULL otherwise — e.g. seeded
188188
* rows).
189+
* - `owner_id` — canonical, *reassignable* record owner (lookup to
190+
* `sys_user`). Auto-provisioned by DEFAULT on user-authored business
191+
* objects so ownership is correct-by-default for AI/human authors who
192+
* would otherwise forget to declare it (or reinvent a custom `owner`
193+
* lookup the platform can't see). Once present, the existing machinery
194+
* engages automatically: SecurityPlugin auto-stamps it to the acting
195+
* user on insert (step 3.5), owner-scoped RLS / "My" views / owner
196+
* reports key off it, and the first-admin bootstrap hands seeded rows
197+
* (owner_id NULL) to the promoted admin. Unlike `created_by` it is
198+
* editable (`readonly: false`) — ownership transfers; provenance does
199+
* not. Excluded for `managedBy` / `sys_*` tables and any object that
200+
* opts out via `ownership: 'org' | 'none'` (Dataverse-style: a
201+
* catalog/junction table that has no per-record owner). Forgetting the
202+
* opt-out is harmless (a spare nullable column), whereas forgetting to
203+
* ADD ownership — the failure mode we are eliminating — silently breaks
204+
* every owner-keyed feature.
189205
*/
190206
export function applySystemFields(
191207
schema: ServiceObject,
@@ -232,6 +248,22 @@ export function applySystemFields(
232248
const wantTenant = sf?.tenant !== false && !tenancyDisabled;
233249
const wantAudit = sf?.audit !== false;
234250

251+
// Ownership is auto-provisioned by DEFAULT on user-authored business
252+
// objects (correct-by-default for AI authors). It is withheld only where a
253+
// per-record owner is meaningless: any platform-managed table (`managedBy`
254+
// is set — config/append-only/system/platform; `better-auth` already
255+
// returned above), the `sys_*` namespace, or an explicit opt-out via
256+
// `ownership: 'org' | 'none'` on the schema (Dataverse-style — catalog /
257+
// junction tables). Note this is the SAFE default direction: forgetting the
258+
// opt-out leaves a harmless spare column, whereas the old opt-IN model let
259+
// authors silently ship objects with no working ownership at all.
260+
const ownership = (schema as any).ownership as 'user' | 'org' | 'none' | undefined;
261+
const wantOwner =
262+
ownership !== 'org' &&
263+
ownership !== 'none' &&
264+
!(schema as any).managedBy &&
265+
!schema.name.startsWith('sys_');
266+
235267
const additions: Record<string, any> = {};
236268

237269
if (wantTenant && !schema.fields?.organization_id) {
@@ -294,6 +326,25 @@ export function applySystemFields(
294326
}
295327
}
296328

329+
// Canonical reassignable owner. `system: true` marks it platform-provided
330+
// (so tooling/migrations recognise it), but — unlike the audit `*_by`
331+
// lookups — it is NOT `readonly`: ownership is transferable, so it stays
332+
// editable in forms and assignable via the API. SecurityPlugin auto-stamps
333+
// it to the acting user on insert when left NULL.
334+
if (wantOwner && !schema.fields?.owner_id) {
335+
additions.owner_id = {
336+
type: 'lookup',
337+
reference: 'sys_user',
338+
label: 'Owner',
339+
required: false,
340+
readonly: false,
341+
system: true,
342+
description:
343+
'Record owner (auto-stamped to the creating user on insert; reassignable). ' +
344+
'Drives owner-scoped views, reports and notifications.',
345+
};
346+
}
347+
297348
if (Object.keys(additions).length === 0) return schema;
298349

299350
return {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { resolveSeedRecord } from '@objectstack/formula';
5+
6+
/**
7+
* The SeedLoader binds `os.user` to a NULL identity (`{ id: null }`) when no
8+
* real user exists at seed time (the normal case). This proves the resolution
9+
* behavior that lets us drop the `usr_system` placeholder entirely:
10+
* `owner_id: cel`os.user.id`` resolves to NULL (not a crash, not a dropped
11+
* record), and the first-admin handoff later claims the NULL-owned row.
12+
*/
13+
describe('seed os.user binding (usr_system-free)', () => {
14+
const cel = (source: string) => ({ dialect: 'cel', source });
15+
16+
it('resolves os.user.id to null under a NULL identity (no error, no drop)', () => {
17+
const rec = { name: 'Acme', owner_id: cel('os.user.id') };
18+
const ctx = { now: new Date(), user: { id: null }, org: undefined, env: {} };
19+
20+
const result = resolveSeedRecord(rec as any, ctx as any);
21+
22+
expect(result.ok).toBe(true);
23+
expect((result as any).value.owner_id).toBeNull();
24+
// Non-identity dynamic values still resolve normally alongside it.
25+
expect((result as any).value.name).toBe('Acme');
26+
});
27+
28+
it('still resolves a real os.user.id when an identity IS supplied (per-org replay)', () => {
29+
const rec = { owner_id: cel('os.user.id') };
30+
const ctx = { now: new Date(), user: { id: 'usr_real_admin' }, org: undefined, env: {} };
31+
32+
const result = resolveSeedRecord(rec as any, ctx as any);
33+
34+
expect(result.ok).toBe(true);
35+
expect((result as any).value.owner_id).toBe('usr_real_admin');
36+
});
37+
});

packages/objectql/src/seed-loader.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -208,14 +208,23 @@ export class SeedLoaderService implements ISeedLoaderService {
208208
const seedNow = new Date();
209209

210210
// Identity/context bound to seed CEL expressions. `os.user` / `os.org`
211-
// resolve from here, so `owner_id: cel\`os.user.id\`` works. When no
212-
// identity is supplied, `os.user` / `os.org` are simply unbound and any
213-
// record that references them fails loudly below (rather than silently
214-
// writing a raw Expression envelope into the column).
211+
// resolve from here, so `owner_id: cel\`os.user.id\`` works.
212+
//
213+
// When no real user identity is supplied (the normal case — seeds run
214+
// before the first human sign-up), `os.user` is bound to a NULL identity
215+
// (`{ id: null }`) rather than left undefined. This makes `os.user.id`
216+
// resolve to `null` instead of crashing the expression, so a seed's
217+
// `owner_id: cel\`os.user.id\`` simply lands NULL — semantically "owned by
218+
// whoever becomes the first admin", which the first-admin handoff
219+
// (`claimSeedOwnership`) then fills in. The platform therefore never has to
220+
// mint a placeholder `usr_system` row just to satisfy this expression.
215221
const seedIdentity = config.identity;
216222
const baseEvalCtx = {
217223
now: seedNow,
218-
user: seedIdentity?.user,
224+
// `id: null` is a legitimate seed-time state (the owning admin does not
225+
// exist yet) that the formula EvalContext's `user.id: string` type does
226+
// not yet model — cast the fallback so `os.user.id` evaluates to null.
227+
user: seedIdentity?.user ?? ({ id: null } as unknown as NonNullable<typeof seedIdentity>['user']),
219228
// Fall back to the per-tenant organizationId so `os.org.id` resolves
220229
// during per-org replay even without an explicit identity.org.
221230
org: seedIdentity?.org ?? (config.organizationId ? { id: config.organizationId } : undefined),
@@ -245,8 +254,9 @@ export class SeedLoaderService implements ISeedLoaderService {
245254
recordIndex: i,
246255
message:
247256
`Cannot resolve dynamic seed values for ${objectName} record #${i}: ${seedResult.error.message}. ` +
248-
'Records using cel`os.user.id` / cel`os.org.id` require a seed identity — ' +
249-
'ensure a system/admin user exists before seeding (see SeedLoaderConfig.identity).',
257+
'`os.user.id` resolves to null at seed time (the owning admin does not exist yet) and ' +
258+
'owner-style fields are assigned by the first-admin handoff — so a required, non-owner ' +
259+
'field must not depend on it. Provide a literal value or make the field optional.',
250260
};
251261
errors.push(error);
252262
allErrors.push(error);

packages/plugins/plugin-security/src/bootstrap-platform-admin.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import type { PermissionSet } from '@objectstack/spec/security';
2323
import { SystemUserId } from '@objectstack/spec/system';
24+
import { claimSeedOwnership } from './claim-seed-ownership.js';
2425

2526
interface BootstrapOptions {
2627
/** Logger from PluginContext. */
@@ -67,6 +68,8 @@ export async function bootstrapPlatformAdmin(
6768
seeded: number;
6869
adminPromoted: boolean;
6970
reason?: string;
71+
/** Count of seeded rows re-owned to the freshly-promoted admin. */
72+
ownershipClaimed?: number;
7073
}> {
7174
const logger = options.logger;
7275
if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') {
@@ -87,7 +90,10 @@ export async function bootstrapPlatformAdmin(
8790
id,
8891
name: ps.name,
8992
label: ps.label ?? ps.name,
90-
description: ps.description ?? null,
93+
// `description` is not part of the typed PermissionSet shape (name/label
94+
// only); read it defensively so a runtime-provided description still
95+
// persists without tripping the dts typecheck.
96+
description: (ps as any).description ?? null,
9197
object_permissions: JSON.stringify(ps.objects ?? {}),
9298
field_permissions: JSON.stringify(ps.fields ?? {}),
9399
// Persist the remaining permset facets so the runtime resolver
@@ -163,5 +169,16 @@ export async function bootstrapPlatformAdmin(
163169
}
164170
logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`);
165171

166-
return { seeded: seededCount, adminPromoted: true };
172+
// Hand seeded business records (owner_id NULL / usr_system) to the freshly
173+
// promoted admin so owner-keyed UX works out of the box. Best-effort and
174+
// idempotent — failures here must not undo the promotion above.
175+
let ownershipClaimed = 0;
176+
try {
177+
const claims = await claimSeedOwnership(ql, target.id, { logger });
178+
ownershipClaimed = claims.reduce((s, c) => s + c.count, 0);
179+
} catch (e) {
180+
logger?.warn?.('[security] seed ownership handoff failed', { error: (e as Error).message });
181+
}
182+
183+
return { seeded: seededCount, adminPromoted: true, ownershipClaimed };
167184
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { claimSeedOwnership } from './claim-seed-ownership.js';
5+
6+
const SYSTEM = 'usr_system';
7+
const ADMIN = 'usr_admin_human';
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+
if ('owner_id' in w) {
17+
return all.filter((r) => (r.owner_id ?? null) === (w.owner_id ?? null));
18+
}
19+
return all;
20+
}),
21+
update: vi.fn(async (object: string, data: any) => {
22+
updates.push({ object, data });
23+
const row = (rowsByObject[object] ?? []).find((r) => r.id === data.id);
24+
if (row) row.owner_id = data.owner_id;
25+
return row;
26+
}),
27+
};
28+
return { ql, updates };
29+
}
30+
31+
describe('claimSeedOwnership', () => {
32+
it('returns [] when registry is unavailable', async () => {
33+
const ql: any = { find: vi.fn(), update: vi.fn() };
34+
expect(await claimSeedOwnership(ql, ADMIN)).toEqual([]);
35+
});
36+
37+
it('no-ops when the target is empty or the system user', async () => {
38+
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }];
39+
const { ql, updates } = makeQL(schemas, { crm_lead: [{ id: 'l1', owner_id: null }] });
40+
expect(await claimSeedOwnership(ql, '')).toEqual([]);
41+
expect(await claimSeedOwnership(ql, SYSTEM)).toEqual([]);
42+
expect(updates).toHaveLength(0);
43+
});
44+
45+
it('skips managedBy and sys_* tables', async () => {
46+
const schemas = [
47+
{ name: 'sys_user', managedBy: 'better-auth', fields: [{ name: 'owner_id' }] },
48+
{ name: 'sys_widget', fields: [{ name: 'owner_id' }] },
49+
];
50+
const { ql, updates } = makeQL(schemas, {
51+
sys_user: [{ id: 'u1', owner_id: null }],
52+
sys_widget: [{ id: 'w1', owner_id: null }],
53+
});
54+
expect(await claimSeedOwnership(ql, ADMIN)).toEqual([]);
55+
expect(updates).toHaveLength(0);
56+
});
57+
58+
it('skips objects without an owner_id field', async () => {
59+
const schemas = [{ name: 'crm_pricebook', fields: [{ name: 'name' }] }];
60+
const { ql, updates } = makeQL(schemas, { crm_pricebook: [{ id: 'p1' }] });
61+
expect(await claimSeedOwnership(ql, ADMIN)).toEqual([]);
62+
expect(updates).toHaveLength(0);
63+
});
64+
65+
it('re-owns NULL and usr_system rows to the admin, leaving human-owned rows untouched', async () => {
66+
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }];
67+
const rows = [
68+
{ id: 'l1', owner_id: null }, // claimed (author left unset)
69+
{ id: 'l2', owner_id: SYSTEM }, // claimed (seed identity)
70+
{ id: 'l3', owner_id: 'usr_someone' },// untouched (already human-owned)
71+
];
72+
const { ql, updates } = makeQL(schemas, { crm_lead: rows });
73+
const result = await claimSeedOwnership(ql, ADMIN);
74+
75+
expect(result).toEqual([{ object: 'crm_lead', count: 2 }]);
76+
expect(updates.map((u) => u.data.id).sort()).toEqual(['l1', 'l2']);
77+
expect(updates.every((u) => u.data.owner_id === ADMIN)).toBe(true);
78+
expect(rows.find((r) => r.id === 'l3')!.owner_id).toBe('usr_someone');
79+
});
80+
81+
it('is idempotent — a second run claims nothing', async () => {
82+
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }];
83+
const { ql } = makeQL(schemas, { crm_lead: [{ id: 'l1', owner_id: null }] });
84+
await claimSeedOwnership(ql, ADMIN);
85+
const second = await claimSeedOwnership(ql, ADMIN);
86+
expect(second).toEqual([]);
87+
});
88+
});

0 commit comments

Comments
 (0)