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
42 changes: 42 additions & 0 deletions .changeset/seed-ownership-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@objectstack/objectql": minor
"@objectstack/plugin-security": minor
"@objectstack/runtime": minor
---

feat(ownership): auto-provision a canonical `owner_id` and hand seeded records to the first admin

Ownership is now correct-by-default instead of opt-in — closing the gap where
seeded demo data ended up owned by nobody a human can log in as (so "My" views,
owner reports and owner notifications were empty out of the box) and where
author-written objects silently shipped with no working ownership at all.

- **`applySystemFields` (objectql)** now auto-injects a canonical, reassignable
`owner_id` lookup (→ `sys_user`) on user-authored business objects, alongside
the existing tenant/audit fields. Unlike the audit `*_by` lookups it is NOT
readonly — ownership transfers. Withheld for `managedBy` / `sys_*` tables and
for objects that opt out via `ownership: 'org' | 'none'` (Dataverse-style). The
safe default direction: forgetting the opt-out leaves a harmless spare column,
whereas the old opt-IN model let authors ship objects with broken ownership.
Once present, the existing machinery engages automatically (insert auto-stamp,
owner-scoped RLS, owner-keyed views/reports).

- **`claimSeedOwnership` (plugin-security)**, invoked from `bootstrapPlatformAdmin`
right after the first human is promoted to platform admin, transfers ownership
of seeded rows (`owner_id` NULL or `usr_system`) to that admin. The ownership
twin of org-scoping's `claimOrphanOrgRows`. Idempotent; skips `managedBy` /
`sys_*`. Authors write plain seed records (no `owner_id`) and the platform —
not the author — performs the handoff, so there is nothing to remember or
mistype.

- **`usr_system` is never minted (runtime + objectql).** The seed loader binds
`os.user` to a NULL identity, so `cel`os.user.id`` resolves to NULL at seed
time (the owning admin does not exist yet) and the row seeds NULL-owned — then
the handoff above fills it. The runtime's `ensureSeedIdentity` (the only code
that inserted a `usr_system` row) is removed. `SystemUserId.SYSTEM` survives
only as a reserved id so legacy DBs' exclusion guards / ownership handoff still
recognize a pre-existing row. `os.org` is unaffected (derived from
`organizationId`).

Also hardens `bootstrapPlatformAdmin` against a latent dts typecheck error
(defensive read of the untyped `description` on seed permission sets).
43 changes: 43 additions & 0 deletions packages/objectql/src/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,49 @@ describe('applySystemFields', () => {
expect(out.fields.updated_at).toMatchObject({ system: true });
});

// ── owner_id — canonical, reassignable record owner ──────────────────
it('injects owner_id by default on a business object (reassignable, not readonly)', () => {
const out = applySystemFields(baseLead, { multiTenant: false });
expect(out.fields.owner_id).toMatchObject({
type: 'lookup', reference: 'sys_user', system: true, readonly: false,
});
// Unlike the audit *_by lookups, owner is editable (ownership transfers).
expect(out.fields.owner_id.readonly).toBe(false);
});

it('does NOT inject owner_id for managedBy tables', () => {
const platform: any = { name: 'sys_audit_log', managedBy: 'platform', fields: { msg: { type: 'text' } } };
const out = applySystemFields(platform, { multiTenant: false });
expect(out.fields.owner_id).toBeUndefined();
});

it('does NOT inject owner_id for sys_* objects', () => {
const sysish: any = { name: 'sys_widget', fields: { msg: { type: 'text' } } };
const out = applySystemFields(sysish, { multiTenant: false });
expect(out.fields.owner_id).toBeUndefined();
// audit/tenant fields are still injected for non-managed sys_ objects
expect(out.fields.created_at).toBeDefined();
});

it('respects ownership: "org" / "none" opt-out (audit + tenant still injected)', () => {
for (const ownership of ['org', 'none'] as const) {
const opted: any = { ...baseLead, ownership };
const out = applySystemFields(opted, { multiTenant: false });
expect(out.fields.owner_id).toBeUndefined();
expect(out.fields.created_at).toBeDefined();
}
});

