From df7fc10cf7d0abe5af87e193e851549b467e6306 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 17:44:55 +0800 Subject: [PATCH 1/2] fix(auth): run better-auth objectql adapter reads as system reads The better-auth -> ObjectQL adapter issued find/findOne/count without a caller context. On the cloud control plane, the org-scope read hook keys off the CALLER's user id to scope sys_member / sys_organization, so context-less adapter reads were filtered down to zero and organization.list() returned no orgs for a user who is a real member of one or more orgs (multi-org switching was unusable). Wrap the adapter's data engine in withSystemReadContext, marking every read context.isSystem:true so such hooks pass through. better-auth has already authenticated the session and scopes results by its own where-clauses (e.g. member.userId = session.user), so system reads are correct here. Writes are untouched (org-scope is a read-only hook). Applied to both createObjectQLAdapterFactory (production) and the legacy createObjectQLAdapter. Verified end-to-end against the cloud control plane: organization.list() now returns all of a user's orgs. Co-Authored-By: Claude Opus 4.8 --- .../plugin-auth/src/objectql-adapter.test.ts | 55 +++++++++++++++++++ .../plugin-auth/src/objectql-adapter.ts | 30 +++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts index a8a5cb9ba1..6b1e4b572a 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createObjectQLAdapter, createObjectQLAdapterFactory, + withSystemReadContext, AUTH_MODEL_TO_PROTOCOL, resolveProtocolName, } from './objectql-adapter'; @@ -279,3 +280,57 @@ describe('createObjectQLAdapter – legacy model name mapping', () => { expect(mockEngine.insert).toHaveBeenCalledWith('organization', { name: 'Acme' }); }); }); + + +describe('withSystemReadContext – system-scoped reads (org-scope bypass)', () => { + let mockEngine: IDataEngine; + + beforeEach(() => { + mockEngine = { + insert: vi.fn().mockResolvedValue({ id: '1' }), + findOne: vi.fn().mockResolvedValue({ id: '1' }), + find: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + update: vi.fn().mockResolvedValue({ id: '1' }), + delete: vi.fn().mockResolvedValue(undefined), + } as unknown as IDataEngine; + }); + + it('injects context.isSystem into find / findOne / count', async () => { + const e = withSystemReadContext(mockEngine); + await e.find('sys_member', { where: { user_id: 'u1' } } as any); + await e.findOne('sys_organization', { where: { id: 'o1' } } as any); + await e.count('sys_member', { where: { user_id: 'u1' } } as any); + expect(mockEngine.find).toHaveBeenCalledWith('sys_member', expect.objectContaining({ context: { isSystem: true } })); + expect(mockEngine.findOne).toHaveBeenCalledWith('sys_organization', expect.objectContaining({ context: { isSystem: true } })); + expect(mockEngine.count).toHaveBeenCalledWith('sys_member', expect.objectContaining({ context: { isSystem: true } })); + }); + + it('merges isSystem with a caller-supplied context', async () => { + const e = withSystemReadContext(mockEngine); + await e.find('sys_member', { where: {}, context: { transaction: 'tx1' } } as any); + expect(mockEngine.find).toHaveBeenCalledWith('sys_member', expect.objectContaining({ context: { transaction: 'tx1', isSystem: true } })); + }); + + it('does NOT alter writes (insert / update / delete pass straight through)', async () => { + const e = withSystemReadContext(mockEngine); + await e.insert('sys_member', { id: 'm1' } as any); + await e.update('sys_member', { id: 'm1' } as any); + await e.delete('sys_member', { where: { id: 'm1' } } as any); + expect(mockEngine.insert).toHaveBeenCalledWith('sys_member', { id: 'm1' }); + expect(mockEngine.update).toHaveBeenCalledWith('sys_member', { id: 'm1' }); + expect(mockEngine.delete).toHaveBeenCalledWith('sys_member', { where: { id: 'm1' } }); + }); +}); + +describe('createObjectQLAdapter – reads bypass control-plane org-scope (regression)', () => { + it('findMany on member runs as a system read so org-scope hooks pass through', async () => { + const mockEngine = { + insert: vi.fn(), findOne: vi.fn(), find: vi.fn().mockResolvedValue([]), + count: vi.fn(), update: vi.fn(), delete: vi.fn(), + } as unknown as IDataEngine; + const adapter = createObjectQLAdapter(mockEngine); + await adapter.findMany({ model: 'account', limit: 10 }); + expect(mockEngine.find).toHaveBeenCalledWith('sys_account', expect.objectContaining({ context: { isSystem: true } })); + }); +}); diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index 04eaf28ebe..34c4cbc0ca 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -135,6 +135,30 @@ function convertWhere(where: CleanedWhere[]): Record { // Adapter factory // --------------------------------------------------------------------------- +/** + * Wrap a data engine so its READ operations (find / findOne / count) run as + * SYSTEM reads — injecting `context.isSystem: true` (merged; any caller-supplied + * context still wins on other keys). better-auth has already authenticated the + * session and scopes every query by its OWN where-clauses (e.g. member.userId = + * session.user). A deployment's control-plane org-scope read hook, however, keys + * off the CALLER's user id, and these adapter reads carry no caller context — so + * without isSystem that hook filters sys_member / sys_organization reads down to + * zero and `organization.list()` returns no orgs for a real member. Writes pass + * through untouched (org-scope is a read-only hook). + */ +export function withSystemReadContext(engine: IDataEngine): IDataEngine { + const e = engine as any; + const asSystem = (q: any) => ({ ...(q ?? {}), context: { isSystem: true, ...(q?.context ?? {}) } }); + return { + insert: (m: string, d: any) => e.insert(m, d), + update: (m: string, d: any) => e.update(m, d), + delete: (m: string, q?: any) => e.delete(m, q), + find: (m: string, q?: any) => e.find(m, asSystem(q)), + findOne: (m: string, q?: any) => e.findOne(m, asSystem(q)), + count: (m: string, q?: any) => e.count(m, asSystem(q)), + } as unknown as IDataEngine; +} + /** * Create an ObjectQL adapter **factory** for better-auth. * @@ -151,7 +175,8 @@ function convertWhere(where: CleanedWhere[]): Record { * @param dataEngine - ObjectQL data engine instance * @returns better-auth AdapterFactory */ -export function createObjectQLAdapterFactory(dataEngine: IDataEngine) { +export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { + const dataEngine = withSystemReadContext(rawDataEngine); return createAdapterFactory({ config: { adapterId: 'objectql', @@ -281,7 +306,8 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) { * @param dataEngine - ObjectQL data engine instance * @returns better-auth CustomAdapter (raw, without factory wrapping) */ -export function createObjectQLAdapter(dataEngine: IDataEngine) { +export function createObjectQLAdapter(rawDataEngine: IDataEngine) { + const dataEngine = withSystemReadContext(rawDataEngine); return { create: async >({ model, data, select: _select }: { model: string; data: T; select?: string[] }): Promise => { const objectName = resolveProtocolName(model); From 457ee8fb97da0ba961a7073de99033e85c8b4b03 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 18:33:50 +0800 Subject: [PATCH 2/2] feat(auth): add onOrganizationCreated seam for host org-create side effects better-auth's organization-plugin models (organization/member) do NOT fire core databaseHooks, so a host stack had no server-side seam to run side effects when a user creates a 2nd+ organization. Add an optional onOrganizationCreated config callback, invoked from the org plugin's afterCreateOrganization hook with { organizationId, userId, name, slug }. Failure-isolated (org creation is never rolled back). The cloud control plane uses this to uphold its born-with-environment invariant: every organization, not just the signup/personal org, is born with its production environment. Without it a user-created org landed env-less and unusable. Co-Authored-By: Claude Opus 4.8 --- .../plugins/plugin-auth/src/auth-manager.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index f17699a42e..ef270d8103 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -165,6 +165,21 @@ export interface AuthManagerOptions extends Partial { */ dataEngine?: IDataEngine; + /** + * Optional callback invoked AFTER an organization is created via better-auth's + * `createOrganization` (the org-plugin `afterCreateOrganization` hook). Lets a + * host stack run org-creation side effects that core `databaseHooks` can't — + * better-auth's org-plugin models (`organization`/`member`) do NOT fire those. + * The cloud control plane uses it to provision an org's born-with production + * environment. Failure-isolated: org creation is never rolled back. + */ + onOrganizationCreated?: (data: { + organizationId: string; + userId?: string; + name?: string; + slug?: string; + }) => void | Promise; + /** * Base path for auth routes * Forwarded to better-auth's basePath option so it can match incoming @@ -741,6 +756,25 @@ export class AuthManager { }); } }, + // Run host-provided org-creation side effects (e.g. the cloud control + // plane provisions the org's born-with production environment). The + // org-plugin's models don't fire core databaseHooks, so this is the + // only server-side seam for "every org is born with its prod env". + // Failure-isolated: org creation must not roll back on a side-effect miss. + afterCreateOrganization: async ({ organization, member, user }: any) => { + const cb = this.config.onOrganizationCreated; + if (typeof cb !== 'function') return; + try { + await cb({ + organizationId: organization?.id, + userId: user?.id ?? member?.userId, + name: organization?.name, + slug: organization?.slug, + }); + } catch (err: any) { + console.warn('[auth] onOrganizationCreated callback failed:', err?.message ?? String(err)); + } + }, beforeUpdateOrganization: async ({ organization, member }: any) => { const newSlug = organization?.slug; const orgId = member?.organizationId;