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
15 changes: 15 additions & 0 deletions .changeset/22074-auth-identity-objects-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/plugin-auth': minor
---

`createIdentityObjectsPlugin()` registers plugin-auth's identity objects (`sys_user`, `sys_member`, `sys_organization` and the rest of the list `AuthPlugin` registers) on a kernel that does not mount `AuthPlugin`, such as an app's or a plugin's own test kit.

Clause-②: yes (widening)

- **What is new.** `createIdentityObjectsPlugin(options?)`, the `IdentityObjectsPlugin` class it returns, `IdentityObjectsPluginOptions` (`manifestDatasource`, with the meaning `AuthPluginOptions.manifestDatasource` has) and `IDENTITY_OBJECTS_PLUGIN_NAME` (`com.objectstack.auth.identity-objects`, its kernel plugin name).
- **One list.** It registers the identity half of plugin-auth's manifest: the header, the objects and the field plugin-auth adds to `sys_sso_provider`. `AuthPlugin` spreads the same builder into the one manifest it registers, so a reduced kernel and a full one register the same objects, and a kit no longer copies plugin-auth's object list or manifest id.
- **Same owner.** The objects register under plugin-auth's package id, `com.objectstack.plugin-auth`, as `AuthPlugin` registers them. The registry records one owning package per object, so a different id would make the owner of `sys_user` depend on which plugin a kernel mounts.
- **No authentication.** It registers objects only: no sessions, no routes, no `auth` service.
- **Not beside `AuthPlugin`.** `AuthPlugin` registers the same objects itself. A kernel that mounts both is refused at boot, by the identity plugin's `init()`, with an error naming both.
- **Usage.** `await kernel.use(createIdentityObjectsPlugin())` after `ObjectQLPlugin`. A suite that boots through `@objectstack/verify` already gets these objects: its `bootStack` mounts `AuthPlugin`.
- **Unchanged.** `AuthPlugin` registers the same manifest it registered before, under the same id.
19 changes: 19 additions & 0 deletions .changeset/22074-security-identity-objects-boot-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/plugin-security': minor
---

