Skip to content

Commit deb7e7e

Browse files
os-zhuangclaude
andauthored
fix(plugin-auth): run better-auth adapter writes as system context (#3164) (#3177)
The better-auth ObjectQL adapter wrapped the engine so its READS carried `isSystem` (to bypass the control-plane org-scope read hook) but its WRITES passed through with no context. The static-`readonly` UPDATE strip (#2948) runs on any non-system update, and since the adapter carries no caller context `!ctx?.isSystem` was true — so the strip SILENTLY DROPPED better-auth's own writes to readonly `sys_user` columns: `email` (change-email), `banned` / `ban_reason` / `ban_expires` (admin ban). Those operations returned success but never persisted. Rename `withSystemReadContext` → `withSystemContext` (deprecated alias kept one release) and inject `isSystem` on insert/update/delete as well as reads. Correct because these are the identity authority's own writes: user-context writes to `managedBy: 'better-auth'` tables are already rejected upstream by the ADR-0092 identity write guard, so this path only ever carries better-auth's internal writes. Found while implementing #3043 (the INSERT-side readonly strip) — this is its UPDATE-side dual. Also corrects content/docs/data-modeling/fields.mdx, which still said "insert may still seed it" (stale after #3043 / PR #3162): a readonly column is now server-enforced on INSERT too, and seeding one at create requires a system context. Verified: plugin-auth suite 460 passed (adapter writes now assert isSystem); dogfood auth/identity/permission regression green (sign-in, org/member reads, permission seeding, two-doors provenance). Claude-Session: https://claude.ai/code/session_01P5fRY4ctobCeFpBba1oGrz Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1cd4264 commit deb7e7e

4 files changed

Lines changed: 80 additions & 28 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): run better-auth adapter WRITES as system context so #2948 doesn't strip readonly identity columns (#3164)
6+
7+
The better-auth ObjectQL adapter wrapped the engine so its READS carried
8+
`isSystem` (to bypass the control-plane org-scope read hook), but its WRITES
9+
passed through with no context. The static-`readonly` UPDATE strip (#2948) runs
10+
on any non-system update — and since the adapter carries no caller context,
11+
`!ctx?.isSystem` was `true`, so the strip silently DROPPED better-auth's own
12+
writes to readonly `sys_user` columns: `email` (change-email), `banned` /
13+
`ban_reason` / `ban_expires` (admin ban). Those operations returned success but
14+
never persisted.
15+
16+
`withSystemReadContext` is renamed to `withSystemContext` (a deprecated alias is
17+
kept for one release) and now injects `isSystem` on `insert` / `update` /
18+
`delete` as well as reads. This is correct because these are the identity
19+
authority's own writes — user-context writes to `managedBy: 'better-auth'` tables
20+
are already rejected upstream by the ADR-0092 identity write guard, so the
21+
adapter path only ever carries better-auth's internal writes.
22+
23+
Found while implementing #3043 (the INSERT-side readonly strip). This is its
24+
UPDATE-side dual: #3043 relocated the insert strip to the external ingress
25+
precisely because internal writers (this adapter included) don't declare
26+
`isSystem`; the pre-existing engine-level UPDATE strip has no such relocation, so
27+
the adapter had to declare its writes system.

content/docs/data-modeling/fields.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ These properties are available on all field types:
284284
| `description` | `string` || Developer documentation |
285285
| `inlineHelpText` | `string` || Help text shown in UI |
286286
| `hidden` | `boolean` | `false` | Hide from default views |
287-
| `readonly` | `boolean` | `false` | Prevent editing — hidden from create/edit forms AND server-enforced: non-system `UPDATE` writes to the field are silently dropped (insert may still seed it) |
287+
| `readonly` | `boolean` | `false` | Prevent editing — hidden from create/edit forms AND server-enforced on both write paths: a non-system write to the field is silently dropped on `UPDATE` (in the engine) and on `INSERT` through the data API (REST/GraphQL/MCP/import, at the DataProtocol ingress). A stripped field still falls back to its `defaultValue`; **seeding a `readonly` column at create requires a system context** (import/migration/programmatic seed). Platform (`sys_`/`managedBy`) objects are governed by their own guards instead. |
288288
| `sortable` | `boolean` | `true` | Allow sorting by this field |
289289
| `group` | `string` || Group name for organizing in forms (e.g. `'billing'`) |
290290

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
44
import {
55
createObjectQLAdapter,
66
createObjectQLAdapterFactory,
7-
withSystemReadContext,
7+
withSystemContext,
88
AUTH_MODEL_TO_PROTOCOL,
99
resolveProtocolName,
1010
} from './objectql-adapter';
@@ -226,7 +226,9 @@ describe('createObjectQLAdapter – legacy model name mapping', () => {
226226
it('create: should call dataEngine.insert with sys_ protocol name', async () => {
227227
const adapter = createObjectQLAdapter(mockEngine);
228228
await adapter.create({ model: 'user', data: { email: 'a@b.com' } });
229-
expect(mockEngine.insert).toHaveBeenCalledWith('sys_user', { email: 'a@b.com' });
229+
// #3164 — adapter writes run as system so the #2948 readonly strip doesn't
230+
// drop better-auth's own writes to readonly identity columns.
231+
expect(mockEngine.insert).toHaveBeenCalledWith('sys_user', { email: 'a@b.com' }, { context: { isSystem: true } });
230232
});
231233

232234
it('findOne: should call dataEngine.findOne with sys_ protocol name', async () => {
@@ -262,7 +264,8 @@ describe('createObjectQLAdapter – legacy model name mapping', () => {
262264
update: { name: 'New' },
263265
});
264266
expect(mockEngine.findOne).toHaveBeenCalledWith('sys_user', expect.anything());
265-
expect(mockEngine.update).toHaveBeenCalledWith('sys_user', expect.objectContaining({ name: 'New', id: '1' }));
267+
// #3164 — the update carries system context (readonly identity writes survive).
268+
expect(mockEngine.update).toHaveBeenCalledWith('sys_user', expect.objectContaining({ name: 'New', id: '1' }), { context: { isSystem: true } });
266269
});
267270

268271
it('delete: should call dataEngine with sys_ protocol name', async () => {
@@ -278,7 +281,7 @@ describe('createObjectQLAdapter – legacy model name mapping', () => {
278281
it('should pass through unknown model names unchanged', async () => {
279282
const adapter = createObjectQLAdapter(mockEngine);
280283
await adapter.create({ model: 'organization', data: { name: 'Acme' } });
281-
expect(mockEngine.insert).toHaveBeenCalledWith('organization', { name: 'Acme' });
284+
expect(mockEngine.insert).toHaveBeenCalledWith('organization', { name: 'Acme' }, { context: { isSystem: true } });
282285
});
283286
});
284287

@@ -325,7 +328,7 @@ describe('createObjectQLAdapterFactory – schema-less plugin bridging (@better-
325328
});
326329
});
327330

328-
describe('withSystemReadContext – system-scoped reads (org-scope bypass)', () => {
331+
describe('withSystemContext – system-scoped reads AND writes', () => {
329332
let mockEngine: IDataEngine;
330333

331334
beforeEach(() => {
@@ -340,7 +343,7 @@ describe('withSystemReadContext – system-scoped reads (org-scope bypass)', ()
340343
});
341344

342345
it('injects context.isSystem into find / findOne / count', async () => {
343-
const e = withSystemReadContext(mockEngine);
346+
const e = withSystemContext(mockEngine);
344347
await e.find('sys_member', { where: { user_id: 'u1' } } as any);
345348
await e.findOne('sys_organization', { where: { id: 'o1' } } as any);
346349
await e.count('sys_member', { where: { user_id: 'u1' } } as any);
@@ -350,19 +353,21 @@ describe('withSystemReadContext – system-scoped reads (org-scope bypass)', ()
350353
});
351354

352355
it('merges isSystem with a caller-supplied context', async () => {
353-
const e = withSystemReadContext(mockEngine);
356+
const e = withSystemContext(mockEngine);
354357
await e.find('sys_member', { where: {}, context: { transaction: 'tx1' } } as any);
355358
expect(mockEngine.find).toHaveBeenCalledWith('sys_member', expect.objectContaining({ context: { transaction: 'tx1', isSystem: true } }));
356359
});
357360

358-
it('does NOT alter writes (insert / update / delete pass straight through)', async () => {
359-
const e = withSystemReadContext(mockEngine);
361+
it('runs WRITES as system too — insert/update carry isSystem, delete merges it into the query (#3164)', async () => {
362+
const e = withSystemContext(mockEngine);
360363
await e.insert('sys_member', { id: 'm1' } as any);
361364
await e.update('sys_member', { id: 'm1' } as any);
362365
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+
// Without this the #2948 readonly-UPDATE strip silently drops better-auth's
367+
// own writes to readonly identity columns (sys_user.email, banned, …).
368+
expect(mockEngine.insert).toHaveBeenCalledWith('sys_member', { id: 'm1' }, { context: { isSystem: true } });
369+
expect(mockEngine.update).toHaveBeenCalledWith('sys_member', { id: 'm1' }, { context: { isSystem: true } });
370+
expect(mockEngine.delete).toHaveBeenCalledWith('sys_member', { where: { id: 'm1' }, context: { isSystem: true } });
366371
});
367372
});
368373

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

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -145,29 +145,49 @@ function convertWhere(where: CleanedWhere[]): Record<string, any> {
145145
// ---------------------------------------------------------------------------
146146

147147
/**
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).
148+
* Wrap a data engine so its operations run as SYSTEM against the identity
149+
* tables — injecting `context.isSystem: true` (merged; any caller-supplied
150+
* context still wins on other keys). better-auth is the identity AUTHORITY: it
151+
* has already authenticated the session and scopes every query/write by its OWN
152+
* where-clauses (e.g. member.userId = session.user).
153+
*
154+
* READS run as system so a deployment's control-plane org-scope read hook —
155+
* which keys off the CALLER's user id — doesn't filter these caller-context-less
156+
* adapter reads of sys_member / sys_organization down to zero (which would make
157+
* `organization.list()` return no orgs for a real member).
158+
*
159+
* WRITES (`update` / `insert` / `delete`) also run as system (#3164). Several
160+
* identity columns are declared `readonly` on their schema — `sys_user.email`
161+
* (change-email), `banned` / `ban_reason` / `ban_expires` (admin ban) — and the
162+
* static-`readonly` UPDATE strip (#2948) runs on any NON-system update. Since
163+
* the adapter carries no caller context, `!ctx?.isSystem` was TRUE and the strip
164+
* silently DROPPED better-auth's own writes to those columns (change-email /
165+
* ban would return success but never persist). Marking the adapter's writes
166+
* system exempts them — correct, because these ARE the identity authority's own
167+
* writes; user-context writes to `managedBy: 'better-auth'` tables are already
168+
* rejected upstream by the identity write guard (ADR-0092 D2), so this path only
169+
* ever carries better-auth's internal writes.
157170
*/
158-
export function withSystemReadContext(engine: IDataEngine): IDataEngine {
171+
export function withSystemContext(engine: IDataEngine): IDataEngine {
159172
const e = engine as any;
160173
const asSystem = (q: any) => ({ ...(q ?? {}), context: { isSystem: true, ...(q?.context ?? {}) } });
161174
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),
175+
insert: (m: string, d: any, o?: any) => e.insert(m, d, asSystem(o)),
176+
update: (m: string, d: any, o?: any) => e.update(m, d, asSystem(o)),
177+
delete: (m: string, q?: any) => e.delete(m, asSystem(q)),
165178
find: (m: string, q?: any) => e.find(m, asSystem(q)),
166179
findOne: (m: string, q?: any) => e.findOne(m, asSystem(q)),
167180
count: (m: string, q?: any) => e.count(m, asSystem(q)),
168181
} as unknown as IDataEngine;
169182
}
170183

184+
/**
185+
* @deprecated Renamed to {@link withSystemContext} (#3164) now that writes are
186+
* system-scoped too, not only reads. Kept as an alias for one release so
187+
* external callers / in-flight imports don't break.
188+
*/
189+
export const withSystemReadContext = withSystemContext;
190+
171191
/**
172192
* Create an ObjectQL adapter **factory** for better-auth.
173193
*
@@ -185,7 +205,7 @@ export function withSystemReadContext(engine: IDataEngine): IDataEngine {
185205
* @returns better-auth AdapterFactory
186206
*/
187207
export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
188-
const dataEngine = withSystemReadContext(rawDataEngine);
208+
const dataEngine = withSystemContext(rawDataEngine);
189209
// Field-name bridging for better-auth plugins that expose NO `schema` option
190210
// (e.g. @better-auth/sso): when a model is remapped via AUTH_MODEL_TO_PROTOCOL,
191211
// its camelCase model fields are also converted to snake_case columns on the
@@ -406,7 +426,7 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
406426
* @returns better-auth CustomAdapter (raw, without factory wrapping)
407427
*/
408428
export function createObjectQLAdapter(rawDataEngine: IDataEngine) {
409-
const dataEngine = withSystemReadContext(rawDataEngine);
429+
const dataEngine = withSystemContext(rawDataEngine);
410430
return {
411431
create: async <T extends Record<string, any>>({ model, data, select: _select }: { model: string; data: T; select?: string[] }): Promise<T> => {
412432
const objectName = resolveProtocolName(model);

0 commit comments

Comments
 (0)