diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index d9d42eac8f..30f056cfc4 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 @@ -744,6 +759,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; diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts index 9633e71c04..571e4e1ae7 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'; @@ -323,3 +324,56 @@ describe('createObjectQLAdapterFactory – schema-less plugin bridging (@better- expect(row).not.toHaveProperty('oidc_config'); }); }); + +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 50f02f2aac..d03aa64f0e 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -144,6 +144,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. * @@ -160,7 +184,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); // Field-name bridging for better-auth plugins that expose NO `schema` option // (e.g. @better-auth/sso): when a model is remapped via AUTH_MODEL_TO_PROTOCOL, // its camelCase model fields are also converted to snake_case columns on the @@ -331,7 +356,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);