it('does NOT overwrite an author-declared owner_id', () => {
const declared: any = {
name: 'lead',
fields: { owner_id: { type: 'lookup', reference: 'sys_user', label: 'Custom Owner', readonly: true } },
};
const out = applySystemFields(declared, { multiTenant: false });
expect(out.fields.owner_id.label).toBe('Custom Owner');
expect(out.fields.owner_id.readonly).toBe(true);
});

it('SchemaRegistry({ multiTenant: true }) auto-injects on registerObject', () => {
const reg = new SchemaRegistry({ multiTenant: true });
reg.registerObject({ name: 'lead', fields: { first_name: { type: 'text' } } } as any, 'crm', 'crm', 'own');
Expand Down
51 changes: 51 additions & 0 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,22 @@ export interface SchemaRegistryOptions {
* timestamps; the `*_by` lookups are filled by the runtime when an
* authenticated session is present (NULL otherwise — e.g. seeded
* rows).
* - `owner_id` — canonical, *reassignable* record owner (lookup to
* `sys_user`). Auto-provisioned by DEFAULT on user-authored business
* objects so ownership is correct-by-default for AI/human authors who
* would otherwise forget to declare it (or reinvent a custom `owner`
* lookup the platform can't see). Once present, the existing machinery
* engages automatically: SecurityPlugin auto-stamps it to the acting
* user on insert (step 3.5), owner-scoped RLS / "My" views / owner
* reports key off it, and the first-admin bootstrap hands seeded rows
* (owner_id NULL) to the promoted admin. Unlike `created_by` it is
* editable (`readonly: false`) — ownership transfers; provenance does
* not. Excluded for `managedBy` / `sys_*` tables and any object that
* opts out via `ownership: 'org' | 'none'` (Dataverse-style: a
* catalog/junction table that has no per-record owner). Forgetting the
* opt-out is harmless (a spare nullable column), whereas forgetting to
* ADD ownership — the failure mode we are eliminating — silently breaks
* every owner-keyed feature.
*/
export function applySystemFields(
schema: ServiceObject,
Expand Down Expand Up @@ -232,6 +248,22 @@ export function applySystemFields(
const wantTenant = sf?.tenant !== false && !tenancyDisabled;
const wantAudit = sf?.audit !== false;

// Ownership is auto-provisioned by DEFAULT on user-authored business
// objects (correct-by-default for AI authors). It is withheld only where a
// per-record owner is meaningless: any platform-managed table (`managedBy`
// is set — config/append-only/system/platform; `better-auth` already
// returned above), the `sys_*` namespace, or an explicit opt-out via
// `ownership: 'org' | 'none'` on the schema (Dataverse-style — catalog /
// junction tables). Note this is the SAFE default direction: forgetting the
// opt-out leaves a harmless spare column, whereas the old opt-IN model let
// authors silently ship objects with no working ownership at all.
const ownership = (schema as any).ownership as 'user' | 'org' | 'none' | undefined;
const wantOwner =
ownership !== 'org' &&
ownership !== 'none' &&
!(schema as any).managedBy &&
!schema.name.startsWith('sys_');

const additions: Record<string, any> = {};

if (wantTenant && !schema.fields?.organization_id) {
Expand Down Expand Up @@ -294,6 +326,25 @@ export function applySystemFields(
}
}

// Canonical reassignable owner. `system: true` marks it platform-provided
// (so tooling/migrations recognise it), but — unlike the audit `*_by`
// lookups — it is NOT `readonly`: ownership is transferable, so it stays
// editable in forms and assignable via the API. SecurityPlugin auto-stamps
// it to the acting user on insert when left NULL.
if (wantOwner && !schema.fields?.owner_id) {
additions.owner_id = {
type: 'lookup',
reference: 'sys_user',
label: 'Owner',
required: false,
readonly: false,
system: true,
description:
'Record owner (auto-stamped to the creating user on insert; reassignable). ' +
'Drives owner-scoped views, reports and notifications.',
};
}

if (Object.keys(additions).length === 0) return schema;

return {
Expand Down
37 changes: 37 additions & 0 deletions packages/objectql/src/seed-loader-os-user.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { resolveSeedRecord } from '@objectstack/formula';

/**
* The SeedLoader binds `os.user` to a NULL identity (`{ id: null }`) when no
* real user exists at seed time (the normal case). This proves the resolution
* behavior that lets us drop the `usr_system` placeholder entirely:
* `owner_id: cel`os.user.id`` resolves to NULL (not a crash, not a dropped
* record), and the first-admin handoff later claims the NULL-owned row.
*/
describe('seed os.user binding (usr_system-free)', () => {
const cel = (source: string) => ({ dialect: 'cel', source });

it('resolves os.user.id to null under a NULL identity (no error, no drop)', () => {
const rec = { name: 'Acme', owner_id: cel('os.user.id') };
const ctx = { now: new Date(), user: { id: null }, org: undefined, env: {} };

const result = resolveSeedRecord(rec as any, ctx as any);

expect(result.ok).toBe(true);
expect((result as any).value.owner_id).toBeNull();
// Non-identity dynamic values still resolve normally alongside it.
expect((result as any).value.name).toBe('Acme');
});

it('still resolves a real os.user.id when an identity IS supplied (per-org replay)', () => {
const rec = { owner_id: cel('os.user.id') };
const ctx = { now: new Date(), user: { id: 'usr_real_admin' }, org: undefined, env: {} };

const result = resolveSeedRecord(rec as any, ctx as any);

expect(result.ok).toBe(true);
expect((result as any).value.owner_id).toBe('usr_real_admin');
});
});
24 changes: 17 additions & 7 deletions packages/objectql/src/seed-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,14 +208,23 @@ export class SeedLoaderService implements ISeedLoaderService {
const seedNow = new Date();

// Identity/context bound to seed CEL expressions. `os.user` / `os.org`
// resolve from here, so `owner_id: cel\`os.user.id\`` works. When no
// identity is supplied, `os.user` / `os.org` are simply unbound and any
// record that references them fails loudly below (rather than silently
// writing a raw Expression envelope into the column).
// resolve from here, so `owner_id: cel\`os.user.id\`` works.
//
// When no real user identity is supplied (the normal case — seeds run
// before the first human sign-up), `os.user` is bound to a NULL identity
// (`{ id: null }`) rather than left undefined. This makes `os.user.id`
// resolve to `null` instead of crashing the expression, so a seed's
// `owner_id: cel\`os.user.id\`` simply lands NULL — semantically "owned by
// whoever becomes the first admin", which the first-admin handoff
// (`claimSeedOwnership`) then fills in. The platform therefore never has to
// mint a placeholder `usr_system` row just to satisfy this expression.
const seedIdentity = config.identity;
const baseEvalCtx = {
now: seedNow,
user: seedIdentity?.user,
// `id: null` is a legitimate seed-time state (the owning admin does not
// exist yet) that the formula EvalContext's `user.id: string` type does
// not yet model — cast the fallback so `os.user.id` evaluates to null.
user: seedIdentity?.user ?? ({ id: null } as unknown as NonNullable<typeof seedIdentity>['user']),
// Fall back to the per-tenant organizationId so `os.org.id` resolves
// during per-org replay even without an explicit identity.org.
org: seedIdentity?.org ?? (config.organizationId ? { id: config.organizationId } : undefined),
Expand Down Expand Up @@ -245,8 +254,9 @@ export class SeedLoaderService implements ISeedLoaderService {
recordIndex: i,
message:
`Cannot resolve dynamic seed values for ${objectName} record #${i}: ${seedResult.error.message}. ` +
'Records using cel`os.user.id` / cel`os.org.id` require a seed identity — ' +
'ensure a system/admin user exists before seeding (see SeedLoaderConfig.identity).',
'`os.user.id` resolves to null at seed time (the owning admin does not exist yet) and ' +
'owner-style fields are assigned by the first-admin handoff — so a required, non-owner ' +
'field must not depend on it. Provide a literal value or make the field optional.',
};
errors.push(error);
allErrors.push(error);
Expand Down
21 changes: 19 additions & 2 deletions packages/plugins/plugin-security/src/bootstrap-platform-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

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

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

return { seeded: seededCount, adminPromoted: true };
// Hand seeded business records (owner_id NULL / usr_system) to the freshly
// promoted admin so owner-keyed UX works out of the box. Best-effort and
// idempotent — failures here must not undo the promotion above.
let ownershipClaimed = 0;
try {
const claims = await claimSeedOwnership(ql, target.id, { logger });
ownershipClaimed = claims.reduce((s, c) => s + c.count, 0);
} catch (e) {
logger?.warn?.('[security] seed ownership handoff failed', { error: (e as Error).message });
}

return { seeded: seededCount, adminPromoted: true, ownershipClaimed };
}
88 changes: 88 additions & 0 deletions packages/plugins/plugin-security/src/claim-seed-ownership.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

const SYSTEM = 'usr_system';
const ADMIN = 'usr_admin_human';

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 ?? {};
if ('owner_id' in w) {
return all.filter((r) => (r.owner_id ?? null) === (w.owner_id ?? null));
}
return all;
}),
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('claimSeedOwnership', () => {
it('returns [] when registry is unavailable', async () => {
const ql: any = { find: vi.fn(), update: vi.fn() };
expect(await claimSeedOwnership(ql, ADMIN)).toEqual([]);
});

it('no-ops when the target is empty or the system user', async () => {
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }];
const { ql, updates } = makeQL(schemas, { crm_lead: [{ id: 'l1', owner_id: null }] });
expect(await claimSeedOwnership(ql, '')).toEqual([]);
expect(await claimSeedOwnership(ql, SYSTEM)).toEqual([]);
expect(updates).toHaveLength(0);
});

it('skips managedBy and sys_* tables', async () => {
const schemas = [
{ name: 'sys_user', managedBy: 'better-auth', fields: [{ name: 'owner_id' }] },
{ name: 'sys_widget', fields: [{ name: 'owner_id' }] },
];
const { ql, updates } = makeQL(schemas, {
sys_user: [{ id: 'u1', owner_id: null }],
sys_widget: [{ id: 'w1', owner_id: null }],
});
expect(await claimSeedOwnership(ql, ADMIN)).toEqual([]);
expect(updates).toHaveLength(0);
});

it('skips objects without an owner_id field', async () => {
const schemas = [{ name: 'crm_pricebook', fields: [{ name: 'name' }] }];
const { ql, updates } = makeQL(schemas, { crm_pricebook: [{ id: 'p1' }] });
expect(await claimSeedOwnership(ql, ADMIN)).toEqual([]);
expect(updates).toHaveLength(0);
});

it('re-owns NULL and usr_system rows to the admin, leaving human-owned rows untouched', async () => {
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }];
const rows = [
{ id: 'l1', owner_id: null }, // claimed (author left unset)
{ id: 'l2', owner_id: SYSTEM }, // claimed (seed identity)
{ id: 'l3', owner_id: 'usr_someone' },// untouched (already human-owned)
];
const { ql, updates } = makeQL(schemas, { crm_lead: rows });
const result = await claimSeedOwnership(ql, ADMIN);

expect(result).toEqual([{ object: 'crm_lead', count: 2 }]);
expect(updates.map((u) => u.data.id).sort()).toEqual(['l1', 'l2']);
expect(updates.every((u) => u.data.owner_id === ADMIN)).toBe(true);
expect(rows.find((r) => r.id === 'l3')!.owner_id).toBe('usr_someone');
});

it('is idempotent — a second run claims nothing', async () => {
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }];
const { ql } = makeQL(schemas, { crm_lead: [{ id: 'l1', owner_id: null }] });
await claimSeedOwnership(ql, ADMIN);
const second = await claimSeedOwnership(ql, ADMIN);
expect(second).toEqual([]);
});
});
Loading
Loading