From 880cb8009cc0f72704469840fa92e2f37c4ff7ed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:41:08 +0000 Subject: [PATCH 1/8] feat(plugin-auth, plugin-security): identity-objects plugin; SecurityPlugin refuses a kernel without the identity objects its authz store reads plugin-auth exports createIdentityObjectsPlugin(), which registers the identity half of its manifest (authIdentityManifest, also spread by AuthPlugin's own registration) for a kernel without AuthPlugin. SecurityPlugin declares sys_user and sys_member and refuses at kernel:ready when the engine registry holds either one not. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-plugin.ts | 37 +++--- .../src/identity-objects-plugin.ts | 117 ++++++++++++++++++ packages/plugins/plugin-auth/src/index.ts | 4 + packages/plugins/plugin-auth/src/manifest.ts | 19 +++ .../plugin-security/src/security-plugin.ts | 62 ++++++++++ 5 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/identity-objects-plugin.ts diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 14ed794d45e..db358f89f31 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -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, @@ -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/ resolves diff --git a/packages/plugins/plugin-auth/src/identity-objects-plugin.ts b/packages/plugins/plugin-auth/src/identity-objects-plugin.ts new file mode 100644 index 00000000000..13bf4149abb --- /dev/null +++ b/packages/plugins/plugin-auth/src/identity-objects-plugin.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * plugin-auth's identity objects, registered without the rest of plugin-auth. + * + * A kernel that mounts ObjectQL but not `AuthPlugin` (the reduced kernel an + * app's or a plugin's own test suite boots) has no `sys_user`, `sys_member` or + * `sys_organization`. The engine refuses every read and write of a name its + * registry does not hold, so a `sys_user` fixture insert fails with + * `OBJECT_NOT_FOUND`, and `SecurityPlugin` refuses to boot because its + * authorization store reads `sys_user` and `sys_member`. Mount this plugin in + * place of `AuthPlugin` there: + * + * ```ts + * await kernel.use(new ObjectQLPlugin()); + * await kernel.use(createIdentityObjectsPlugin()); + * await kernel.use(new SecurityPlugin()); + * ``` + * + * It registers {@link authIdentityManifest}, the same list `AuthPlugin` + * registers, so a kit never copies plugin-auth's object list or manifest id. + * It mounts no authentication: no sessions, no routes, no `auth` service. + * + * `@objectstack/verify`'s in-process handle (`bootStack`) already mounts + * `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. + * + * ## Why the objects keep plugin-auth's package id + * + * The manifest is registered under `AUTH_PLUGIN_ID`, the id `AuthPlugin` uses, + * not under an id of this plugin's own. The registry records one owning package + * per object and refuses a second package that claims it (ADR-0029 D3), so the + * id decides the objects' provenance. Under a different id the same `sys_user` + * would belong to one package in a reduced kernel and to another in a full one. + * The kernel plugin name below is distinct only because the kernel needs one + * name per plugin. + * + * ## Not beside `AuthPlugin` + * + * `AuthPlugin` registers the same manifest itself. Mounting both would register + * the objects twice under one package id, and the second registration replaces + * the first package record. `init()` refuses that composition instead. + * `optionalDependencies` orders `AuthPlugin` ahead when it is composed, so its + * `auth` service is registered by the time this plugin's `init()` asks. + */ + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { authIdentityManifest } from './manifest.js'; + +/** The kernel plugin name of {@link IdentityObjectsPlugin}. */ +export const IDENTITY_OBJECTS_PLUGIN_NAME = 'com.objectstack.auth.identity-objects'; + +/** `AuthPlugin`'s kernel plugin name; its `init()` registers the `auth` service. */ +const AUTH_KERNEL_PLUGIN_NAME = 'com.objectstack.auth'; + +export interface IdentityObjectsPluginOptions { + /** + * The datasource that owns the identity tables, with the same meaning as + * `AuthPluginOptions.manifestDatasource`. Defaults to the manifest header's + * `defaultDatasource`. + */ + manifestDatasource?: string; +} + +/** + * Registers plugin-auth's identity objects through the `manifest` service, for + * a kernel that does not mount `AuthPlugin`. See the module header. + */ +export class IdentityObjectsPlugin implements Plugin { + name = IDENTITY_OBJECTS_PLUGIN_NAME; + type = 'standard' as const; + version = '1.0.0'; + /** ObjectQL registers the `manifest` service this plugin registers through. */ + dependencies: string[] = ['com.objectstack.engine.objectql']; + /** Ordered ahead when composed, so `init()` can refuse the pair. */ + optionalDependencies: string[] = [AUTH_KERNEL_PLUGIN_NAME]; + requiresServices: string[] = ['manifest']; + + private readonly options: IdentityObjectsPluginOptions; + + constructor(options: IdentityObjectsPluginOptions = {}) { + this.options = options; + } + + async init(ctx: PluginContext): Promise { + if (authPluginComposed(ctx)) { + throw new Error( + `${IDENTITY_OBJECTS_PLUGIN_NAME}: AuthPlugin is also mounted on this kernel, and it registers ` + + 'the same identity objects under the same package id. Mount createIdentityObjectsPlugin() ' + + 'only on a kernel without AuthPlugin; remove it from this one.', + ); + } + ctx.getService<{ register(manifest: unknown): unknown }>('manifest').register( + authIdentityManifest({ datasource: this.options.manifestDatasource }), + ); + } +} + +/** A new {@link IdentityObjectsPlugin}. */ +export function createIdentityObjectsPlugin( + options: IdentityObjectsPluginOptions = {}, +): IdentityObjectsPlugin { + return new IdentityObjectsPlugin(options); +} + +/** + * Whether `AuthPlugin` is composed. Only its `init()` registers the `auth` + * service, and `optionalDependencies` puts that `init()` first, so absence + * here means it is not mounted. + */ +function authPluginComposed(ctx: PluginContext): boolean { + try { + return ctx.getService('auth') != null; + } catch { + return false; + } +} diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index 21cbc0f50e6..3b4e6873222 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -9,6 +9,10 @@ */ export * from './auth-plugin.js'; +// plugin-auth's identity objects as a plugin of their own, for a kernel that +// mounts ObjectQL without AuthPlugin (an app's or a plugin's test kit). +// AuthPlugin registers the same manifest from the same builder. +export * from './identity-objects-plugin.js'; export * from './auth-manager.js'; export * from './ensure-default-organization.js'; // ADR-0093 D7 — the default-org bootstrap with its owner bind decided once; diff --git a/packages/plugins/plugin-auth/src/manifest.ts b/packages/plugins/plugin-auth/src/manifest.ts index dcb1408eec1..850a8457b35 100644 --- a/packages/plugins/plugin-auth/src/manifest.ts +++ b/packages/plugins/plugin-auth/src/manifest.ts @@ -133,3 +133,22 @@ export const authPluginManifestHeader = { name: 'Authentication & Identity Plugin', description: 'Core authentication objects for ObjectStack (User, Session, Account, Verification)', }; + +/** + * The identity-object half of plugin-auth's runtime manifest: the header, the + * objects and the fields plugin-auth adds to them. `AuthPlugin` spreads it into + * the one manifest it registers, and `IdentityObjectsPlugin` registers it on its + * own, so a reduced kernel and a full one register the same list under the same + * package id. + * + * `datasource` overrides the header's `defaultDatasource`, as + * `AuthPluginOptions.manifestDatasource` does. + */ +export function authIdentityManifest(options: { datasource?: string } = {}) { + return { + ...authPluginManifestHeader, + ...(options.datasource ? { defaultDatasource: options.datasource } : {}), + objects: authIdentityObjects, + objectExtensions: authObjectExtensions, + }; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 3fb0c694a6b..791b2face13 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1069,6 +1069,59 @@ function readSeedSettlementSnapshot(ctx: PluginContext): SeedSettlementSnapshot } } +/** + * The identity objects this plugin's authorization store READS — declared here, + * enforced at boot by {@link refuseMissingAuthzIdentityObjects}, and ⛔ never + * registered by this plugin: they belong to `@objectstack/plugin-auth`. + * + * Measured from the reads, not guessed: permission resolution runs through + * `@objectstack/core`'s `resolveUserAuthzGrants` (per request, and for this + * plugin's explain engine and scoped-invitation placement). Its reads are + * `sys_user` and `sys_member`, which plugin-auth registers, plus + * `sys_user_position`, `sys_user_permission_set`, `sys_position`, + * `sys_position_permission_set` and `sys_permission_set`, which are this + * plugin's own `securityObjects`, registered in `init()`. The engine refuses a + * read of a name its registry does not hold (`OBJECT_NOT_FOUND`), and that + * resolver reports the refusal as `AuthzStoreUnavailableError` (503) on every + * authenticated request: a missing dependency that reads as an outage. + */ +const AUTHZ_STORE_IDENTITY_OBJECTS: readonly string[] = ['sys_user', 'sys_member']; + +/** + * The boot refusal for a kernel that mounts this plugin without the identity + * objects its authorization store reads. `missingObjects` names exactly the + * ones absent from the engine's registry. + */ +export class AuthzIdentityObjectsMissingError extends Error { + readonly missingObjects: readonly string[]; + + constructor(missingObjects: readonly string[]) { + super( + `SecurityPlugin cannot boot: its authorization store reads ${AUTHZ_STORE_IDENTITY_OBJECTS.join(', ')}, ` + + `and this kernel does not register ${missingObjects.join(', ')}. Without them every ` + + 'authenticated request fails its permission read with AuthzStoreUnavailableError (503), ' + + 'which reads as an outage. @objectstack/plugin-auth registers these objects: mount ' + + 'AuthPlugin, or, on a kernel without authentication such as a test kit, mount ' + + 'createIdentityObjectsPlugin() from @objectstack/plugin-auth.', + ); + this.name = 'AuthzIdentityObjectsMissingError'; + this.missingObjects = [...missingObjects]; + } +} + +/** + * Run from a `kernel:ready` handler: every plugin's `init()` and `start()` has + * registered its manifests by then, and a throw there fails `bootstrap()` on + * both kernels. That is the boot-gate moment the kernel documents; during + * `init()` the registry is still filling. The question asked is the one the + * read itself will ask: does the engine's registry resolve the name. + */ +function refuseMissingAuthzIdentityObjects(ctx: PluginContext): void { + const ql = ctx.getService('objectql'); + const missing = AUTHZ_STORE_IDENTITY_OBJECTS.filter((name) => !ql.getSchema(name)); + if (missing.length > 0) throw new AuthzIdentityObjectsMissingError(missing); +} + export class SecurityPlugin implements Plugin { name = 'com.objectstack.security'; /** @@ -1405,6 +1458,15 @@ export class SecurityPlugin implements Plugin { async init(ctx: PluginContext): Promise { ctx.logger.info('Initializing Security Plugin...'); + // The identity objects the authorization store reads must be registered by + // the time the boot completes; a kernel without them is refused here, by + // name, instead of at its first permission read. Subscribed in `init()` so + // it runs ahead of every `kernel:ready` handler registered in `start()`, + // this plugin's own bootstraps included. + if (typeof (ctx as any).hook === 'function') { + (ctx as any).hook('kernel:ready', () => refuseMissingAuthzIdentityObjects(ctx)); + } + // Register security services ctx.registerService('security.permissions', this.permissionEvaluator); ctx.registerService('security.rls', this.rlsCompiler); From d9733b3a7e961c8864f071ee36b32de723c9cae5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:47:37 +0000 Subject: [PATCH 2/8] test(plugin-auth, plugin-security): pin the identity preset and the boot refusal Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../src/identity-objects-plugin.test.ts | 243 ++++++++++++++++++ ...uthz-identity-objects-boot-refusal.test.ts | 115 +++++++++ 2 files changed, 358 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/identity-objects-plugin.test.ts create mode 100644 packages/plugins/plugin-security/src/authz-identity-objects-boot-refusal.test.ts diff --git a/packages/plugins/plugin-auth/src/identity-objects-plugin.test.ts b/packages/plugins/plugin-auth/src/identity-objects-plugin.test.ts new file mode 100644 index 00000000000..053d5ca7611 --- /dev/null +++ b/packages/plugins/plugin-auth/src/identity-objects-plugin.test.ts @@ -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 { + 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'); + } + + 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 = {}) { + const registered: Array> = []; + const ctx = { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'manifest') return { register: (m: Record) => 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(); + }); +}); diff --git a/packages/plugins/plugin-security/src/authz-identity-objects-boot-refusal.test.ts b/packages/plugins/plugin-security/src/authz-identity-objects-boot-refusal.test.ts new file mode 100644 index 00000000000..b0726116ed2 --- /dev/null +++ b/packages/plugins/plugin-security/src/authz-identity-objects-boot-refusal.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * SecurityPlugin declares the identity objects its authorization store reads, + * and refuses at boot a kernel that does not register them. + * + * Its permission resolution (`@objectstack/core`'s `resolveUserAuthzGrants`) + * reads `sys_user` and `sys_member`, which plugin-auth registers. Without them + * the engine refuses those reads (`OBJECT_NOT_FOUND`) and the resolver reports + * `AuthzStoreUnavailableError` (503) on every authenticated request. The + * refusal moves that failure to `bootstrap()`, where it names the objects and + * the plugin that registers them. + * + * Real kernel, real ObjectQL, real SQLite driver. The identity objects come from + * `@objectstack/platform-objects/identity` under a package id of this test's + * own: the gate asks whether the engine's registry holds the names, not who + * registered them. plugin-auth's own suite pins the preset that registers them + * (`identity-objects-plugin.test.ts`). + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysMember, SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; +import { AuthzIdentityObjectsMissingError, SecurityPlugin } from './security-plugin.js'; + +/** 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 }), + ); + }, + }; +} + +/** Registers the given objects through the `manifest` service under a test package id. */ +function objectsPlugin(objects: unknown[]): Plugin { + return { + name: 'test.identity-stand-in', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + async init(ctx: PluginContext) { + ctx.getService<{ register(m: unknown): unknown }>('manifest').register({ + id: 'test.identity-stand-in', + namespace: 'sys', + version: '1.0.0', + type: 'plugin', + name: 'Identity stand-in', + objects, + }); + }, + }; +} + +describe('SecurityPlugin refuses at boot a kernel without the identity objects its authz store reads', () => { + let kernel: ObjectKernel | undefined; + + afterEach(async () => { + try { + await kernel?.shutdown(); + } catch { + /* a refused boot leaves the kernel stopped */ + } + kernel = undefined; + }); + + async function boot(extra: Plugin[]): Promise { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(sqliteDriverPlugin()); + await kernel.use(new ObjectQLPlugin()); + for (const p of extra) await kernel.use(p); + await kernel.use(new SecurityPlugin()); + try { + await kernel.bootstrap(); + return undefined; + } catch (err) { + return err; + } + } + + it('refuses when neither object is registered, naming both and the plugin that registers them', async () => { + const err = await boot([]); + + expect(err).toBeInstanceOf(AuthzIdentityObjectsMissingError); + expect((err as AuthzIdentityObjectsMissingError).missingObjects).toEqual(['sys_user', 'sys_member']); + const message = (err as Error).message; + expect(message).toContain('sys_user'); + expect(message).toContain('sys_member'); + expect(message).toContain('@objectstack/plugin-auth'); + expect(message).toContain('createIdentityObjectsPlugin()'); + expect(kernel!.getState()).toBe('stopped'); + }); + + it('names only the object that is missing', async () => { + const err = await boot([objectsPlugin([SysUser])]); + + expect(err).toBeInstanceOf(AuthzIdentityObjectsMissingError); + expect((err as AuthzIdentityObjectsMissingError).missingObjects).toEqual(['sys_member']); + }); + + it('boots when the registry holds both, whoever registered them', async () => { + const err = await boot([objectsPlugin([SysUser, SysMember, SysOrganization])]); + + expect(err).toBeUndefined(); + expect(kernel!.getState()).toBe('running'); + }); +}); From 01a6f4a376c0529a2599315b51b80447e9e4c29e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:53:08 +0000 Subject: [PATCH 3/8] test(plugin-security): engine doubles that fire kernel:ready hold the identity objects Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../claim-seed-ownership-seed-settle-rerun.test.ts | 6 +++++- .../declared-permission-reload-projection.test.ts | 5 ++++- .../plugin-security/src/security-plugin.test.ts | 13 +++++++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts index 1fc5651d5c2..34eb8837a14 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts @@ -94,7 +94,11 @@ function makeEngine(tables: Record, schemas: any[]) { middlewares, registry: { getAllObjects: () => schemas }, registerMiddleware: (mw: any) => middlewares.push(mw), - getSchema: (name: string) => schemas.find((s) => s.name === name), + // Plus the identity objects the plugin's boot gate requires: a kernel that + // boots it registers them (`authz-identity-objects-boot-refusal.test.ts`). + getSchema: (name: string) => + schemas.find((s) => s.name === name) + ?? (name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined), async find(object: string, query: any = {}) { const all = tables[object] ?? []; let hits = all.filter((r) => rowMatches(r, query?.where ?? {})); diff --git a/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts b/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts index 613591ac390..b87b77821fb 100644 --- a/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts +++ b/packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts @@ -65,7 +65,10 @@ async function boot() { const declaredPermissions: any[] = []; const ql: any = { registerMiddleware: () => {}, - getSchema: () => undefined, + // Only the identity objects the plugin's boot gate requires: a kernel that + // boots it registers them (`authz-identity-objects-boot-refusal.test.ts`). + getSchema: (name: string) => + name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined, registry: { listItems: (type: string) => (type === 'permission' ? [...declaredPermissions] : []), }, diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index a1df91cd24c..903994ce7af 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -16,6 +16,15 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { RLS } from '@objectstack/spec/security'; import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system'; +/** + * The registry answer for the doubles below that fire every `kernel:ready` + * handler, as the kernel does: the plugin refuses a boot whose registry lacks + * the identity objects its authorization store reads, so a double that boots it + * holds those two names and nothing else (`authz-identity-objects-boot-refusal.test.ts`). + */ +const identityObjectsOnly = (name: string) => + name === 'sys_user' || name === 'sys_member' ? { name, fields: {} } : undefined; + /** * [commit a016f08b8] What the ENGINE does inside the middleware's `next()` — the part of * `ObjectQL.insert` the doubles in this file stand in for. @@ -170,7 +179,7 @@ describe('SecurityPlugin', () => { assertEngineUpdateDispatch(d, o); return true; }), - getSchema: () => undefined, + getSchema: identityObjectsOnly, }; const metadata = { get: async () => null, list: async () => [] }; const services: Record = { manifest: manifestService, objectql: ql, metadata }; @@ -4436,7 +4445,7 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185 assertEngineUpdateDispatch(d, o); return true; }, - getSchema: () => undefined, + getSchema: identityObjectsOnly, }; const metadata = { get: async () => null, From f4f9a0256b422f7473aec46d92d0394446eb9970 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:55:00 +0000 Subject: [PATCH 4/8] fix(plugin-security): arm the identity-object boot gate from start(), not init() A declarations-only boot (os migrate composes host plugins for init() and suppresses start()) arms nothing that reads the authorization store and must not be refused. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../plugin-security/src/security-plugin.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 791b2face13..b90b3e0faca 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1458,15 +1458,6 @@ export class SecurityPlugin implements Plugin { async init(ctx: PluginContext): Promise { ctx.logger.info('Initializing Security Plugin...'); - // The identity objects the authorization store reads must be registered by - // the time the boot completes; a kernel without them is refused here, by - // name, instead of at its first permission read. Subscribed in `init()` so - // it runs ahead of every `kernel:ready` handler registered in `start()`, - // this plugin's own bootstraps included. - if (typeof (ctx as any).hook === 'function') { - (ctx as any).hook('kernel:ready', () => refuseMissingAuthzIdentityObjects(ctx)); - } - // Register security services ctx.registerService('security.permissions', this.permissionEvaluator); ctx.registerService('security.rls', this.rlsCompiler); @@ -1652,6 +1643,17 @@ export class SecurityPlugin implements Plugin { async start(ctx: PluginContext): Promise { ctx.logger.info('Starting Security Plugin...'); + // The identity objects the authorization store reads must be registered by + // the time the boot completes: a kernel without them is refused at + // `kernel:ready`, by name, instead of at its first permission read. + // Subscribed at the head of `start()`, so it runs ahead of this plugin's own + // `kernel:ready` bootstraps, and above both bail-outs below. A boot that + // composes this plugin for its declarations only (`os migrate`, which + // suppresses `start()`) arms nothing that reads the store, and is not refused. + if (typeof (ctx as any).hook === 'function') { + (ctx as any).hook('kernel:ready', () => refuseMissingAuthzIdentityObjects(ctx)); + } + // [#10706] Bind the report sink FIRST — above the two bail-outs below. // Both of them `return` before the "capture handles" block, so binding the // sink there left `this.logger` at its `= {}` construction default for the From dbcaa279fe122a4ecc574005637eec89c31eeb44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:04:58 +0000 Subject: [PATCH 5/8] test(runtime, service-automation): harnesses that boot SecurityPlugin without AuthPlugin mount the identity preset Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../src/standalone-stack-seeder-declaration-copy.test.ts | 5 +++++ packages/services/service-automation/package.json | 1 + ...es-stored-metadata-family-refusal.integration.test.ts | 9 ++++++++- .../src/runas-system-stamping.integration.test.ts | 5 +++++ packages/services/service-automation/vitest.config.ts | 9 +++++++++ pnpm-lock.yaml | 3 +++ 6 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts index a87281dcffa..f4cd13cd12d 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -72,6 +72,7 @@ import { join } from 'node:path'; import { ObjectKernel } from '@objectstack/core'; import { Runtime } from './runtime.js'; import { createStandaloneStack } from './standalone-stack.js'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { SharingServicePlugin } from '@objectstack/plugin-sharing'; @@ -222,6 +223,10 @@ beforeAll(async () => { // `org-scoping` installed. Without it the sharing seeder runs zero passes and // this file would measure the tenancy default instead of the seeder's read. kernel.registerService('tenancy', { posture: 'single' } as any); + // No auth plugin here, so plugin-auth's identity objects come from its + // preset: `SecurityPlugin` refuses a boot without the `sys_user` / + // `sys_member` its authorization store reads. + await kernel.use(createIdentityObjectsPlugin() as any); await kernel.use(new SecurityPlugin() as any); await kernel.use(new SharingServicePlugin() as any); await kernel.bootstrap(); diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 0f4f6b38570..1369c798edf 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -37,6 +37,7 @@ "@objectstack/driver-sql": "workspace:*", "@objectstack/metadata-core": "workspace:*", "@objectstack/objectql": "workspace:*", + "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/refd-timer-testkit": "workspace:*", "@objectstack/service-job": "workspace:*", 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 08fe0e58941..cb6ea5422e5 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 @@ -52,6 +52,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { ObjectKernel } from '@objectstack/core'; import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { STORED_METADATA_BODY_PRESCRIPTION } from '@objectstack/spec/kernel'; @@ -162,7 +163,13 @@ async function boot(secured: boolean) { const kernel = new ObjectKernel({ logger: { level: 'fatal' } }); await kernel.use(new ObjectQLPlugin()); await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); - if (secured) await kernel.use(new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] })); + if (secured) { + // SecurityPlugin refuses a kernel without the identity objects its + // authorization store reads; with no auth plugin here, plugin-auth's preset + // registers them. + await kernel.use(createIdentityObjectsPlugin()); + await kernel.use(new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] })); + } await kernel.bootstrap(); const ql = kernel.getService('objectql'); const automation = kernel.getService('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 0ec2975573c..34b563c2d68 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 @@ -38,6 +38,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectKernel } from '@objectstack/core'; import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system'; @@ -243,6 +244,10 @@ describe('the #5494 admission flip: row content, not caller, decides (real Secur kernel = new ObjectKernel({ logger: { level: 'fatal' } }); await kernel.use(new ObjectQLPlugin()); await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + // SecurityPlugin refuses a kernel without the identity objects its + // authorization store reads; with no auth plugin here, plugin-auth's preset + // registers them. + await kernel.use(createIdentityObjectsPlugin()); await kernel.use( new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, taskDeleteSet], diff --git a/packages/services/service-automation/vitest.config.ts b/packages/services/service-automation/vitest.config.ts index 9dc81cc82f6..8d57372290b 100644 --- a/packages/services/service-automation/vitest.config.ts +++ b/packages/services/service-automation/vitest.config.ts @@ -72,6 +72,15 @@ export default defineConfig({ find: /^@objectstack\/metadata-protocol$/, replacement: path.resolve(__dirname, '../../metadata-protocol/src/index.ts'), }, + { + // The two integration suites that boot the real `SecurityPlugin` mount + // plugin-auth's identity preset (`createIdentityObjectsPlugin()`): + // `SecurityPlugin` refuses a kernel without the `sys_user` / `sys_member` + // its authorization store reads. Read from source for the reason the + // entries above give. Same anchored-regex rule. + find: /^@objectstack\/plugin-auth$/, + replacement: path.resolve(__dirname, '../../plugins/plugin-auth/src/index.ts'), + }, ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dac884e5176..74b7acc382c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2460,6 +2460,9 @@ importers: '@objectstack/objectql': specifier: workspace:* version: link:../../objectql + '@objectstack/plugin-auth': + specifier: workspace:* + version: link:../../plugins/plugin-auth '@objectstack/plugin-security': specifier: workspace:* version: link:../../plugins/plugin-security From 2e81abbd621ec950cfcfd35f73fe4b4092e826f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:08:52 +0000 Subject: [PATCH 6/8] chore(changeset): plugin-auth identity preset (minor); plugin-security boot refusal (minor, breaking) Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../22074-auth-identity-objects-plugin.md | 15 +++++++++++ ...-security-identity-objects-boot-refusal.md | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 .changeset/22074-auth-identity-objects-plugin.md create mode 100644 .changeset/22074-security-identity-objects-boot-refusal.md diff --git a/.changeset/22074-auth-identity-objects-plugin.md b/.changeset/22074-auth-identity-objects-plugin.md new file mode 100644 index 00000000000..54fa57ee5a0 --- /dev/null +++ b/.changeset/22074-auth-identity-objects-plugin.md @@ -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. diff --git a/.changeset/22074-security-identity-objects-boot-refusal.md b/.changeset/22074-security-identity-objects-boot-refusal.md new file mode 100644 index 00000000000..2288d8b3916 --- /dev/null +++ b/.changeset/22074-security-identity-objects-boot-refusal.md @@ -0,0 +1,27 @@ +--- +'@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) + + + +**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()`). + +## FROM → TO + +| you composed | compose instead | +|:--|:--| +| `ObjectQLPlugin` + `SecurityPlugin`, no `AuthPlugin` (a test kit) | add `createIdentityObjectsPlugin()` from `@objectstack/plugin-auth` | +| a hand-written plugin that registers `SysUser` / `SysMember` | `createIdentityObjectsPlugin()`, which registers plugin-auth's own list (the hand-written one still satisfies the check) | +| `DevPlugin` with `services: { auth: false }` and security on | `services: { security: false }`, or `createIdentityObjectsPlugin()` in `extraPlugins` | + +**The one-line fix:** `await kernel.use(createIdentityObjectsPlugin())`, from `@objectstack/plugin-auth`, on a kernel that mounts `SecurityPlugin` without `AuthPlugin`. From 1a507892d8eaf86c5071de0b978628b5732c2575 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:09:40 +0000 Subject: [PATCH 7/8] chore(changeset): state the plugin-security remedy as prose; nothing authorable is renamed Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../22074-security-identity-objects-boot-refusal.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.changeset/22074-security-identity-objects-boot-refusal.md b/.changeset/22074-security-identity-objects-boot-refusal.md index 2288d8b3916..6d221554ce2 100644 --- a/.changeset/22074-security-identity-objects-boot-refusal.md +++ b/.changeset/22074-security-identity-objects-boot-refusal.md @@ -16,12 +16,4 @@ Clause-②: yes (narrowing) **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()`). -## FROM → TO - -| you composed | compose instead | -|:--|:--| -| `ObjectQLPlugin` + `SecurityPlugin`, no `AuthPlugin` (a test kit) | add `createIdentityObjectsPlugin()` from `@objectstack/plugin-auth` | -| a hand-written plugin that registers `SysUser` / `SysMember` | `createIdentityObjectsPlugin()`, which registers plugin-auth's own list (the hand-written one still satisfies the check) | -| `DevPlugin` with `services: { auth: false }` and security on | `services: { security: false }`, or `createIdentityObjectsPlugin()` in `extraPlugins` | - -**The one-line fix:** `await kernel.use(createIdentityObjectsPlugin())`, from `@objectstack/plugin-auth`, on a kernel that mounts `SecurityPlugin` without `AuthPlugin`. +**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. From 90bb6549a87ef271f7f895eaf80a9359027f9f0e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:30:16 +0000 Subject: [PATCH 8/8] fix(plugin-auth): spell IdentityObjectsPlugin's optionalDependencies as a literal the init-service gate reads Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../plugin-auth/src/identity-objects-plugin.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-auth/src/identity-objects-plugin.ts b/packages/plugins/plugin-auth/src/identity-objects-plugin.ts index 13bf4149abb..d12fc3fe67d 100644 --- a/packages/plugins/plugin-auth/src/identity-objects-plugin.ts +++ b/packages/plugins/plugin-auth/src/identity-objects-plugin.ts @@ -50,9 +50,6 @@ import { authIdentityManifest } from './manifest.js'; /** The kernel plugin name of {@link IdentityObjectsPlugin}. */ export const IDENTITY_OBJECTS_PLUGIN_NAME = 'com.objectstack.auth.identity-objects'; -/** `AuthPlugin`'s kernel plugin name; its `init()` registers the `auth` service. */ -const AUTH_KERNEL_PLUGIN_NAME = 'com.objectstack.auth'; - export interface IdentityObjectsPluginOptions { /** * The datasource that owns the identity tables, with the same meaning as @@ -72,8 +69,12 @@ export class IdentityObjectsPlugin implements Plugin { version = '1.0.0'; /** ObjectQL registers the `manifest` service this plugin registers through. */ dependencies: string[] = ['com.objectstack.engine.objectql']; - /** Ordered ahead when composed, so `init()` can refuse the pair. */ - optionalDependencies: string[] = [AUTH_KERNEL_PLUGIN_NAME]; + /** + * `AuthPlugin`'s kernel plugin name: ordered ahead when composed, so its + * `auth` service is registered by the time `init()` asks, and the pair is + * refused there. + */ + optionalDependencies: string[] = ['com.objectstack.auth']; requiresServices: string[] = ['manifest']; private readonly options: IdentityObjectsPluginOptions;