diff --git a/.changeset/23189-identity-objects-default-organization.md b/.changeset/23189-identity-objects-default-organization.md new file mode 100644 index 00000000000..7cf17654fb5 --- /dev/null +++ b/.changeset/23189-identity-objects-default-organization.md @@ -0,0 +1,22 @@ +--- +'@objectstack/plugin-auth': minor +'@objectstack/runtime': patch +--- + +fix(plugin-auth)!: IdentityObjectsPlugin keeps the single posture's Default Organization at boot + +Clause-②: no (narrowing) — it restores ADR-0131 D3's Default Organization boot invariant, which `default-organization-invariant.ts` states as "[ADR-0131 D3] The Default Organization as a BOOT INVARIANT under the single posture", in the kernel that mounts `createIdentityObjectsPlugin()` without `AuthPlugin`; no published schema or export moves. + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +A kernel that mounts `createIdentityObjectsPlugin()` without `AuthPlugin` now holds its Default Organization under the `single` tenancy posture, so its seeds and system writes on tenant-scoped objects are stamped with it instead of refused with "the install holds no organization at all". + +- **Refused now:** a composition that mounts `createIdentityObjectsPlugin()` and publishes its driver after `bootstrap()` (the identity objects' `start()` reads `sys_organization` under `single`, so the boot fails with `No driver available for object 'sys_organization'`); and a kit that inserts its own organization row after boot, which now holds two organizations under `single`, so every system write that names no organization is refused as ambiguous (ADR-0131 D9). +- **Unchanged:** the walled postures (`group`, `isolated`), where `start()` creates nothing; the refusal at `init()` of `createIdentityObjectsPlugin()` mounted beside `AuthPlugin`; every composition that publishes its driver before boot; and a declaration-only boot (`os migrate`), which suppresses `start()`. + +- **Why.** ADR-0131 D3 makes the Default Organization a boot invariant under `single`: it owns every row. Registering `sys_organization` is what turns the engine's and the seed loader's zero-organization branch from "unstamped" into "refused" (D9). `IdentityObjectsPlugin` registers that object, but only `AuthPlugin.start()` created the organization, so the reduced kernel the plugin exists for (ObjectQL, `createIdentityObjectsPlugin()`, `SecurityPlugin`) refused every seed row of an organization-owned object. +- **What changed in `@objectstack/plugin-auth`.** `IdentityObjectsPlugin` gains a `start()` that, under `single`, finds or creates the Default Organization with the same invariant `AuthPlugin.start()` runs. It is idempotent, and a failure fails the boot. The posture is the `tenancy` service's when one is registered, otherwise the requested one (`OS_TENANCY_POSTURE`). Under `group` and `isolated` it does nothing. Nothing new is exported, and the plugin is still refused beside `AuthPlugin`. +- **What changed in `@objectstack/runtime`.** `AppPlugin` orders `com.objectstack.auth.identity-objects` ahead of itself, as it already ordered `com.objectstack.auth`, so the organization exists before the inline seed loads, wherever a host lists the two plugins. +- **If your test kit mounts `createIdentityObjectsPlugin()`.** Its kernel now reads and writes `sys_organization` during `bootstrap()` under `single`. Publish the driver before the boot (a `driver.default` service, or a datasource plugin), not with `registerDriver()` after it. Otherwise the boot fails with `No driver available for object 'sys_organization'`. A kit that inserted its own organization row to stand in for the missing one now holds two, and a system write that names no organization is refused as ambiguous; drop that insert and read the Default Organization (`slug: 'default'`) the boot created. diff --git a/packages/plugins/plugin-auth/src/default-organization-invariant.ts b/packages/plugins/plugin-auth/src/default-organization-invariant.ts index 96f3f619daf..c0e8c0ee919 100644 --- a/packages/plugins/plugin-auth/src/default-organization-invariant.ts +++ b/packages/plugins/plugin-auth/src/default-organization-invariant.ts @@ -6,7 +6,8 @@ * before any application seed loads and before the server accepts a request. * * ⛔ Internal on purpose: this module is NOT re-exported from the package - * barrel. Its one caller is `AuthPlugin`; the owner bind it hands over to stays + * barrel. Its two callers are `AuthPlugin.start()` and + * `IdentityObjectsPlugin.start()`; the owner bind it hands over to stays * with `ensureDefaultOrganization` (`ensure-default-organization.ts`), which is * the published helper. */ diff --git a/packages/plugins/plugin-auth/src/identity-objects-plugin.test.ts b/packages/plugins/plugin-auth/src/identity-objects-plugin.test.ts index 053d5ca7611..5c306b27177 100644 --- a/packages/plugins/plugin-auth/src/identity-objects-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/identity-objects-plugin.test.ts @@ -16,6 +16,11 @@ * is this export). * 4. The full kernel is unchanged: `AuthPlugin` registers the same identity * manifest, from the same builder, in its one registration. + * 5. [ADR-0131 D3] Under `single`, the plugin that registers `sys_organization` + * keeps its Default Organization: a system insert on a tenant-scoped object + * is stamped with it instead of refused (D9). Idempotent; a walled posture + * is unchanged; a host's `tenancy` service decides over the requested + * posture; the composition with `AuthPlugin` is still refused. * * "An app" is a plugin that registers its manifest through the `manifest` * service in `init()`, which is what `AppPlugin.init()` does. `AppPlugin` itself @@ -23,7 +28,7 @@ * cannot import it without a workspace cycle. */ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ObjectKernel, resolvePluginOrder, @@ -158,6 +163,107 @@ describe('createIdentityObjectsPlugin() — identity objects for a kernel withou }); }); +describe('[ADR-0131 D3] 5. the Default Organization, kept by the plugin that registers sys_organization', () => { + let kernel: ObjectKernel | undefined; + const rowsOf = (r: unknown): Array> => + Array.isArray(r) ? r : ((r as { records?: Array> })?.records ?? []); + + beforeEach(() => { + // `single` is the unset default; spelled out so a stray variable in the + // runner's environment cannot decide these pins. + vi.stubEnv('OS_TENANCY_POSTURE', 'single'); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + try { + await kernel?.shutdown(); + } catch { + /* a refused boot leaves the kernel stopped */ + } + kernel = undefined; + }); + + /** A host plugin registering a `tenancy` service, as a kit's own kernel may. */ + function tenancyService(posture: string): Plugin { + return { + name: 'test.tenancy', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService('tenancy', { posture }); + }, + }; + } + + async function boot(plugins: Plugin[]): Promise { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(sqliteDriverPlugin()); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(appPlugin()); + for (const p of plugins) await kernel.use(p); + await kernel.bootstrap(); + return kernel.getService('objectql'); + } + + const organizations = async (ql: ObjectQL) => rowsOf(await ql.find('sys_organization', { context: SYS })); + + it('under single, without AuthPlugin: a system insert on a tenant-scoped object is stamped with the Default Organization', async () => { + const ql = await boot([createIdentityObjectsPlugin(), new SecurityPlugin()]); + + // The seed loader's and a hook's shape: system context, no organization named. + const row = await ql.insert('kit_account', { name: 'Seeded by the system' }, { context: SYS }); + const orgs = await organizations(ql); + expect(orgs.map((o) => o.slug)).toEqual(['default']); + const stored = await ql.findOne('kit_account', { where: { id: row.id }, context: SYS }); + expect(stored?.organization_id).toBe(orgs[0].id); + }); + + it('is idempotent: start() again over the same store finds the organization and creates none', async () => { + const ql = await boot([createIdentityObjectsPlugin()]); + const [org] = await organizations(ql); + + const ctx = { + getService: (name: string) => { + if (name === 'objectql') return ql; + throw new Error(`service not registered: ${name}`); + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + } as unknown as PluginContext; + await createIdentityObjectsPlugin().start(ctx); + + expect((await organizations(ql)).map((o) => o.id)).toEqual([org.id]); + }); + + it.each(['group', 'isolated'])('under the walled %s posture, nothing changes: no organization is created', async (posture) => { + vi.stubEnv('OS_TENANCY_POSTURE', posture); + const ql = await boot([createIdentityObjectsPlugin()]); + + expect(await organizations(ql)).toEqual([]); + }); + + it('a host-registered tenancy service decides over the requested posture, in both directions', async () => { + vi.stubEnv('OS_TENANCY_POSTURE', 'isolated'); + const single = await boot([tenancyService('single'), createIdentityObjectsPlugin()]); + expect((await organizations(single)).map((o) => o.slug)).toEqual(['default']); + await kernel?.shutdown(); + + vi.stubEnv('OS_TENANCY_POSTURE', 'single'); + const walled = await boot([tenancyService('isolated'), createIdentityObjectsPlugin()]); + expect(await organizations(walled)).toEqual([]); + }); + + it('the composition with AuthPlugin is still refused, at init, on a real kernel', async () => { + const refusal = await boot([ + new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000' }), + createIdentityObjectsPlugin(), + ]).then(() => undefined, (err: unknown) => err as Error); + + expect(refusal?.message).toContain(IDENTITY_OBJECTS_PLUGIN_NAME); + expect(refusal?.message).toContain('AuthPlugin'); + }); +}); + describe('one list: AuthPlugin and IdentityObjectsPlugin register the same identity manifest', () => { /** A context whose `manifest.register` records what was registered. */ function capturingContext(services: Record = {}) { diff --git a/packages/plugins/plugin-auth/src/identity-objects-plugin.ts b/packages/plugins/plugin-auth/src/identity-objects-plugin.ts index d12fc3fe67d..e71cadbcf51 100644 --- a/packages/plugins/plugin-auth/src/identity-objects-plugin.ts +++ b/packages/plugins/plugin-auth/src/identity-objects-plugin.ts @@ -25,6 +25,18 @@ * `AuthPlugin`, and with it these objects. An app suite that boots through it * needs nothing from this module; it is for kits that compose their own kernel. * + * ## The Default Organization (ADR-0131 D3) + * + * Registering `sys_organization` is what makes the engine's ADR-0131 D9 branch + * refuse a system insert on a tenant-scoped object when the install holds no + * organization under the `single` posture: a composition that registers no + * organization object keeps the unstamped branch, one that registers it must + * hold its Default Organization. So the plugin that registers the object keeps + * the invariant too: under `single`, `start()` runs the same + * `ensureDefaultOrganizationExists` `AuthPlugin.start()` runs, and a failure + * fails the boot. The invariant is not exported for a host to call — a step the + * host must remember is a step a composition can forget. + * * ## Why the objects keep plugin-auth's package id * * The manifest is registered under `AUTH_PLUGIN_ID`, the id `AuthPlugin` uses, @@ -45,6 +57,10 @@ */ import type { Plugin, PluginContext } from '@objectstack/core'; +import type { IDataEngine } from '@objectstack/spec/contracts'; +import type { TenancyPosture } from '@objectstack/spec/security'; +import { resolveTenancyPosture } from '@objectstack/types'; +import { ensureDefaultOrganizationExists } from './default-organization-invariant.js'; import { authIdentityManifest } from './manifest.js'; /** The kernel plugin name of {@link IdentityObjectsPlugin}. */ @@ -95,6 +111,27 @@ export class IdentityObjectsPlugin implements Plugin { authIdentityManifest({ datasource: this.options.manifestDatasource }), ); } + + /** + * [ADR-0131 D3] Under the `single` posture, the Default Organization exists + * before this `start()` returns: found, or created. The same invariant, and + * the same call, as `AuthPlugin.start()`; see the module header for why this + * plugin keeps it. A failure throws and fails the boot, never warn-only. + */ + async start(ctx: PluginContext): Promise { + if (postureInForce(ctx) !== 'single') return; + const invariant = await ensureDefaultOrganizationExists(ctx.getService('objectql'), { + logger: ctx.logger, + }); + if (invariant.ambiguous) { + ctx.logger.warn( + `${IDENTITY_OBJECTS_PLUGIN_NAME}: the 'single' tenancy posture holds ` + + `${invariant.ambiguous.organizationCount} organizations and none is the Default Organization ` + + "(slug 'default'), so no owner is derivable: system writes and seed rows that need one are " + + 'refused (ADR-0131 D9), and each refusal names how to carry the organization instead.', + ); + } + } } /** A new {@link IdentityObjectsPlugin}. */ @@ -116,3 +153,21 @@ function authPluginComposed(ctx: PluginContext): boolean { return false; } } + +/** + * The tenancy posture in force, judged the way `AppPlugin` judges whether it + * loads its seed inline: the `tenancy` service's posture when one is + * registered, otherwise the posture the deployment requested + * (`resolveTenancyPosture()`, which throws on an unrecognized value). Without + * `AuthPlugin` nothing in the platform registers that service, so the request + * usually decides; a host that registers one decides for both. + */ +function postureInForce(ctx: PluginContext): TenancyPosture { + try { + const posture = ctx.getService<{ posture?: TenancyPosture } | undefined>('tenancy')?.posture; + if (posture) return posture; + } catch { + /* no `tenancy` service in this composition */ + } + return resolveTenancyPosture(); +} diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index b5e80d8820e..37b6656013d 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -126,11 +126,19 @@ export class AppPlugin implements Plugin { * `com.objectstack.auth` (ADR-0131 D3): under the `single` posture the * auth plugin creates the Default Organization in its `start()`, and that * organization must exist before this plugin's `start()` loads the inline - * seed — every seed row is stamped with it or refused. Order-if-present: - * a composition without the auth plugin registers no organization object, - * and its seeds keep today's unstamped branch. + * seed — every seed row is stamped with it or refused. A kernel without + * the auth plugin that registers plugin-auth's identity objects + * (`com.objectstack.auth.identity-objects`, `createIdentityObjectsPlugin()`) + * registers the organization object too, and that plugin's `start()` keeps + * the same invariant, so it is ordered ahead for the same reason. + * Order-if-present: a composition with neither registers no organization + * object, and its seeds keep today's unstamped branch. */ - optionalDependencies = ['com.objectstack.engine.objectql', 'com.objectstack.auth']; + optionalDependencies = [ + 'com.objectstack.engine.objectql', + 'com.objectstack.auth', + 'com.objectstack.auth.identity-objects', + ]; /** * The one init-time service this plugin CANNOT degrade without: a * non-empty bundle is registered via `getService('manifest').register()` diff --git a/packages/runtime/src/identity-objects-default-organization.pin.test.ts b/packages/runtime/src/identity-objects-default-organization.pin.test.ts new file mode 100644 index 00000000000..c921f402c0b --- /dev/null +++ b/packages/runtime/src/identity-objects-default-organization.pin.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3 / D9] The reduced kernel — ObjectQL, an app, plugin-auth's + * identity objects (`createIdentityObjectsPlugin()`) and `SecurityPlugin`, no + * `AuthPlugin` — holds its Default Organization before the app's inline seed + * loads, so every seed row of a tenant-scoped object is stamped with it. + * + * Registering `sys_organization` is what moves the engine's and the seed + * loader's zero-organization branch from "no organization object, unstamped" + * to "refused" (D9). `IdentityObjectsPlugin` registers it, so its `start()` + * keeps the invariant, as `AuthPlugin.start()` does in the full kernel + * (`default-organization-boot-invariant.pin.test.ts` is that kernel's pin). + * + * The seed is `AppPlugin`'s own, the producer of the refusal this pin guards + * against: "the install holds no organization at all". `AppPlugin` is + * registered BEFORE the identity objects on purpose, so the kernel has to start + * them first from `AppPlugin`'s declared order-if-present dependency, not from + * where a host happened to list it. + * + * Controls: an object declaring `tenancy: { enabled: false }` takes no + * organization, seeded or inserted; under a walled posture this plugin creates + * no organization and `AppPlugin` replays its seed per organization instead. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { AppPlugin } from './app-plugin.js'; +import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; + +const BOOT_TIMEOUT = 120_000; +const SYS = { isSystem: true }; + +const PIN_APP: any = { + manifest: { id: 'com.pin.identityobjectsdefaultorg', name: 'Identity objects default organization pins', version: '1.0.0' }, + objects: [ + { name: 'pin_kit_ledger', label: 'Pin kit ledger', fields: { title: { type: 'text', label: 'Title' } } }, + { + // ADR-0066: the declared way to hold rows that belong to no organization. + name: 'pin_kit_licence', + label: 'Pin kit licence', + tenancy: { enabled: false }, + fields: { title: { type: 'text', label: 'Title' } }, + }, + ], + data: [ + { object: 'pin_kit_ledger', mode: 'upsert', externalId: 'title', records: [{ title: 'seeded one' }, { title: 'seeded two' }] }, + { object: 'pin_kit_licence', mode: 'upsert', externalId: 'title', records: [{ title: 'seeded licence' }] }, + ], +}; + +const rowsOf = (r: unknown): Array> => + Array.isArray(r) ? r : ((r as { records?: Array> })?.records ?? []); + +interface Booted { + kernel: ObjectKernel; + engine: ObjectQL; + find: (object: string, where?: Record) => Promise>>; + /** Every insert's object, in order, as the engine saw it. */ + inserts: string[]; +} + +async function boot(): Promise { + const inserts: string[] = []; + const probe: Plugin = { + name: 'pin.identity-objects-default-org.probe', + version: '0.0.0', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + ctx.getService('objectql').registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.operation === 'insert') inserts.push(String(opCtx.object)); + await next(); + }); + }, + }; + const kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } })); + await kernel.use(probe); + // Before the identity objects, on purpose: see the header. + await kernel.use(new AppPlugin(PIN_APP)); + await kernel.use(createIdentityObjectsPlugin()); + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(PIN_APP))); + await kernel.bootstrap(); + const engine = kernel.getService('objectql'); + return { + kernel, + engine, + inserts, + find: async (object, where = {}) => rowsOf(await engine.find(object, { where, limit: 50, context: SYS })), + }; +} + +describe('[ADR-0131 D3] the reduced kernel under `single` (no AuthPlugin)', () => { + let K: Booted; + beforeAll(async () => { K = await boot(); }, BOOT_TIMEOUT); + afterAll(async () => { try { await K?.kernel.shutdown(); } catch { /* best-effort */ } }, 60_000); + + it('holds exactly the Default Organization, written before the first seed row', async () => { + expect((await K.find('sys_organization')).map((o) => o.slug)).toEqual(['default']); + const organization = K.inserts.indexOf('sys_organization'); + const firstSeed = K.inserts.indexOf('pin_kit_ledger'); + // Both were observed — the order below compares two real writes. + expect(organization).toBeGreaterThanOrEqual(0); + expect(firstSeed).toBeGreaterThanOrEqual(0); + expect(organization).toBeLessThan(firstSeed); + }); + + it('every seed row of the tenant-scoped object carries it; the tenancy-off control carries none', async () => { + const [org] = await K.find('sys_organization'); + const ledger = await K.find('pin_kit_ledger', { title: { $in: ['seeded one', 'seeded two'] } }); + expect(ledger.map((r) => r.organization_id)).toEqual([org.id, org.id]); + // CONTROL — the seed WAS written, and with no organization, where the + // object declares none: the stamp is a per-object derivation. + const licences = await K.find('pin_kit_licence', { title: 'seeded licence' }); + expect(licences.map((r) => r.organization_id ?? null)).toEqual([null]); + }); + + it('an isSystem insert on the tenant-scoped object, naming no organization, lands stamped with it', async () => { + const [org] = await K.find('sys_organization'); + const row = await K.engine.insert('pin_kit_ledger', { title: 'system write, no organization' }, { context: SYS }); + expect((await K.find('pin_kit_ledger', { id: row.id }))[0]?.organization_id).toBe(org.id); + }); +}); + +describe('the reduced kernel under a walled posture: unchanged', () => { + let K: Booted; + let prev: string | undefined; + beforeAll(async () => { + prev = process.env.OS_TENANCY_POSTURE; + process.env.OS_TENANCY_POSTURE = 'isolated'; + K = await boot(); + }, BOOT_TIMEOUT); + afterAll(async () => { + try { await K?.kernel.shutdown(); } catch { /* best-effort */ } + if (prev === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = prev; + }, 60_000); + + it('creates no organization, and AppPlugin leaves its seed to the per-organization replay', async () => { + expect(await K.find('sys_organization')).toEqual([]); + expect(K.inserts).not.toContain('sys_organization'); + expect(await K.find('pin_kit_ledger')).toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts b/packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts index cb6ea5422e5..feb46dcaee1 100644 --- a/packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts +++ b/packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts @@ -49,7 +49,7 @@ */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import { ObjectKernel } from '@objectstack/core'; +import { ObjectKernel, type PluginContext } from '@objectstack/core'; import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; @@ -161,6 +161,17 @@ function writeFlow(name: string, runAs: RunAs, node: Record) { async function boot(secured: boolean) { const kernel = new ObjectKernel({ logger: { level: 'fatal' } }); + // The driver is published before the boot, as a datasource plugin does: under + // `single` the identity objects' `start()` reads and writes `sys_organization`. + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await kernel.use({ + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService('driver.default', driver); + }, + }); await kernel.use(new ObjectQLPlugin()); await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); if (secured) { @@ -173,9 +184,6 @@ async function boot(secured: boolean) { await kernel.bootstrap(); const ql = kernel.getService('objectql'); const automation = kernel.getService('automation'); - const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); - await driver.connect(); - ql.registerDriver(driver, true); ql.registry.registerObject(PLAIN_OBJECT as any, 'pin-21624', 'pin-21624'); await ql.syncSchemas(); return { kernel, ql, automation }; diff --git a/packages/services/service-automation/src/runas-system-stamping.integration.test.ts b/packages/services/service-automation/src/runas-system-stamping.integration.test.ts index a58e9560709..2cd4c60b178 100644 --- a/packages/services/service-automation/src/runas-system-stamping.integration.test.ts +++ b/packages/services/service-automation/src/runas-system-stamping.integration.test.ts @@ -35,7 +35,7 @@ */ import { describe, it, expect, afterEach } from 'vitest'; -import { ObjectKernel } from '@objectstack/core'; +import { ObjectKernel, type PluginContext } from '@objectstack/core'; import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; @@ -232,16 +232,31 @@ describe('the #5494 admission flip: row content, not caller, decides (real Secur objects: { crm_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, }); - /** The rank-and-file member who triggered the sweep. */ + /** + * The rank-and-file member who triggered the sweep. `tenantId` is the + * deployment's one organization, the Default Organization the boot creates; + * `bootWithSecurity` fills it in. + */ const MEMBER_CTX = { userId: 'usr_member', - tenantId: 'org_1', + tenantId: '', positions: ['org_member'], permissions: ['member_default', 'task_delete'], }; async function bootWithSecurity() { kernel = new ObjectKernel({ logger: { level: 'fatal' } }); + // The driver is published before the boot, as a datasource plugin does: + // the identity objects' `start()` reads and writes `sys_organization`. + const driver = makeSqliteDriver(); + await kernel.use({ + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService('driver.default', driver); + }, + }); await kernel.use(new ObjectQLPlugin()); await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); // SecurityPlugin refuses a kernel without the identity objects its @@ -258,22 +273,15 @@ describe('the #5494 admission flip: row content, not caller, decides (real Secur ql = kernel.getService('objectql'); automation = kernel.getService('automation'); - const driver = makeSqliteDriver(); - await driver.connect(); - ql.registerDriver(driver, true); ql.registry.registerObject(crmTask as any, 'stamping-test', 'stamping-test'); await ql.syncSchemas(); - // [ADR-0131 D3] A production `single` boot holds the Default Organization - // before any flow runs: the auth plugin's boot invariant, which this rig - // (identity objects, no auth plugin) does not compose. It is the member's - // organization, `org_1`, the deployment's one organization. Without it the - // user-less run's system insert is refused (D9): this composition - // registers `sys_organization`, and the install would hold none. - await ql.insert( - 'sys_organization', - { id: 'org_1', name: 'Default Organization', slug: 'default' }, - { context: { isSystem: true } }, - ); + // [ADR-0131 D3] A `single` boot holds the Default Organization before any + // flow runs: the identity objects keep that invariant without the auth + // plugin. It is the member's organization, the deployment's one + // organization, which the user-less run's system insert derives (D9). + const orgs = await ql.find('sys_organization', { context: { isSystem: true } }); + expect(orgs.map((o: any) => o.slug)).toEqual(['default']); + MEMBER_CTX.tenantId = String(orgs[0].id); } const SYS = { isSystem: true } as const; @@ -291,7 +299,7 @@ describe('the #5494 admission flip: row content, not caller, decides (real Secur const row = await rowByTitle('flip A'); expect(row!.created_by).toBe('usr_member'); expect(row!.owner_id).toBe('usr_member'); - expect(row!.organization_id).toBe('org_1'); + expect(row!.organization_id).toBe(MEMBER_CTX.tenantId); // Step 3, inverted: the same member — no elevation, no transfer grant — // repairs and completes the record the sweep made for them. @@ -318,7 +326,7 @@ describe('the #5494 admission flip: row content, not caller, decides (real Secur // [ADR-0131 D9] The user-less system insert derives the install's one // organization, the member's own, so the denial below is decided by the // stamp columns alone and not by an organization boundary. - expect(row!.organization_id).toBe('org_1'); + expect(row!.organization_id).toBe(MEMBER_CTX.tenantId); // The SAME member context that succeeded above is denied here: the only // difference between the two attempts is the row's stamp columns — the