`SecurityPlugin` declares the identity objects its authorization store reads, `sys_user` and `sys_member`, and refuses to boot a kernel that does not register them. The refusal names the missing objects and the plugin that registers them, where the kernel used to boot and then fail every authenticated request's permission read as an outage.

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) A boot refusal of a plugin composition, not a metadata change: no spec key, export, option or stored shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. The remedy is a kernel composition edit (mount plugin-auth's identity objects), 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 already-registered); and what narrows is which kernels boot, not a runtime interface or a type surface (not runtime-interface-only / type-surface-only). -->

**BREAKING**: a kernel that booted before is refused at boot, shipped as `minor` under the launch-window convention.

**Why.** Permission resolution, `resolveUserAuthzGrants` in `@objectstack/core`, reads `sys_user` and `sys_member`, which `@objectstack/plugin-auth` registers. The engine refuses a read of an object its registry does not hold, so a kernel with `SecurityPlugin` and without those objects booted cleanly, and then every authenticated request's permission read failed with `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503), which reads as an outage and names no missing dependency.

**What is refused.** A kernel where `SecurityPlugin.start()` ran and the engine registry does not hold `sys_user` or `sys_member` at `kernel:ready`: `bootstrap()` rejects with an error named `AuthzIdentityObjectsMissingError`, whose `missingObjects` lists the absent names and whose message names `@objectstack/plugin-auth`, `AuthPlugin` and `createIdentityObjectsPlugin()`. Measured in this repository: three test harnesses that boot `SecurityPlugin` without `AuthPlugin` (one in `@objectstack/runtime`, two in `@objectstack/service-automation`), each now mounting `createIdentityObjectsPlugin()`. By reading the code, not by a run: `objectstack dev` through `DevPlugin` with `services: { auth: false }` and security left on is refused too.

**What still boots, unchanged.** A kernel that mounts `AuthPlugin` (`os serve` pairs the two; `@objectstack/verify`'s `bootStack` mounts both); a kernel that registers the two objects any other way; and a boot that composes `SecurityPlugin` for its declarations only (`os migrate`, which suppresses `start()`).

**The remedy.** on a kernel that mounts `SecurityPlugin` without `AuthPlugin`, such as a test kit, add `await kernel.use(createIdentityObjectsPlugin())` from `@objectstack/plugin-auth`. It registers plugin-auth's own identity list, so a hand-written plugin that registers `SysUser` and `SysMember` is no longer needed, though it still satisfies the check. A `DevPlugin` stack with `services: { auth: false }` either turns security off as well or passes `createIdentityObjectsPlugin()` in `extraPlugins`. Nothing in an app's metadata changes.
37 changes: 16 additions & 21 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,7 @@ import { runSetInitialPassword } from './set-initial-password.js';
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js';
import { runResendVerificationEmail } from './send-verification-email.js';
import type { CounterStore } from './rate-limit-storage.js';
import {
authIdentityObjects,
authObjectExtensions,
authPluginManifestHeader,
} from './manifest.js';
import { authIdentityManifest } from './manifest.js';
import { scheduleLegacySsoSecretMigration } from './sso-client-secret.js';
import {
devSeedAdminEmail,
Expand Down Expand Up @@ -643,22 +639,21 @@ export class AuthPlugin implements Plugin {
this.tenancy = tenancy;

ctx.getService<{ register(m: any): void }>('manifest').register({
...authPluginManifestHeader,
...(this.options.manifestDatasource
? { defaultDatasource: this.options.manifestDatasource }
: {}),
// [ADR-0108 / #3723] Registered as authored: nothing widens the
// `sys_invitation.role` / `sys_member.role` selects at boot. The closed
// four-name vocabulary those objects declare statically
// (`BUILTIN_MEMBERSHIP_ROLE_OPTIONS`) is the whole list, and it is the
// write-side guardrail that keeps an ungoverned capability grant
// unrepresentable.
objects: authIdentityObjects,
// [#8009] `sys_sso_provider.oidc_client_secret` — the encrypted home of the
// OIDC client secret that used to sit in cleartext inside `oidc_config`.
// See `manifest.ts` for why the field is declared here and not on the
// object file.
objectExtensions: authObjectExtensions,
// The header, `objects` and `objectExtensions`, from the ONE builder
// `IdentityObjectsPlugin` registers on its own in a reduced kernel, so the
// two paths cannot carry different lists. In it:
// - [ADR-0108 / #3723] `objects` registered as authored: nothing widens
// the `sys_invitation.role` / `sys_member.role` selects at boot. The
// closed four-name vocabulary those objects declare statically
// (`BUILTIN_MEMBERSHIP_ROLE_OPTIONS`) is the whole list, and it is the
// write-side guardrail that keeps an ungoverned capability grant
// unrepresentable.
// - [#8009] `objectExtensions` carries
// `sys_sso_provider.oidc_client_secret`, the encrypted home of the OIDC
// client secret that used to sit in cleartext inside `oidc_config`. See
// `manifest.ts` for why the field is declared there and not on the
// object file.
...authIdentityManifest({ datasource: this.options.manifestDatasource }),
// ADR-0048 — Setup/Studio/Account apps (and the Setup nav contributions)
// moved to their own one-app packages (@objectstack/{setup,studio,account}),
// each registering under its own package id so /apps/<packageId> resolves
Expand Down
243 changes: 243 additions & 0 deletions packages/plugins/plugin-auth/src/identity-objects-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `createIdentityObjectsPlugin()`: plugin-auth's identity objects for a kernel
* that mounts ObjectQL without `AuthPlugin`, the reduced kernel an app's or a
* plugin's own test suite boots.
*
* Pins:
* 1. ObjectQL + an app + this plugin: a `sys_user` fixture inserts, and the
* registered set is plugin-auth's list under plugin-auth's package id.
* Without the plugin the same insert is refused `OBJECT_NOT_FOUND` (control).
* 2. Adding `SecurityPlugin`: the kernel boots and permissions resolve from
* `sys_member` through `@objectstack/core`'s `resolveUserAuthzGrants`.
* 3. `SecurityPlugin` without the identity objects is refused at boot by name
* (`plugin-security`'s own suite pins the refusal; here, the remedy it names
* is this export).
* 4. The full kernel is unchanged: `AuthPlugin` registers the same identity
* manifest, from the same builder, in its one registration.
*
* "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 {
ObjectKernel,
resolvePluginOrder,
resolveUserAuthzGrants,
type OrderablePlugin,
type Plugin,
type PluginContext,
} from '@objectstack/core';
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { SecurityPlugin } from '@objectstack/plugin-security';
import { Field } from '@objectstack/spec/data';
import {
IDENTITY_OBJECTS_PLUGIN_NAME,
IdentityObjectsPlugin,
createIdentityObjectsPlugin,
} from './identity-objects-plugin.js';
import { AUTH_PLUGIN_ID, authIdentityManifest, authIdentityObjects, authObjectExtensions } from './manifest.js';
import { AuthPlugin } from './auth-plugin.js';

const SYS = { isSystem: true } as const;

/** Publishes an in-memory SQLite driver the way a datasource plugin does. */
function sqliteDriverPlugin(): Plugin {
return {
name: 'test.driver.sqlite',
type: 'standard',
version: '1.0.0',
async init(ctx: PluginContext) {
ctx.registerService(
'driver.default',
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
);
},
};
}

/** An app registering its manifest through the `manifest` service, as `AppPlugin.init()` does. */
function appPlugin(): Plugin {
return {
name: 'com.example.kit-app',
type: 'app',
version: '1.0.0',
dependencies: ['com.objectstack.engine.objectql'],
async init(ctx: PluginContext) {
ctx.getService<{ register(m: unknown): unknown }>('manifest').register({
id: 'com.example.kit-app',
version: '1.0.0',
type: 'app',
name: 'Kit App',
objects: [{ name: 'kit_account', label: 'Account', fields: { name: Field.text({ label: 'Name' }) } }],
});
},
};
}

describe('createIdentityObjectsPlugin() — identity objects for a kernel without AuthPlugin', () => {
let kernel: ObjectKernel | undefined;

afterEach(async () => {
try {
await kernel?.shutdown();
} catch {
/* a refused boot leaves the kernel stopped */
}
kernel = undefined;
});

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');
}

it('1. registers plugin-auth\'s list under plugin-auth\'s package id, and a sys_user fixture inserts', async () => {
const ql = await boot([createIdentityObjectsPlugin()]);

const registered = ql.registry.getAllObjects(AUTH_PLUGIN_ID).map((o) => o.name).sort();
expect(registered).toEqual(authIdentityObjects.map((o) => o.name).sort());

await ql.insert('sys_user', { id: 'usr_kit', name: 'Kit User', email: 'kit@example.com' }, { context: SYS });
const row = await ql.findOne('sys_user', { where: { id: 'usr_kit' }, context: SYS });
expect(row?.email).toBe('kit@example.com');
});

it('1 (control). without it, the same sys_user insert is refused OBJECT_NOT_FOUND', async () => {
const ql = await boot([]);

const refusal = await ql
.insert('sys_user', { id: 'usr_kit', name: 'Kit User', email: 'kit@example.com' }, { context: SYS })
.then(() => undefined, (err: unknown) => err as { code?: string; status?: number });
expect(refusal).toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 });
});

it('2. with SecurityPlugin added, the kernel boots and permissions resolve from sys_member', async () => {
const ql = await boot([createIdentityObjectsPlugin(), new SecurityPlugin()]);

await ql.insert('sys_user', { id: 'usr_kit', name: 'Kit User', email: 'kit@example.com' }, { context: SYS });
await ql.insert('sys_organization', { id: 'org_kit', name: 'Kit Org' }, { context: SYS });
await ql.insert(
'sys_member',
{ id: 'mem_kit', user_id: 'usr_kit', organization_id: 'org_kit', role: 'admin' },
{ context: SYS },
);

const grants = await resolveUserAuthzGrants(ql, 'usr_kit', { tenantId: 'org_kit' });
expect(grants.positions).toContain('org_admin');
expect(grants.accessible_org_ids).toEqual(['org_kit']);
expect(grants.email).toBe('kit@example.com');
});

it('3. SecurityPlugin without them is refused at boot, and the refusal names this export', async () => {
const refusal = await boot([new SecurityPlugin()]).then(() => undefined, (err: unknown) => err as Error);

expect(refusal?.name).toBe('AuthzIdentityObjectsMissingError');
expect(refusal?.message).toContain('createIdentityObjectsPlugin()');
expect(refusal?.message).toContain('@objectstack/plugin-auth');
});

it('4. the full kernel (AuthPlugin, no preset) boots SecurityPlugin unchanged', async () => {
const ql = await boot([
new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000' }),
new SecurityPlugin(),
]);

const registered = ql.registry.getAllObjects(AUTH_PLUGIN_ID).map((o) => o.name).sort();
expect(registered).toEqual(authIdentityObjects.map((o) => o.name).sort());
});
});

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> = {}) {
const registered: Array<Record<string, unknown>> = [];
const ctx = {
registerService: vi.fn(),
getService: vi.fn((name: string) => {
if (name === 'manifest') return { register: (m: Record<string, unknown>) => registered.push(m) };
if (name in services) return services[name];
throw new Error(`service not registered: ${name}`);
}),
getServices: vi.fn(() => new Map()),
hook: vi.fn(),
trigger: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
getKernel: vi.fn(),
} as unknown as PluginContext;
return { ctx, registered };
}

it('4. AuthPlugin\'s one registration carries the identity manifest unchanged (the full kernel)', async () => {
const { ctx, registered } = capturingContext({ data: undefined });
await new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000' }).init(ctx);

expect(registered).toHaveLength(1);
const [manifest] = registered;
expect(manifest).toMatchObject(authIdentityManifest());
expect(manifest.objects).toBe(authIdentityObjects);
expect(manifest.objectExtensions).toBe(authObjectExtensions);
expect(manifest.id).toBe(AUTH_PLUGIN_ID);
// The rest of AuthPlugin's manifest is still there.
expect(Array.isArray(manifest.pages) && manifest.pages.length).toBeGreaterThan(0);
expect(Array.isArray(manifest.dashboards) && manifest.dashboards.length).toBeGreaterThan(0);
});

it('4. IdentityObjectsPlugin registers exactly that manifest, and honours the datasource override as AuthPlugin does', async () => {
const plain = capturingContext();
await createIdentityObjectsPlugin().init(plain.ctx);
expect(plain.registered).toEqual([authIdentityManifest()]);
expect(plain.registered[0].objects).toBe(authIdentityObjects);

const kit = capturingContext();
await createIdentityObjectsPlugin({ manifestDatasource: 'default' }).init(kit.ctx);
const full = capturingContext({ data: undefined });
await new AuthPlugin({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
manifestDatasource: 'default',
}).init(full.ctx);
expect(kit.registered[0].defaultDatasource).toBe('default');
expect(full.registered[0].defaultDatasource).toBe('default');
});
});

