Skip to content
Merged
22 changes: 22 additions & 0 deletions .changeset/23189-identity-objects-default-organization.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) A boot-time invariant of a plugin composition, not a metadata change: no spec key, export, option, config field or stored shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which compositions boot and which hand-made organization topologies a system write accepts: a kernel that mounts `createIdentityObjectsPlugin()` and publishes its driver only after `bootstrap()` is refused at boot, and a kit that inserts its own organization row next to the Default Organization has its system writes that name no organization refused as ambiguous. The remedy is a kernel composition edit (publish the driver before the boot, read the Default Organization instead of inserting one), which no ledger entry can derive from metadata. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this boot path and this diff adds none (not registered / already-registered); and the one type change is the optional `Plugin.start` lifecycle member made concrete on `IdentityObjectsPlugin`, which narrows nothing a caller types against (not runtime-interface-only / type-surface-only). -->

**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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
108 changes: 107 additions & 1 deletion packages/plugins/plugin-auth/src/identity-objects-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,19 @@
* 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
* lives in `@objectstack/runtime`, which depends on this package, so this suite
* 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,
Expand Down Expand Up @@ -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<Record<string, any>> =>
Array.isArray(r) ? r : ((r as { records?: Array<Record<string, any>> })?.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<ObjectQL> {
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>('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<string, unknown> = {}) {
Expand Down
55 changes: 55 additions & 0 deletions packages/plugins/plugin-auth/src/identity-objects-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}. */
Expand Down Expand Up @@ -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<void> {
if (postureInForce(ctx) !== 'single') return;
const invariant = await ensureDefaultOrganizationExists(ctx.getService<IDataEngine>('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}. */
Expand All @@ -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();
}
16 changes: 12 additions & 4 deletions packages/runtime/src/app-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
Expand Down
Loading
Loading