From 0a93529f3fd7c680c46e5d44262a328d9cda842f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 23:37:26 +0000 Subject: [PATCH 1/5] fix(plugin-auth, runtime): three principal-less engine calls take the explicit system opt-in The platform-admin OAuth client toggle route reads and writes sys_oauth_application through withSystemContext, the wrapper better-auth's adapter already writes those rows through. The SCIM bearer verifier's credential probe and the dispatcher's environment-membership read pass isSystem: true in the read's trailing options. Each call is authorized by its own door (the platform-admin judge, the bearer digest, the membership gate itself); none now reaches the engine as a context with no principal and no opt-in, the security middleware's principal-less hand-off (ADR-0096). No door, answer or stored row moves. Pins: a context recorder on a real engine (plugin-auth) and a find double (runtime), plus the membership gate's answers on an engine that refuses a principal-less context and its unchanged fail-open catch. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-plugin.ts | 13 +- ...ipal-less-producers-system-context.test.ts | 207 ++++++++++++++++++ .../src/scim-connection-service.ts | 18 +- ...spatcher.membership-system-context.test.ts | 130 +++++++++++ packages/runtime/src/http-dispatcher.ts | 7 +- 5 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts create mode 100644 packages/runtime/src/http-dispatcher.membership-system-context.test.ts diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index f36179871dd..93a3bc6eff7 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -44,6 +44,7 @@ import { import { isDefaultOrganizationBootstrapTrigger } from './ensure-default-organization.js'; import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; import { runAttributedToUser } from './auth-actor-attribution.js'; +import { withSystemContext } from './objectql-adapter.js'; import type { AuthEventAuditSurface } from './auth-session-audit.js'; import { createTenancyService, type TenancyService } from './tenancy-service.js'; import { @@ -2379,10 +2380,18 @@ export class AuthPlugin implements Plugin { // better-auth's own endpoint invocation context. This is the same // physical row the better-auth runtime reads at introspect / token // / authorize time, so the toggle is fully honoured. - const dataEngine: any = this.authManager!.getDataEngine(); - if (!dataEngine) { + // + // And through the same WRAPPER: `withSystemContext`, so the read and + // the write both carry the explicit system opt-in (`isSystem: true`). + // The platform-admin judge above is this route's authorization; the + // raw engine would have handed the security middleware a context with + // no principal and no opt-in — the principal-less hand-off (ADR-0096), + // which is not an authorization at all. + const rawEngine = this.authManager!.getDataEngine(); + if (!rawEngine) { return c.json({ success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'Data engine unavailable' } }, 503); } + const dataEngine: any = withSystemContext(rawEngine); const existing = await dataEngine.findOne('sys_oauth_application', { where: { client_id: clientId }, diff --git a/packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts b/packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts new file mode 100644 index 00000000000..3a820bec5ef --- /dev/null +++ b/packages/plugins/plugin-auth/src/principal-less-producers-system-context.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Two plugin-auth engine calls that used to reach the engine with NO principal + * and NO opt-in now carry the explicit system opt-in (`isSystem: true`): + * + * - the platform-admin OAuth client toggle route (`/admin/oauth2/toggle-disabled`): + * its `sys_oauth_application` read and write go through `withSystemContext`, + * the same wrapper better-auth's adapter writes those rows through; + * - the SCIM bearer verifier (`verifyScimBearerToken`): its credential probe + * carries the opt-in in the read's trailing options. + * + * A context with neither a principal nor `isSystem` is the security + * middleware's principal-less hand-off (ADR-0096), which is not an + * authorization: each of these calls is already authorized by its own door + * (the platform-admin judge; the bearer digest). The opt-in names that. + * + * Measured on a REAL engine: a middleware registered on it records the context + * every operation receives, so what is asserted is what the middleware chain — + * the security middleware included, in a composed deployment — is handed. Each + * case also pins the door's answer and the stored row, which this change does + * not move. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Hono } from 'hono'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { PluginContext } from '@objectstack/core'; +import { AuthPlugin } from './auth-plugin.js'; +import { authIdentityObjects } from './manifest.js'; +import { SCIM_CREDENTIAL_OBJECT, digestScimBearerToken, verifyScimBearerToken } from './scim-connection-service.js'; + +const BASE = '/api/v1/auth'; +const ORIGIN = 'http://localhost:3000'; +const SYSTEM = { context: { isSystem: true } } as const; + +interface Seen { + object: string; + operation: string; + context: Record | undefined; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + const e = engines.pop(); + try { + await (e as unknown as { destroy?(): Promise })?.destroy?.(); + } catch { + /* noop */ + } + } +}); + +/** A real engine over in-memory sqlite, with plugin-auth's own objects and a context recorder. */ +async function bootEngine(): Promise<{ engine: ObjectQL; seen: Seen[] }> { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const object of authIdentityObjects) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + await engine.syncSchemas(); + const seen: Seen[] = []; + engine.registerMiddleware(async (opCtx, next) => { + seen.push({ + object: opCtx.object, + operation: opCtx.operation, + context: opCtx.context as Record | undefined, + }); + await next(); + }); + return { engine, seen }; +} + +const mockCtx = (): PluginContext => + ({ + registerService: vi.fn(), + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + }) as unknown as PluginContext; + +/** + * The plugin's REAL route registration on a real Hono app (the + * `admin-sso-bridge-gate.test.ts` harness), with the auth manager reduced to + * the seams the toggle route reads: the session the platform-admin judge + * reads, and the data engine. + */ +async function mountRoutes(engine: ObjectQL) { + const app = new Hono(); + const ctx = mockCtx(); + const plugin = new AuthPlugin({ + secret: 'test-secret-at-least-32-chars-long!!', + plugins: { oidcProvider: false }, + }); + await plugin.init(ctx); + (plugin as unknown as { authManager: unknown }).authManager = { + handleRequest: async () => new Response(null, { status: 404 }), + getApi: async () => ({ + getSession: async () => ({ user: { id: 'usr_platform_admin', isPlatformAdmin: true } }), + }), + getDataEngine: () => engine, + }; + (plugin as unknown as { + registerAuthRoutes(server: unknown, ctx: PluginContext): void; + }).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); + return app; +} + +const toggle = (app: Hono, body: Record) => + app.request(`${ORIGIN}${BASE}/admin/oauth2/toggle-disabled`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN }, + body: JSON.stringify(body), + }); + +const onObject = (seen: Seen[], object: string) => seen.filter((s) => s.object === object); + +describe('platform-admin OAuth client toggle — the read and the write carry the system opt-in', () => { + it('flips the stored flag, and every sys_oauth_application operation it makes is isSystem', async () => { + const { engine, seen } = await bootEngine(); + await engine.insert( + 'sys_oauth_application', + { + client_id: 'pin-client', + name: 'Pin Client', + redirect_uris: JSON.stringify(['https://pin.example/cb']), + disabled: false, + }, + SYSTEM, + ); + const app = await mountRoutes(engine); + seen.length = 0; + + const res = await toggle(app, { client_id: 'pin-client', disabled: true }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true, data: { client_id: 'pin-client', disabled: true } }); + + const ops = onObject(seen, 'sys_oauth_application'); + // Not vacuous: the route made both of its calls. + expect(ops.map((o) => o.operation)).toEqual(['findOne', 'update']); + for (const op of ops) { + expect(op.context?.isSystem, `${op.operation} reached the engine without the system opt-in`).toBe(true); + } + + const stored = await engine.findOne('sys_oauth_application', { where: { client_id: 'pin-client' } }, SYSTEM); + expect(Boolean(stored?.disabled)).toBe(true); + }); + + it('a missing client is still 404 RESOURCE_NOT_FOUND, and its read is isSystem', async () => { + const { engine, seen } = await bootEngine(); + const app = await mountRoutes(engine); + seen.length = 0; + + const res = await toggle(app, { client_id: 'no-such-client', disabled: true }); + expect(res.status).toBe(404); + const body = (await res.json()) as { success: boolean; error: { code: string } }; + expect(body.success).toBe(false); + expect(body.error.code).toBe('RESOURCE_NOT_FOUND'); + + const ops = onObject(seen, 'sys_oauth_application'); + expect(ops.map((o) => o.operation)).toEqual(['findOne']); + expect(ops[0].context?.isSystem).toBe(true); + }); +}); + +describe('SCIM bearer verification — the credential probe carries the system opt-in', () => { + const SECRET = 'scim-pin-secret-at-least-32-chars-long'; + const TOKEN = 'oss_scim_pin-token'; + + it('resolves a known bearer to its connection, through an isSystem read', async () => { + const { engine, seen } = await bootEngine(); + await engine.insert( + SCIM_CREDENTIAL_OBJECT, + { connection_id: 'conn-pin', token_digest: digestScimBearerToken(SECRET, TOKEN), active: true }, + SYSTEM, + ); + seen.length = 0; + + const verified = await verifyScimBearerToken(engine as never, SECRET, TOKEN); + expect(verified?.connection).toEqual({ id: 'conn-pin', provisioningDomainId: 'conn-pin' }); + + const ops = onObject(seen, SCIM_CREDENTIAL_OBJECT); + expect(ops.map((o) => o.operation)).toEqual(['findOne']); + expect(ops[0].context?.isSystem, 'the credential probe reached the engine without the system opt-in').toBe(true); + }); + + it('an unknown bearer is still null, through an isSystem read', async () => { + const { engine, seen } = await bootEngine(); + seen.length = 0; + + expect(await verifyScimBearerToken(engine as never, SECRET, 'oss_scim_unknown')).toBeNull(); + + const ops = onObject(seen, SCIM_CREDENTIAL_OBJECT); + expect(ops.map((o) => o.operation)).toEqual(['findOne']); + expect(ops[0].context?.isSystem).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-auth/src/scim-connection-service.ts b/packages/plugins/plugin-auth/src/scim-connection-service.ts index b388b4dec98..2c7cb0a2900 100644 --- a/packages/plugins/plugin-auth/src/scim-connection-service.ts +++ b/packages/plugins/plugin-auth/src/scim-connection-service.ts @@ -107,9 +107,23 @@ export type ScimScope = (typeof SCIM_ALL_SCOPES)[number]; /** Minimal engine surface the service needs (matches IDataEngine usage here). */ interface CredentialEngine { insert(object: string, row: Record): Promise>; - findOne(object: string, query: { where: Record }): Promise | null>; + findOne( + object: string, + query: { where: Record }, + options?: { context?: { isSystem?: boolean } }, + ): Promise | null>; } +/** + * The credential probe's execution context: the explicit system opt-in. The + * verifier runs BEFORE any caller is known — the bearer it is verifying is the + * only identity on the request — so there is no principal to carry, and the + * read must say so rather than reach the engine with no principal and no + * opt-in (the security middleware's principal-less hand-off, ADR-0096). The + * digest equality in the `where` is the whole of what the probe may match. + */ +const CREDENTIAL_PROBE_CONTEXT = { context: { isSystem: true } } as const; + /** * One-way digest of a SCIM bearer: HMAC-SHA-256(secret, "scim-credential-v1:" + token), * base64url unpadded. See the file header for why keyed + deterministic. @@ -196,7 +210,7 @@ export async function verifyScimBearerToken( const digest = digestScimBearerToken(secret, token); let row: Record | null; try { - row = await engine.findOne(SCIM_CREDENTIAL_OBJECT, { where: { token_digest: digest } }); + row = await engine.findOne(SCIM_CREDENTIAL_OBJECT, { where: { token_digest: digest } }, CREDENTIAL_PROBE_CONTEXT); } catch { // A storage fault reads as "cannot verify", never as "verified". return null; diff --git a/packages/runtime/src/http-dispatcher.membership-system-context.test.ts b/packages/runtime/src/http-dispatcher.membership-system-context.test.ts new file mode 100644 index 00000000000..e603a922124 --- /dev/null +++ b/packages/runtime/src/http-dispatcher.membership-system-context.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The dispatcher's environment-membership gate reads `sys_environment_member` + * with the explicit system opt-in (`isSystem: true`). + * + * The gate is the one asking the question — the caller's user id is the + * `where`, not the reader — so the read runs as the platform. Before, it + * reached the engine with no principal and no opt-in: the security + * middleware's principal-less hand-off (ADR-0096), which is not an + * authorization, and which a deny of principal-less contexts would refuse. + * + * Three facts are pinned here: + * + * 1. the read carries the opt-in, for a member and for a non-member, and the + * gate's two answers (pass; `403 PROJECT_MEMBERSHIP_REQUIRED`) are + * unchanged; + * 2. the gate KEEPS its answers on an engine that refuses a principal-less, + * non-system context — the read is not one, so the non-member is still + * refused rather than waved through by the catch below; + * 3. the catch around the read is unchanged: a read that throws lets the + * request through (`null`), as it did before. That is pre-existing + * behaviour, pinned as it stands, not endorsed here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from './http-dispatcher.js'; + +const USER = 'user-1'; +const ENV = 'env-private'; + +type FindCall = [string, Record | undefined, { context?: Record } | undefined]; + +/** The context the engine would act under: the query's, overridden by the trailing options' (ObjectQL's rule). */ +function effectiveContext(call: FindCall): Record { + const fromQuery = (call[1]?.context ?? {}) as Record; + const fromOptions = (call[2]?.context ?? {}) as Record; + return { ...fromQuery, ...fromOptions }; +} + +function isPrincipalLessNonSystem(ctx: Record): boolean { + const positions = (ctx.positions as unknown[] | undefined) ?? []; + const permissions = (ctx.permissions as unknown[] | undefined) ?? []; + return positions.length === 0 && permissions.length === 0 && !ctx.userId && ctx.isSystem !== true; +} + +function makeDispatcher(find: (...args: FindCall) => Promise) { + const ql = { find: vi.fn(find) }; + const kernel: any = { + context: { + getService: (name: string) => { + if (name === 'auth') { + return { getApi: async () => ({ getSession: async () => ({ user: { id: USER } }) }) }; + } + if (name === 'objectql') return ql; + return null; + }, + }, + }; + const dispatcher = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: true }); + const check = () => + (dispatcher as any).enforceProjectMembership( + { request: { headers: {} }, environmentId: ENV }, + `/api/v1/environments/${ENV}/data/task`, + ) as Promise<{ status: number; body: any } | null>; + return { ql, check }; +} + +function expectMembershipRefusal(response: { status: number; body: any } | null) { + expect(response?.status).toBe(403); + expect(response?.body?.error?.code).toBe('PROJECT_MEMBERSHIP_REQUIRED'); +} + +describe('environment-membership gate — the read carries the system opt-in', () => { + it('a non-member is refused, and the membership read was isSystem', async () => { + const { ql, check } = makeDispatcher(async () => []); + expectMembershipRefusal(await check()); + + expect(ql.find).toHaveBeenCalledTimes(1); + const call = ql.find.mock.calls[0] as FindCall; + expect(call[0]).toBe('sys_environment_member'); + expect(call[1]?.where).toEqual({ environment_id: ENV, user_id: USER }); + expect(effectiveContext(call).isSystem, 'the membership read reached the engine without the system opt-in').toBe(true); + }); + + it('a member passes, and the membership read was isSystem', async () => { + const { ql, check } = makeDispatcher(async () => [{ id: 'm1' }]); + expect(await check()).toBeNull(); + expect(effectiveContext(ql.find.mock.calls[0] as FindCall).isSystem).toBe(true); + }); +}); + +describe('environment-membership gate — on an engine that refuses a principal-less, non-system context', () => { + /** Refuses as the security middleware's principal-less deny would: 403 PERMISSION_DENIED, before any row is read. */ + const refusingEngine = (rows: (o: string, q?: Record) => Promise) => + async (...call: FindCall) => { + if (isPrincipalLessNonSystem(effectiveContext(call))) { + throw Object.assign(new Error('[Security] Access denied: principal-less context'), { + code: 'PERMISSION_DENIED', + status: 403, + }); + } + return rows(call[0], call[1]); + }; + + it('still refuses the non-member — the gate is not opened by the refusal', async () => { + const { check } = makeDispatcher(refusingEngine(async () => [])); + expectMembershipRefusal(await check()); + }); + + it('still passes the member', async () => { + const { check } = makeDispatcher(refusingEngine(async () => [{ id: 'm1' }])); + expect(await check()).toBeNull(); + }); +}); + +describe('environment-membership gate — the catch around the read is unchanged (pre-existing)', () => { + it('a read that throws lets the request through (null), as before', async () => { + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + try { + const { ql, check } = makeDispatcher(async () => { + throw new Error('control-plane store unavailable'); + }); + expect(await check()).toBeNull(); + expect(ql.find).toHaveBeenCalledTimes(1); + } finally { + debug.mockRestore(); + } + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 5944b36f25f..fd99e50a659 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1461,10 +1461,15 @@ export class HttpDispatcher { const ql = qlService ?? await this.resolveService(this.requestKernel(context), 'objectql'); if (!ql) return null; // No QL — cannot enforce; fail open. + // The membership read carries the explicit system opt-in. This + // gate is the one asking the question — the caller's user id is + // the `where`, not the reader — so the read runs as the platform, + // never as a context with no principal and no opt-in (the + // security middleware's principal-less hand-off, ADR-0096). let rows = await ql.find('sys_environment_member', { where: { environment_id: environmentId, user_id: userId }, limit: 1, - } as any); + } as any, { context: { isSystem: true } }); if (rows && (rows as any).value) rows = (rows as any).value; const isMember = Array.isArray(rows) && rows.length > 0; From 4239dd477d6f62154555df7bb4031ee0e3534209 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 00:16:01 +0000 Subject: [PATCH 2/5] docs(permissions): system-context census counts current; changeset for the three system opt-ins The census page's held declaration count follows the new trailing-options type on the SCIM credential probe (check-system-context-census --fix). patch changesets for plugin-auth and runtime. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .changeset/21912-principal-less-producers.md | 14 ++++++++++++++ content/docs/permissions/system-context.mdx | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .changeset/21912-principal-less-producers.md diff --git a/.changeset/21912-principal-less-producers.md b/.changeset/21912-principal-less-producers.md new file mode 100644 index 00000000000..8aac22bc307 --- /dev/null +++ b/.changeset/21912-principal-less-producers.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/runtime': patch +--- + +Three engine calls that reached the data engine with no principal and no `isSystem` now carry the explicit system opt-in. Each is already authorized by its own door, so nothing it answers changes. + +Clause-②: no + +- **`@objectstack/plugin-auth` — the platform-admin OAuth client toggle route** (`POST /api/v1/auth/admin/oauth2/toggle-disabled`). Its `sys_oauth_application` read and write go through `withSystemContext`, the wrapper better-auth's adapter already writes those rows through. The platform-admin judge still runs first. The answers (`200`, `404 RESOURCE_NOT_FOUND`, the refusals) and the stored row are unchanged. One log line goes away: the engine's read-only `updated_at` warning on every toggle. The value it warned about was discarded before and the driver still stamps the column. +- **`@objectstack/plugin-auth` — `verifyScimBearerToken`.** The credential probe passes `isSystem: true` in the read's trailing options. It runs before any caller is known, and the digest equality is still all it matches. An unknown, inactive or expired bearer is still `null` (`401`). +- **`@objectstack/runtime` — the dispatcher's environment-membership gate.** The `sys_environment_member` read passes `isSystem: true` in the read's trailing options. The caller's user id stays in the `where`. A member still passes and a non-member is still refused with `403 PROJECT_MEMBERSHIP_REQUIRED`. The catch around the read is unchanged: a read that throws still lets the request through. + +Why: the security middleware hands a context with no principal and no `isSystem` straight through (ADR-0096). That hand-through is not an authorization. A caller that is the platform acting for itself says so explicitly. ⛔ No new elevation API, no door's authorization moves, and no accept set changes. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 9cbf07f77d2..db91cb97888 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -353,7 +353,7 @@ still holds equal to the census on every pull request: | — in tests | 1013 | — | | — in non-test sources | 798 | — | | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | -| — parsed as a declaration | 25 | ✅ | +| — parsed as a declaration | 26 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | | — parsed as a property **read** | 120 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | From c3147947b3ee6f44a3f20b852f0abd12ccbd81e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 00:31:02 +0000 Subject: [PATCH 3/5] fix(plugin-auth): the organization slug guard's reads take the explicit system opt-in beforeUpdateOrganization reads sys_organization and sys_environment through withSystemContext. The hook is the slug guard itself: the organization id is the where, not the reader, so neither read reaches the engine as a context with no principal and no opt-in (ADR-0096's principal-less hand-off). The catches around both reads are unchanged. Pins: both reads carry the opt-in; the guard still refuses on an engine that refuses a principal-less context; the pre-existing catches still end the hook without refusing. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- ...ager.org-slug-guard-system-context.test.ts | 198 ++++++++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 12 +- 2 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts diff --git a/packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts b/packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts new file mode 100644 index 00000000000..b228ec74297 --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The organization slug guard (`organizationHooks.beforeUpdateOrganization`) + * reads `sys_organization` and `sys_environment` through `withSystemContext`, + * so both reads carry the explicit system opt-in (`isSystem: true`). + * + * The hook IS the guard: the organization id is the `where`, not the reader. + * Before, both reads reached the engine with no principal and no opt-in — the + * security middleware's principal-less hand-off (ADR-0096), which is not an + * authorization, and which a deny of principal-less contexts would refuse. + * + * Three facts are pinned here: + * + * 1. both reads carry the opt-in, and the guard's answers (refuse a slug + * change while an active environment references the organization; allow + * it otherwise) are unchanged; + * 2. the guard KEEPS refusing on an engine that refuses a principal-less, + * non-system context — its reads are not one, so the catch below never + * turns the refusal into a skipped guard; + * 3. the catches around the two reads are unchanged: a read that throws ends + * the hook without refusing, as it did before. That is pre-existing + * behaviour, pinned as it stands, not endorsed here. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AuthManager } from './auth-manager'; + +vi.mock('better-auth', () => ({ + betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })), +})); +vi.mock('better-auth/plugins/organization', () => ({ + organization: vi.fn((opts: any) => ({ id: 'organization', _opts: opts })), +})); +vi.mock('better-auth/plugins/two-factor', () => ({ + twoFactor: vi.fn((opts: any) => ({ id: 'two-factor', _opts: opts })), +})); +vi.mock('better-auth/plugins/magic-link', () => ({ + magicLink: vi.fn((_opts?: any) => ({ id: 'magic-link' })), +})); +vi.mock('better-auth/plugins/custom-session', () => ({ + customSession: vi.fn((fn: any) => ({ id: 'custom-session', _fn: fn })), +})); +vi.mock('better-auth/plugins/haveibeenpwned', () => ({ + haveIBeenPwned: vi.fn((opts: any) => ({ id: 'have-i-been-pwned', _opts: opts })), +})); + +import { betterAuth } from 'better-auth'; + +type Query = { where?: Record; context?: Record } | undefined; +type Options = { context?: Record } | undefined; + +/** The context the engine would act under: the query's, overridden by the trailing options' (ObjectQL's rule). */ +const effectiveContext = (q: Query, o?: Options): Record => ({ ...(q?.context ?? {}), ...(o?.context ?? {}) }); + +function isPrincipalLessNonSystem(ctx: Record): boolean { + const positions = (ctx.positions as unknown[] | undefined) ?? []; + const permissions = (ctx.permissions as unknown[] | undefined) ?? []; + return positions.length === 0 && permissions.length === 0 && !ctx.userId && ctx.isSystem !== true; +} + +const ORG = { id: 'org-42', slug: 'acme-old' }; +const ENVS = [ + { id: 'e1', status: 'active' }, + { id: 'e2', status: 'archived' }, +]; + +const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED; +beforeEach(() => { + vi.clearAllMocks(); + // The MCP surface is default-ON and would append jwt + oauth-provider; this + // file needs only the organization plugin's hooks. + process.env.OS_MCP_SERVER_ENABLED = 'false'; +}); +afterEach(() => { + if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED; + else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv; +}); + +/** Build the manager over `dataEngine` and hand back the slug guard better-auth would call. */ +async function slugGuard(dataEngine: unknown) { + let captured: any; + (betterAuth as any).mockImplementation((config: any) => { + captured = config; + return { handler: vi.fn(), api: {} }; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { organization: true }, + dataEngine: dataEngine as any, + }); + await manager.getAuthInstance(); + } finally { + warnSpy.mockRestore(); + } + const orgPlugin = captured.plugins.find((p: any) => p.id === 'organization'); + const guard = orgPlugin._opts.organizationHooks.beforeUpdateOrganization as (arg: unknown) => Promise; + expect(typeof guard).toBe('function'); + return (slug: string) => guard({ organization: { slug }, member: { organizationId: ORG.id } }); +} + +/** The guard's refusal, asserted on its envelope: better-auth's `FORBIDDEN` / 403, naming the active environments. */ +async function expectSlugRefusal(attempt: Promise) { + const err = (await attempt.then( + () => undefined, + (e: unknown) => e, + )) as { status?: unknown; statusCode?: unknown; body?: { message?: unknown } } | undefined; + expect(err, 'the slug change was not refused').toBeTruthy(); + expect(err?.statusCode).toBe(403); + expect(err?.status).toBe('FORBIDDEN'); + expect(String(err?.body?.message)).toMatch(/active.*environment/i); +} + +describe('organization slug guard — both reads carry the system opt-in', () => { + it('refuses a slug change while an active environment references the org, through two isSystem reads', async () => { + const engine = { + findOne: vi.fn(async (_object: string, _q?: Query, _o?: Options) => ORG), + find: vi.fn(async (_object: string, _q?: Query, _o?: Options) => ENVS), + }; + const update = await slugGuard(engine); + + await expectSlugRefusal(update('acme-new')); + + expect(engine.findOne).toHaveBeenCalledTimes(1); + const [orgObject, orgQuery, orgOptions] = engine.findOne.mock.calls[0]; + expect(orgObject).toBe('sys_organization'); + expect(orgQuery?.where).toEqual({ id: ORG.id }); + expect(effectiveContext(orgQuery, orgOptions).isSystem, 'the organization read reached the engine without the system opt-in').toBe(true); + + expect(engine.find).toHaveBeenCalledTimes(1); + const [envObject, envQuery, envOptions] = engine.find.mock.calls[0]; + expect(envObject).toBe('sys_environment'); + expect(envQuery?.where).toEqual({ organization_id: ORG.id }); + expect(effectiveContext(envQuery, envOptions).isSystem, 'the environment read reached the engine without the system opt-in').toBe(true); + }); + + it('allows the change when no active environment references the org', async () => { + const engine = { + findOne: vi.fn(async () => ORG), + find: vi.fn(async () => [{ id: 'e2', status: 'archived' }]), + }; + const update = await slugGuard(engine); + await expect(update('acme-new')).resolves.toBeUndefined(); + }); +}); + +describe('organization slug guard — on an engine that refuses a principal-less, non-system context', () => { + it('still refuses the slug change — the guard is not skipped by the refusal', async () => { + const refuse = (q?: Query, o?: Options) => { + if (isPrincipalLessNonSystem(effectiveContext(q, o))) { + throw Object.assign(new Error('[Security] Access denied: principal-less context'), { + code: 'PERMISSION_DENIED', + status: 403, + }); + } + }; + const engine = { + findOne: vi.fn(async (_object: string, q?: Query, o?: Options) => { + refuse(q, o); + return ORG; + }), + find: vi.fn(async (_object: string, q?: Query, o?: Options) => { + refuse(q, o); + return ENVS; + }), + }; + const update = await slugGuard(engine); + await expectSlugRefusal(update('acme-new')); + }); +}); + +describe('organization slug guard — the catches around the reads are unchanged (pre-existing)', () => { + it('an organization read that throws ends the hook without refusing, as before', async () => { + const engine = { + findOne: vi.fn(async () => { + throw new Error('store unavailable'); + }), + find: vi.fn(async () => ENVS), + }; + const update = await slugGuard(engine); + await expect(update('acme-new')).resolves.toBeUndefined(); + expect(engine.find).not.toHaveBeenCalled(); + }); + + it('an environment read that throws ends the hook without refusing, as before', async () => { + const engine = { + findOne: vi.fn(async () => ORG), + find: vi.fn(async () => { + throw new Error('object sys_environment is not registered'); + }), + }; + const update = await slugGuard(engine); + await expect(update('acme-new')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index edb5160bfe2..97a4838a6d0 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -57,7 +57,7 @@ import { } from '@objectstack/spec'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; -import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; +import { createObjectQLAdapterFactory, withSystemContext, withSystemReadContext } from './objectql-adapter.js'; import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; import { runWithAuthActorScope, setAuthActorResolver } from './auth-actor-attribution.js'; import { @@ -3334,8 +3334,14 @@ export class AuthManager { const orgId = member?.organizationId; if (!newSlug || !orgId) return; - const dataEngine = this.config.dataEngine as any; - if (!dataEngine) return; + // Both reads run as the platform (`withSystemContext`): this hook + // IS the slug guard — the organization id is the `where`, not the + // reader — so neither read reaches the engine with no principal + // and no opt-in (the security middleware's principal-less + // hand-off, ADR-0096). The catches below are unchanged. + const rawEngine = this.config.dataEngine; + if (!rawEngine) return; + const dataEngine = withSystemContext(rawEngine) as any; let currentSlug: string | undefined; try { From f922118bec19fcfcf8fcabeea686e56e765a44fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 00:31:27 +0000 Subject: [PATCH 4/5] docs(changeset): name the organization slug guard among the producers that take the system opt-in Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .changeset/21912-principal-less-producers.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/21912-principal-less-producers.md b/.changeset/21912-principal-less-producers.md index 8aac22bc307..d8cc999baba 100644 --- a/.changeset/21912-principal-less-producers.md +++ b/.changeset/21912-principal-less-producers.md @@ -3,12 +3,13 @@ '@objectstack/runtime': patch --- -Three engine calls that reached the data engine with no principal and no `isSystem` now carry the explicit system opt-in. Each is already authorized by its own door, so nothing it answers changes. +Four producers that reached the data engine with no principal and no `isSystem` now carry the explicit system opt-in. Each is already authorized by its own door, so nothing it answers changes. Clause-②: no - **`@objectstack/plugin-auth` — the platform-admin OAuth client toggle route** (`POST /api/v1/auth/admin/oauth2/toggle-disabled`). Its `sys_oauth_application` read and write go through `withSystemContext`, the wrapper better-auth's adapter already writes those rows through. The platform-admin judge still runs first. The answers (`200`, `404 RESOURCE_NOT_FOUND`, the refusals) and the stored row are unchanged. One log line goes away: the engine's read-only `updated_at` warning on every toggle. The value it warned about was discarded before and the driver still stamps the column. - **`@objectstack/plugin-auth` — `verifyScimBearerToken`.** The credential probe passes `isSystem: true` in the read's trailing options. It runs before any caller is known, and the digest equality is still all it matches. An unknown, inactive or expired bearer is still `null` (`401`). +- **`@objectstack/plugin-auth` — the organization slug guard** (`organizationHooks.beforeUpdateOrganization`). Its `sys_organization` and `sys_environment` reads go through `withSystemContext`. The organization id stays in the `where`. A slug change while an active environment references the organization is still refused (`FORBIDDEN`), and any other change is still allowed. The catches around both reads are unchanged: a read that throws still ends the hook without refusing. - **`@objectstack/runtime` — the dispatcher's environment-membership gate.** The `sys_environment_member` read passes `isSystem: true` in the read's trailing options. The caller's user id stays in the `where`. A member still passes and a non-member is still refused with `403 PROJECT_MEMBERSHIP_REQUIRED`. The catch around the read is unchanged: a read that throws still lets the request through. Why: the security middleware hands a context with no principal and no `isSystem` straight through (ADR-0096). That hand-through is not an authorization. A caller that is the platform acting for itself says so explicitly. ⛔ No new elevation API, no door's authorization moves, and no accept set changes. From 9878b925fc207120aae9966a26929a58d274c268 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 01:53:34 +0000 Subject: [PATCH 5/5] fix(runtime): the membership read carries its system context in the query, not the trailing options Same opt-in, same engine reading (ObjectQL merges the query's context with the trailing one). Spelled inside the query so the existing membership suites, which assert the read's two arguments, stay unchanged. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .changeset/21912-principal-less-producers.md | 2 +- packages/runtime/src/http-dispatcher.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/21912-principal-less-producers.md b/.changeset/21912-principal-less-producers.md index d8cc999baba..9000f83d45f 100644 --- a/.changeset/21912-principal-less-producers.md +++ b/.changeset/21912-principal-less-producers.md @@ -10,6 +10,6 @@ Clause-②: no - **`@objectstack/plugin-auth` — the platform-admin OAuth client toggle route** (`POST /api/v1/auth/admin/oauth2/toggle-disabled`). Its `sys_oauth_application` read and write go through `withSystemContext`, the wrapper better-auth's adapter already writes those rows through. The platform-admin judge still runs first. The answers (`200`, `404 RESOURCE_NOT_FOUND`, the refusals) and the stored row are unchanged. One log line goes away: the engine's read-only `updated_at` warning on every toggle. The value it warned about was discarded before and the driver still stamps the column. - **`@objectstack/plugin-auth` — `verifyScimBearerToken`.** The credential probe passes `isSystem: true` in the read's trailing options. It runs before any caller is known, and the digest equality is still all it matches. An unknown, inactive or expired bearer is still `null` (`401`). - **`@objectstack/plugin-auth` — the organization slug guard** (`organizationHooks.beforeUpdateOrganization`). Its `sys_organization` and `sys_environment` reads go through `withSystemContext`. The organization id stays in the `where`. A slug change while an active environment references the organization is still refused (`FORBIDDEN`), and any other change is still allowed. The catches around both reads are unchanged: a read that throws still ends the hook without refusing. -- **`@objectstack/runtime` — the dispatcher's environment-membership gate.** The `sys_environment_member` read passes `isSystem: true` in the read's trailing options. The caller's user id stays in the `where`. A member still passes and a non-member is still refused with `403 PROJECT_MEMBERSHIP_REQUIRED`. The catch around the read is unchanged: a read that throws still lets the request through. +- **`@objectstack/runtime` — the dispatcher's environment-membership gate.** The `sys_environment_member` read carries `isSystem: true` as its query context. The caller's user id stays in the `where`. A member still passes and a non-member is still refused with `403 PROJECT_MEMBERSHIP_REQUIRED`. The catch around the read is unchanged: a read that throws still lets the request through. Why: the security middleware hands a context with no principal and no `isSystem` straight through (ADR-0096). That hand-through is not an authorization. A caller that is the platform acting for itself says so explicitly. ⛔ No new elevation API, no door's authorization moves, and no accept set changes. diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 5463d903ea5..eaefa74b516 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1469,7 +1469,8 @@ export class HttpDispatcher { let rows = await ql.find('sys_environment_member', { where: { environment_id: environmentId, user_id: userId }, limit: 1, - } as any, { context: { isSystem: true } }); + context: { isSystem: true }, + } as any); if (rows && (rows as any).value) rows = (rows as any).value; const isMember = Array.isArray(rows) && rows.length > 0;