Skip to content

Commit df7fc10

Browse files
os-zhuangclaude
andcommitted
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>
1 parent 3afaeed commit df7fc10

2 files changed

Lines changed: 83 additions & 2 deletions

File tree

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

Lines changed: 55 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';
@@ -279,3 +280,57 @@ describe('createObjectQLAdapter – legacy model name mapping', () => {
279280
expect(mockEngine.insert).toHaveBeenCalledWith('organization', { name: 'Acme' });
280281
});
281282
});
283+
284+
285+
describe('withSystemReadContext – system-scoped reads (org-scope bypass)', () => {
286+
let mockEngine: IDataEngine;
287+
288+
beforeEach(() => {
289+
mockEngine = {
290+
insert: vi.fn().mockResolvedValue({ id: '1' }),
291+
findOne: vi.fn().mockResolvedValue({ id: '1' }),
292+
find: vi.fn().mockResolvedValue([]),
293+
count: vi.fn().mockResolvedValue(0),
294+
update: vi.fn().mockResolvedValue({ id: '1' }),
295+
delete: vi.fn().mockResolvedValue(undefined),
296+
} as unknown as IDataEngine;
297+
});
298+
299+
it('injects context.isSystem into find / findOne / count', async () => {
300+
const e = withSystemReadContext(mockEngine);
301+
await e.find('sys_member', { where: { user_id: 'u1' } } as any);
302+
await e.findOne('sys_organization', { where: { id: 'o1' } } as any);
303+
await e.count('sys_member', { where: { user_id: 'u1' } } as any);
304+
expect(mockEngine.find).toHaveBeenCalledWith('sys_member', expect.objectContaining({ context: { isSystem: true } }));
305+
expect(mockEngine.findOne).toHaveBeenCalledWith('sys_organization', expect.objectContaining({ context: { isSystem: true } }));
306+
expect(mockEngine.count).toHaveBeenCalledWith('sys_member', expect.objectContaining({ context: { isSystem: true } }));
307+
});
308+
309+
it('merges isSystem with a caller-supplied context', async () => {
310+
const e = withSystemReadContext(mockEngine);
311+
await e.find('sys_member', { where: {}, context: { transaction: 'tx1' } } as any);
312+
expect(mockEngine.find).toHaveBeenCalledWith('sys_member', expect.objectContaining({ context: { transaction: 'tx1', isSystem: true } }));
313+
});
314+
315+
it('does NOT alter writes (insert / update / delete pass straight through)', async () => {
316+
const e = withSystemReadContext(mockEngine);
317+
await e.insert('sys_member', { id: 'm1' } as any);
318+
await e.update('sys_member', { id: 'm1' } as any);
319+
await e.delete('sys_member', { where: { id: 'm1' } } as any);
320+
expect(mockEngine.insert).toHaveBeenCalledWith('sys_member', { id: 'm1' });
321+
expect(mockEngine.update).toHaveBeenCalledWith('sys_member', { id: 'm1' });
322+
expect(mockEngine.delete).toHaveBeenCalledWith('sys_member', { where: { id: 'm1' } });
323+
});
324+
});
325+
326+
describe('createObjectQLAdapter – reads bypass control-plane org-scope (regression)', () => {
327+
it('findMany on member runs as a system read so org-scope hooks pass through', async () => {
328+
const mockEngine = {
329+
insert: vi.fn(), findOne: vi.fn(), find: vi.fn().mockResolvedValue([]),
330+
count: vi.fn(), update: vi.fn(), delete: vi.fn(),
331+
} as unknown as IDataEngine;
332+
const adapter = createObjectQLAdapter(mockEngine);
333+
await adapter.findMany({ model: 'account', limit: 10 });
334+
expect(mockEngine.find).toHaveBeenCalledWith('sys_account', expect.objectContaining({ context: { isSystem: true } }));
335+
});
336+
});

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

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

138+
/**
139+
* Wrap a data engine so its READ operations (find / findOne / count) run as
140+
* SYSTEM reads — injecting `context.isSystem: true` (merged; any caller-supplied
141+
* context still wins on other keys). better-auth has already authenticated the
142+
* session and scopes every query by its OWN where-clauses (e.g. member.userId =
143+
* session.user). A deployment's control-plane org-scope read hook, however, keys
144+
* off the CALLER's user id, and these adapter reads carry no caller context — so
145+
* without isSystem that hook filters sys_member / sys_organization reads down to
146+
* zero and `organization.list()` returns no orgs for a real member. Writes pass
147+
* through untouched (org-scope is a read-only hook).
148+
*/
149+
export function withSystemReadContext(engine: IDataEngine): IDataEngine {
150+
const e = engine as any;
151+
const asSystem = (q: any) => ({ ...(q ?? {}), context: { isSystem: true, ...(q?.context ?? {}) } });
152+
return {
153+
insert: (m: string, d: any) => e.insert(m, d),
154+
update: (m: string, d: any) => e.update(m, d),
155+
delete: (m: string, q?: any) => e.delete(m, q),
156+
find: (m: string, q?: any) => e.find(m, asSystem(q)),
157+
findOne: (m: string, q?: any) => e.findOne(m, asSystem(q)),
158+
count: (m: string, q?: any) => e.count(m, asSystem(q)),
159+
} as unknown as IDataEngine;
160+
}
161+
138162
/**
139163
* Create an ObjectQL adapter **factory** for better-auth.
140164
*
@@ -151,7 +175,8 @@ function convertWhere(where: CleanedWhere[]): Record<string, any> {
151175
* @param dataEngine - ObjectQL data engine instance
152176
* @returns better-auth AdapterFactory
153177
*/
154-
export function createObjectQLAdapterFactory(dataEngine: IDataEngine) {
178+
export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
179+
const dataEngine = withSystemReadContext(rawDataEngine);
155180
return createAdapterFactory({
156181
config: {
157182
adapterId: 'objectql',
@@ -281,7 +306,8 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) {
281306
* @param dataEngine - ObjectQL data engine instance
282307
* @returns better-auth CustomAdapter (raw, without factory wrapping)
283308
*/
284-
export function createObjectQLAdapter(dataEngine: IDataEngine) {
309+
export function createObjectQLAdapter(rawDataEngine: IDataEngine) {
310+
const dataEngine = withSystemReadContext(rawDataEngine);
285311
return {
286312
create: async <T extends Record<string, any>>({ model, data, select: _select }: { model: string; data: T; select?: string[] }): Promise<T> => {
287313
const objectName = resolveProtocolName(model);

0 commit comments

Comments
 (0)