Skip to content

Commit 865ccb9

Browse files
os-zhuangclaude
andauthored
fix(identity): reconcile managedBy:'better-auth' apiMethods with the write guard (#1591) (#3213)
* fix(identity): reconcile managedBy:'better-auth' apiMethods with the write guard (#1591) Phase 1 — tighten `apiMethods` on better-auth-managed identity objects. `managedBy: 'better-auth'` promises generic CRUD is suppressed (writes flow through better-auth's endpoints; the plugin-auth identity write guard rejects direct user-context create/update/delete), yet these schemas still advertised the write verbs in `enable.apiMethods`. That made the HTTP exposure gate admit the request and let it 403 at the engine instead of answering a clean 405, and the metadata contradicted its own doc comments. Each better-auth object is now read-only (`get`/`list`), except `sys_user`, which keeps `update` — the one generic write opened on an identity table (ADR-0092 D4), server-side clamped to the profile-field whitelist ({name, image}) and declared via `userActions.edit`. Phase 2 — registration-time consistency backstop. `reconcileManagedApiMethods` runs in `SchemaRegistry.registerObject` and strips any generic write verb a better-auth object advertises but does not grant (per `resolveCrudAffordances`: the managedBy bucket default plus `userActions` overrides), with a warning. Reads are never touched; non-better-auth objects are untouched. This makes the contradiction impossible to reintroduce — even if a schema re-adds a write verb, the effective exposed set is corrected at registration. Generalizing to other `managedBy` buckets (`externallyManaged`/`writeVia`) stays with #1878. Proven: registry unit tests for the derivation; the identity-create dogfood suite updated to assert `POST /data/sys_team` now returns 405 (OBJECT_API_METHOD_NOT_ALLOWED) at the exposure gate, before the engine's 403 backstop — system-context inserts still succeed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwY68hKVysP98BX7i5eUoE * test(objectql): drop unused RESERVED_NAMESPACES import (CodeQL) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwY68hKVysP98BX7i5eUoE --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3ad3dd5 commit 865ccb9

15 files changed

Lines changed: 221 additions & 29 deletions

packages/objectql/src/registry.test.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { describe, it, expect, beforeEach } from 'vitest';
2-
import { SchemaRegistry, applySystemFields, computeFQN, parseFQN, RESERVED_NAMESPACES } from './registry';
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, computeFQN, parseFQN } from './registry';
33

44
describe('SchemaRegistry', () => {
55
let registry: SchemaRegistry;
@@ -721,3 +721,75 @@ describe('applySystemFields', () => {
721721
expect(stored.fields.updated_by).toMatchObject({ system: true, readonly: true });
722722
});
723723
});
724+
725+
// ==========================================
726+
// reconcileManagedApiMethods — #1591 / ADR-0092
727+
// Registration-time consistency: a better-auth-managed object may not
728+
// advertise generic write verbs it doesn't grant.
729+
// ==========================================
730+
describe('reconcileManagedApiMethods', () => {
731+
const managed = (extra: any = {}): any => ({
732+
name: 'sys_thing',
733+
managedBy: 'better-auth',
734+
enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update', 'delete'] },
735+
...extra,
736+
});
737+
738+
it('strips create/update/delete from a better-auth object with no write affordances', () => {
739+
const warn = vi.fn();
740+
const out = reconcileManagedApiMethods(managed(), { warn });
741+
expect(out).not.toBe(managed()); // new object
742+
expect(out.enable.apiMethods).toEqual(['get', 'list']);
743+
// Warning names the object and the stripped verbs.
744+
expect(warn).toHaveBeenCalledTimes(1);
745+
expect(warn.mock.calls[0][0]).toContain('sys_thing');
746+
expect(warn.mock.calls[0][0]).toContain('create');
747+
});
748+
749+
it('keeps update when userActions.edit grants the edit affordance (sys_user case)', () => {
750+
const warn = vi.fn();
751+
const out = reconcileManagedApiMethods(
752+
managed({ userActions: { edit: true }, enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update', 'delete'] } }),
753+
{ warn },
754+
);
755+
// update survives (edit affordance granted); create/delete still stripped.
756+
expect(out.enable.apiMethods).toEqual(['get', 'list', 'update']);
757+
expect(warn).toHaveBeenCalledTimes(1);
758+
});
759+
760+
it('is a no-op (same reference, no warning) when nothing needs stripping', () => {
761+
const warn = vi.fn();
762+
const already: any = managed({ enable: { apiEnabled: true, apiMethods: ['get', 'list'] } });
763+
const out = reconcileManagedApiMethods(already, { warn });
764+
expect(out).toBe(already);
765+
expect(warn).not.toHaveBeenCalled();
766+
});
767+
768+
it('never touches read verbs', () => {
769+
const out = reconcileManagedApiMethods(
770+
managed({ enable: { apiEnabled: true, apiMethods: ['get', 'delete'] } }),
771+
{ warn: vi.fn() },
772+
);
773+
expect(out.enable.apiMethods).toEqual(['get']);
774+
});
775+
776+
it('leaves non-better-auth objects untouched (platform bucket keeps full CRUD)', () => {
777+
const warn = vi.fn();
778+
const platform: any = {
779+
name: 'sys_business_unit',
780+
managedBy: 'platform',
781+
enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update', 'delete'] },
782+
};
783+
const out = reconcileManagedApiMethods(platform, { warn });
784+
expect(out).toBe(platform);
785+
expect(out.enable.apiMethods).toEqual(['get', 'list', 'create', 'update', 'delete']);
786+
expect(warn).not.toHaveBeenCalled();
787+
});
788+
789+
it('applies on registerObject (the contradiction cannot be stored)', () => {
790+
const reg = new SchemaRegistry({ multiTenant: false });
791+
reg.registerObject(managed(), 'sys', 'sys', 'own');
792+
const stored = (reg as any).objectContributors.get('sys_thing')[0].definition;
793+
expect(stored.enable.apiMethods).toEqual(['get', 'list']);
794+
});
795+
});

packages/objectql/src/registry.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary } from '@objectstack/spec/data';
3+
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances } from '@objectstack/spec/data';
44
import { resolveMultiOrgEnabled, resolveSearchPinyinEnabled } from '@objectstack/types';
55
import { provisionSearchCompanion } from './search-companion.js';
66
import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel';
@@ -367,6 +367,84 @@ export function applySystemFields(
367367
};
368368
}
369369

