Skip to content

Commit 0885fde

Browse files
os-zhuangclaude
andauthored
feat(auth): multi-org enablement — system-scoped adapter reads + onOrganizationCreated hook (#2323)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 21d4f89 commit 0885fde

3 files changed

Lines changed: 116 additions & 2 deletions

File tree

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,21 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
165165
*/
166166
dataEngine?: IDataEngine;
167167

168+
/**
169+
* Optional callback invoked AFTER an organization is created via better-auth's
170+
* `createOrganization` (the org-plugin `afterCreateOrganization` hook). Lets a
171+
* host stack run org-creation side effects that core `databaseHooks` can't —
172+
* better-auth's org-plugin models (`organization`/`member`) do NOT fire those.
173+
* The cloud control plane uses it to provision an org's born-with production
174+
* environment. Failure-isolated: org creation is never rolled back.
175+
*/
176+
onOrganizationCreated?: (data: {
177+
organizationId: string;
178+
userId?: string;
179+
name?: string;
180+
slug?: string;
181+
}) => void | Promise<void>;
182+
168183
/**
169184
* Base path for auth routes
170185
* Forwarded to better-auth's basePath option so it can match incoming
@@ -744,6 +759,25 @@ export class AuthManager {
744759
});
745760
}
746761
},
762+
// Run host-provided org-creation side effects (e.g. the cloud control
763+
// plane provisions the org's born-with production environment). The
764+
// org-plugin's models don't fire core databaseHooks, so this is the
765+
// only server-side seam for "every org is born with its prod env".
766+
// Failure-isolated: org creation must not roll back on a side-effect miss.
767+
afterCreateOrganization: async ({ organization, member, user }: any) => {
768+
const cb = this.config.onOrganizationCreated;
769+
if (typeof cb !== 'function') return;
770+
try {
771+
await cb({
772+
organizationId: organization?.id,
773+
userId: user?.id ?? member?.userId,
774+
name: organization?.name,
775+
slug: organization?.slug,
776+
});
777+
} catch (err: any) {
778+
console.warn('[auth] onOrganizationCreated callback failed:', err?.message ?? String(err));
779+
}
780+
},
747781
beforeUpdateOrganization: async ({ organization, member }: any) => {
748782
const newSlug = organization?.slug;
749783
const orgId = member?.organizationId;

packages/plugins/plugin-auth/src/objectql-adapter.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
44
import {
55
createObjectQLAdapter,
66
createObjectQLAdapterFactory,
7+
withSystemReadContext,
78
AUTH_MODEL_TO_PROTOCOL,
89
resolveProtocolName,
910
} from './objectql-adapter';
@@ -323,3 +324,56 @@ describe('createObjectQLAdapterFactory – schema-less plugin bridging (@better-
323324
expect(row).not.toHaveProperty('oidc_config');
324325
});
325326
});
327+
328+
describe('withSystemReadContext – system-scoped reads (org-scope bypass)', () => {
329+
let mockEngine: IDataEngine;
330+
331+
beforeEach(() => {
332+
mockEngine = {
333+
insert: vi.fn().mockResolvedValue({ id: '1' }),
334+
findOne: vi.fn().mockResolvedValue({ id: '1' }),
335+
find: vi.fn().mockResolvedValue([]),
336+
count: vi.fn().mockResolvedValue(0),
337+
update: vi.fn().mockResolvedValue({ id: '1' }),
338+
delete: vi.fn().mockResolvedValue(undefined),
339+
} as unknown as IDataEngine;
340+
});
341+
342+
it('injects context.isSystem into find / findOne / count', async () => {
343+
const e = withSystemReadContext(mockEngine);
344+
await e.find('sys_member', { where: { user_id: 'u1' } } as any);
345+
await e.findOne('sys_organization', { where: { id: 'o1' } } as any);
346+
await e.count('sys_member', { where: { user_id: 'u1' } } as any);
347+
expect(mockEngine.find).toHaveBeenCalledWith('sys_member', expect.objectContaining({ context: { isSystem: true } }));
348+
expect(mockEngine.findOne).toHaveBeenCalledWith('sys_organization', expect.objectContaining({ context: { isSystem: true } }));
349+
expect(mockEngine.count).toHaveBeenCalledWith('sys_member', expect.objectContaining({ context: { isSystem: true } }));
350+
});
351+
352+
it('merges isSystem with a caller-supplied context', async () => {
353+
const e = withSystemReadContext(mockEngine);
354+
await e.find('sys_member', { where: {}, context: { transaction: 'tx1' } } as any);
355+
expect(mockEngine.find).toHaveBeenCalledWith('sys_member', expect.objectContaining({ context: { transaction: 'tx1', isSystem: true } }));
356+
});
357+
358+
it('does NOT alter writes (insert / update / delete pass straight through)', async () => {
359+
const e = withSystemReadContext(mockEngine);
360+
await e.insert('sys_member', { id: 'm1' } as any);
361+
await e.update('sys_member', { id: 'm1' } as any);
362+
await e.delete('sys_member', { where: { id: 'm1' } } as any);
363+
expect(mockEngine.insert).toHaveBeenCalledWith('sys_member', { id: 'm1' });
364+
expect(mockEngine.update).toHaveBeenCalledWith('sys_member', { id: 'm1' });
365+
expect(mockEngine.delete).toHaveBeenCalledWith('sys_member', { where: { id: 'm1' } });
366+
});
367+
});
368+
369+
describe('createObjectQLAdapter – reads bypass control-plane org-scope (regression)', () => {
370+
it('findMany on member runs as a system read so org-scope hooks pass through', async () => {
371+
const mockEngine = {
372+
insert: vi.fn(), findOne: vi.fn(), find: vi.fn().mockResolvedValue([]),
373+
count: vi.fn(), update: vi.fn(), delete: vi.fn(),
374+
} as unknown as IDataEngine;
375+
const adapter = createObjectQLAdapter(mockEngine);
376+
await adapter.findMany({ model: 'account', limit: 10 });
377+
expect(mockEngine.find).toHaveBeenCalledWith('sys_account', expect.objectContaining({ context: { isSystem: true } }));
378+
});
379+
});

packages/plugins/plugin-auth/src/objectql-adapter.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,30 @@ function convertWhere(where: CleanedWhere[]): Record<string, any> {
144144
// Adapter factory
145145
// ---------------------------------------------------------------------------
146146

147+
/**
148+
* Wrap a data engine so its READ operations (find / findOne / count) run as
149+
* SYSTEM reads — injecting `context.isSystem: true` (merged; any caller-supplied
150+
* context still wins on other keys). better-auth has already authenticated the
151+
* session and scopes every query by its OWN where-clauses (e.g. member.userId =
152+
* session.user). A deployment's control-plane org-scope read hook, however, keys
153+
* off the CALLER's user id, and these adapter reads carry no caller context — so
154+
* without isSystem that hook filters sys_member / sys_organization reads down to
155+
* zero and `organization.list()` returns no orgs for a real member. Writes pass
156+
* through untouched (org-scope is a read-only hook).
157+
*/
158+
export function withSystemReadContext(engine: IDataEngine): IDataEngine {
159+
const e = engine as any;
160+
const asSystem = (q: any) => ({ ...(q ?? {}), context: { isSystem: true, ...(q?.context ?? {}) } });
161+
return {
162+
insert: (m: string, d: any) => e.insert(m, d),
163+
update: (m: string, d: any) => e.update(m, d),
164+
delete: (m: string, q?: any) => e.delete(m, q),
165+
find: (m: string, q?: any) => e.find(m, asSystem(q)),
166+
findOne: (m: string, q?: any) => e.findOne(m, asSystem(q)),
167+
count: (m: string, q?: any) => e.count(m, asSystem(q)),
168+
} as unknown as IDataEngine;
169+
}
170+
147171
/**
148172
* Create an ObjectQL adapter **factory** for better-auth.
149173
*
@@ -160,7 +184,8 @@ function convertWhere(where: CleanedWhere[]): Record<string, any> {
160184
* @param dataEngine - ObjectQL data engine instance
161185
* @returns better-auth AdapterFactory
162186
*/
163-
export function createObjectQLAdapterFactory(dataEngine: IDataEngine) {
187+
export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
188+
const dataEngine = withSystemReadContext(rawDataEngine);
164189
// Field-name bridging for better-auth plugins that expose NO `schema` option
165190
// (e.g. @better-auth/sso): when a model is remapped via AUTH_MODEL_TO_PROTOCOL,
166191
// its camelCase model fields are also converted to snake_case columns on the
@@ -331,7 +356,8 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) {
331356
* @param dataEngine - ObjectQL data engine instance
332357
* @returns better-auth CustomAdapter (raw, without factory wrapping)
333358
*/
334-
export function createObjectQLAdapter(dataEngine: IDataEngine) {
359+
export function createObjectQLAdapter(rawDataEngine: IDataEngine) {
360+
const dataEngine = withSystemReadContext(rawDataEngine);
335361
return {
336362
create: async <T extends Record<string, any>>({ model, data, select: _select }: { model: string; data: T; select?: string[] }): Promise<T> => {
337363
const objectName = resolveProtocolName(model);

0 commit comments

Comments
 (0)