Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,21 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
*/
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<void>;

/**
* Base path for auth routes
* Forwarded to better-auth's basePath option so it can match incoming
Expand Down Expand Up @@ -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;
Expand Down
54 changes: 54 additions & 0 deletions packages/plugins/plugin-auth/src/objectql-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
createObjectQLAdapter,
createObjectQLAdapterFactory,
withSystemReadContext,
AUTH_MODEL_TO_PROTOCOL,
resolveProtocolName,
} from './objectql-adapter';
Expand Down Expand Up @@ -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 } }));
});
});
30 changes: 28 additions & 2 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,30 @@ function convertWhere(where: CleanedWhere[]): Record<string, any> {
// 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.
*
Expand All @@ -160,7 +184,8 @@ function convertWhere(where: CleanedWhere[]): Record<string, any> {
* @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
Expand Down Expand Up @@ -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 <T extends Record<string, any>>({ model, data, select: _select }: { model: string; data: T; select?: string[] }): Promise<T> => {
const objectName = resolveProtocolName(model);
Expand Down