370+
/**
371+
* Generic-write `apiMethods` verbs mapped to the {@link resolveCrudAffordances}
372+
* flag each one needs. Read verbs (`get`/`list`/`search`/`history`/…) are
373+
* always permitted, so they are absent here and never stripped.
374+
*/
375+
const MANAGED_WRITE_VERB_AFFORDANCE: Record<string, 'create' | 'edit' | 'delete'> = {
376+
create: 'create',
377+
update: 'edit',
378+
upsert: 'edit',
379+
delete: 'delete',
380+
purge: 'delete',
381+
};
382+
383+
/**
384+
* Reconcile a better-auth-managed object's `enable.apiMethods` against the
385+
* generic-write affordances it actually grants (ADR-0092 / #1591).
386+
*
387+
* `managedBy: 'better-auth'` promises generic CRUD is suppressed — identity
388+
* writes flow through better-auth's own endpoints, and the plugin-auth
389+
* identity write guard fail-closed rejects direct create/update/delete from a
390+
* user context. An object that *also* advertises those verbs in
391+
* `enable.apiMethods` is internally contradictory: the HTTP exposure gate
392+
* (ADR-0049) would admit the request and let it 403 at the engine instead of
393+
* answering a clean 405, and the metadata misrepresents what the API offers.
394+
*
395+
* This is the registration-time backstop that makes the contradiction
396+
* impossible to *ship*: any write verb whose CRUD affordance the object does
397+
* not grant — via the `managedBy` bucket default plus `userActions` overrides,
398+
* exactly as {@link resolveCrudAffordances} computes for the UI — is stripped,
399+
* with a warning. Reads are never touched. `sys_user` keeps `update` because
400+
* `userActions.edit: true` grants the edit affordance (its writes are clamped
401+
* to a field whitelist by the guard); every other identity table derives down
402+
* to reads only.
403+
*
404+
* Scope is deliberately limited to `better-auth`, the only bucket the write
405+
* guard enforces today — generalizing to an `externallyManaged`/`writeVia`
406+
* capability for the other buckets is ADR-0049 / #1878 (a separate phase),
407+
* not this reconciliation.
408+
*
409+
* Returns the input unchanged when nothing is stripped; otherwise a new schema
410+
* with a rewritten `enable.apiMethods` (immutable, like {@link applySystemFields}).
411+
*/
412+
export function reconcileManagedApiMethods(
413+
schema: ServiceObject,
414+
opts?: { warn?: (msg: string) => void },
415+
): ServiceObject {
416+
if ((schema as any).managedBy !== 'better-auth') return schema;
417+
418+
const methods = (schema as any).enable?.apiMethods;
419+
if (!Array.isArray(methods) || methods.length === 0) return schema;
420+
421+
const affordances = resolveCrudAffordances(schema);
422+
const stripped: string[] = [];
423+
const kept = methods.filter((m: string) => {
424+
const need = MANAGED_WRITE_VERB_AFFORDANCE[m];
425+
if (need && !affordances[need]) {
426+
stripped.push(m);
427+
return false;
428+
}
429+
return true;
430+
});
431+
432+
if (stripped.length === 0) return schema;
433+
434+
const warn = opts?.warn ?? ((msg: string) => console.warn(msg));
435+
warn(
436+
`[Registry] Object "${schema.name}" is managedBy:'better-auth' but advertised ` +
437+
`generic write verb(s) [${stripped.join(', ')}] in enable.apiMethods it does not ` +
438+
`permit — stripping them (ADR-0092/#1591). Writes on better-auth-managed tables go ` +
439+
`through better-auth's endpoints, not the generic data API. Kept: [${kept.join(', ')}].`,
440+
);
441+
442+
return {
443+
...schema,
444+
enable: { ...(schema as any).enable, apiMethods: kept },
445+
};
446+
}
447+
370448
/**
371449
* Platform namespaces that multiple packages may legitimately share, so the
372450
* install-time namespace-uniqueness gate (ADR-0048 Phase 1) must never fire on
@@ -590,6 +668,13 @@ export class SchemaRegistry {
590668
// applySystemFields().
591669
schema = applySystemFields(schema, { multiTenant: this.multiTenant });
592670

671+
// [ADR-0092 / #1591] Reconcile generic-write `apiMethods` against the CRUD
672+
// affordances a better-auth-managed object actually grants — strip verbs
673+
// the identity write guard would reject anyway, so the HTTP exposure gate
674+
// answers a clean 405 and the metadata can't contradict itself. No-op for
675+
// every non-`better-auth` object.
676+
schema = reconcileManagedApiMethods(schema);
677+
593678
// [ADR-0079] Object-materialization seam — DESIGNATE-ONLY primary-title
594679
// provisioning. Runs AFTER `applySystemFields` (so any designated field
595680
// co-exists with the injected system columns) and ONLY for owned objects

packages/platform-objects/src/identity/sys-account.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ export const SysAccount = ObjectSchema.create({
214214
trackHistory: false,
215215
searchable: false,
216216
apiEnabled: true,
217-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
217+
// #1591 — reads only: writes are refused by the identity write guard
218+
// (ADR-0092 D2) and owned by better-auth. HTTP answers 405 before the 403.
219+
apiMethods: ['get', 'list'],
218220
trash: true,
219221
mru: false,
220222
},

packages/platform-objects/src/identity/sys-api-key.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ export const SysApiKey = ObjectSchema.create({
214214
trackHistory: true,
215215
searchable: false,
216216
apiEnabled: true,
217-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
217+
// #1591 — reads only: writes are refused by the identity write guard
218+
// (ADR-0092 D2) and owned by better-auth. HTTP answers 405 before the 403.
219+
apiMethods: ['get', 'list'],
218220
trash: false,
219221
mru: false,
220222
},

packages/platform-objects/src/identity/sys-device-code.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ export const SysDeviceCode = ObjectSchema.create({
139139
trackHistory: false,
140140
searchable: false,
141141
apiEnabled: true,
142-
apiMethods: ['get', 'create', 'update', 'delete'],
142+
// #1591 — reads only: writes are refused by the identity write guard
143+
// (ADR-0092 D2) and owned by better-auth. HTTP answers 405 before the 403.
144+
apiMethods: ['get'],
143145
trash: false,
144146
mru: false,
145147
},

packages/platform-objects/src/identity/sys-invitation.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,9 @@ export const SysInvitation = ObjectSchema.create({
242242
trackHistory: true,
243243
searchable: false,
244244
apiEnabled: true,
245-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
245+
// #1591 — reads only: writes are refused by the identity write guard
246+
// (ADR-0092 D2) and owned by better-auth. HTTP answers 405 before the 403.
247+
apiMethods: ['get', 'list'],
246248
trash: false,
247249
mru: false,
248250
},

packages/platform-objects/src/identity/sys-member.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@ export const SysMember = ObjectSchema.create({
187187
trackHistory: true,
188188
searchable: false,
189189
apiEnabled: true,
190-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
190+
// #1591 — reads only: writes are refused by the identity write guard
191+
// (ADR-0092 D2) and owned by better-auth. HTTP answers 405 before the 403.
192+
apiMethods: ['get', 'list'],
191193
trash: false,
192194
mru: false,
193195
},

packages/platform-objects/src/identity/sys-organization.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,9 @@ export const SysOrganization = ObjectSchema.create({
247247
trackHistory: true,
248248
searchable: true,
249249
apiEnabled: true,
250-
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
250+
// #1591 — reads only: writes are refused by the identity write guard
251+
// (ADR-0092 D2) and owned by better-auth. HTTP answers 405 before the 403.
252+
apiMethods: ['get', 'list'],
251253
trash: true,
252254
mru: true,
253255
},

packages/platform-objects/src/identity/sys-session.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,9 @@ export const SysSession = ObjectSchema.create({
210210
trackHistory: false,
211211
searchable: false,
212212
apiEnabled: true,
213-
apiMethods: ['get', 'list', 'create', 'delete'],
213+
// #1591 — reads only: writes are refused by the identity write guard
214+
// (ADR-0092 D2) and owned by better-auth. HTTP answers 405 before the 403.
215+
apiMethods: ['get', 'list'],
214216
trash: false,
215217
mru: false,
216218
clone: false,

packages/platform-objects/src/identity/sys-team-member.object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ export const SysTeamMember = ObjectSchema.create({
110110
trackHistory: true,
111111
searchable: false,
112112
apiEnabled: true,
113-
apiMethods: ['get', 'list', 'create', 'delete'],
113+
// #1591 — reads only: writes are refused by the identity write guard
114+
// (ADR-0092 D2) and owned by better-auth. HTTP answers 405 before the 403.
115+
apiMethods: ['get', 'list'],
114116
trash: false,
115117
mru: false,
116118
},

0 commit comments

Comments
 (0)