describe('not beside AuthPlugin', () => {
it('orders AuthPlugin ahead when both are composed, whatever the insertion order', () => {
const engine: OrderablePlugin = { name: 'com.objectstack.engine.objectql' };
const identity = createIdentityObjectsPlugin() as unknown as OrderablePlugin;
const auth = new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long' }) as unknown as OrderablePlugin;
const order = resolvePluginOrder(
new Map([engine, identity, auth].map((p) => [p.name, p])),
).map((p) => p.name);

expect(order.indexOf('com.objectstack.auth')).toBeLessThan(order.indexOf(IDENTITY_OBJECTS_PLUGIN_NAME));
});

it('refuses to register when AuthPlugin has registered the auth service', async () => {
const register = vi.fn();
const ctx = {
getService: vi.fn((name: string) => {
if (name === 'auth') return {};
if (name === 'manifest') return { register };
throw new Error(`service not registered: ${name}`);
}),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
} as unknown as PluginContext;

const refusal = await new IdentityObjectsPlugin().init(ctx).then(() => undefined, (err: unknown) => err as Error);
expect(refusal?.message).toContain(IDENTITY_OBJECTS_PLUGIN_NAME);
expect(refusal?.message).toContain('AuthPlugin');
expect(register).not.toHaveBeenCalled();
});
});
Loading
Loading