Repository navigation
Description
Summary
managedBy: 'better-auth' is documented as "generic CRUD is suppressed — use the action endpoints" but nothing enforces it. The generic data API will happily INSERT/UPDATE/DELETE against an externally-managed table, bypassing the owning subsystem's invariants, hooks, and authz path.
Evidence
packages/platform-objects/src/identity/sys-team.object.ts:
managedBy: 'better-auth',
// "Generic CRUD is suppressed (managedBy: 'better-auth'), so these are
// the canonical entry points for create/update/delete."
...
enable: { apiMethods: ['get','list','create','update','delete'] } // <- contradicts the commentpackages/objectql/src/engine.ts— nomanagedBycheck in the insert/update/delete path (assertWriteAllowed()only gates external datasources).packages/objectql/src/registry.ts:~189—managedByis only used to skip system-field injection (if (schema.managedBy === 'better-auth') return schema), not to block ops.packages/rest/src/rest-server.tscreate handler callsp.createData(...)directly; no metadata/apiMethodsgate.
Result: a bare POST /api/v1/data/sys_team runs a generic INSERT. The canonical path is the create_team action → POST /api/v1/auth/organization/create-team, which lets better-auth own id/organizationId, enforce org-membership invariants, and apply its own authz.
Why this matters (not just an error-message bug)
- Integrity: you can create a
sys_teamrow better-auth doesn't know about, or one that violates its team↔org consistency / cascade-on-delete assumptions. - Security surface: the action routes through better-auth authz; the generic
/dataroute uses ObjectQL RLS. These are not guaranteed equivalent.
Proposed change
Make managedBy (external owner) a real capability gate: for objects whose writes are externally managed, the generic data API should refuse create/update/delete with a clear 405/409 that names the canonical action (e.g. "use action create_team"), while leaving read (get/list) open (the UI needs it).
Also reconcile the contradictory metadata: either derive apiMethods from managedBy, or fail object registration when an externally-managed object advertises generic write verbs.
Open design points
- Granularity: refuse all of create/update/delete? (My instinct: yes; reads always open.)
- Generalize
managedBy: 'better-auth'into a broader capability (externallyManaged/writeVia: '<action>') so third-party-managed objects get the same treatment, not just better-auth. - Enforcement layer: REST router (cheap, per-verb) vs. ObjectQL engine (defense-in-depth, covers non-REST callers). Engine-level is more robust.
Related
- Provenance issue (system-field skip-by-name) — same
managedBy/injection boundary (linked below). - Error-mapping safety net: branch
fix/rest-map-schema-errors.
Found running cloud LOCAL-E2E-CHECKLIST B7.
Activity
xuyushun441-sys commentedon Jun 5, 2026
Related set — three rungs of "the data API must honor object metadata before hitting the DB", surfaced together by
LOCAL-E2E-CHECKLISTB7:managedBy(who owns writes)organization_id)Landed as the error-mapping safety net: branch
fix/rest-map-schema-errors(maps the symptoms to 4xx; these issues are the causes).os-zhuang commentedon Jun 15, 2026
Recommendation: fold into ADR-0049 / #1878 (metadata-liveness "enforce-or-remove") as an M2 security item — not a standalone fix.
Why route rather than quick-fix:
managedBy:'better-auth'→externallyManaged/writeVia, REST-layer vs engine-layer enforcement, the full set of managed tables).sys_teametc. —assertWriteAllowed(engine.ts) only gates external datasources (sys_team is ondefault), andprotectionis ADR-0010 metadata protection, not row write-gating. (I did not trace the global auth/RLS layer, so residual exposure depends on that.)assertWriteAllowedwith amanagedBy/externallyManagedrefusal (engine-level = defense-in-depth, covers non-REST callers), reads stay open. Blast-radius caveat to check first: confirm better-auth's adapter and the boot/seed path do NOT write these tables via generic ObjectQLinsert, or the gate breaks auth/startup.Leaving open for the #1878 owner to fold/close. (At a startup stage with a small trusted user base, accepting the residual risk short-term is defensible — but track it, don't drop it.)
os-zhuang commentedon Jul 18, 2026
Re-review (2026-07-18): the core gap is CLOSED — landed via ADR-0092 D2, not #1878. What remains is metadata reconciliation + generalization. Development plan below.
Status of the original report
The headline vulnerability (generic
/dataCRUD bypassing better-auth onsys_teamand every other identity table) is now enforced at the engine level:packages/plugins/plugin-auth/src/identity-write-guard.tsregistersbeforeInsert/beforeUpdate/beforeDeletehooks that fail-closed reject USER-CONTEXT writes on every object whose registered schema declaresmanagedBy: 'better-auth'. The flag is read from the schema registry at evaluation time — no hardcoded table list. Registered atkernel:readyinauth-plugin.ts.sys_user → {name, image}).isSystem— both bypass the guard, so boot/seed/adapter paths are unaffected.identity-write-guard.test.ts+ e2e dogfoodsingle-tenant-identity-create.dogfood.test.ts(POST /data/sys_team→ 403PERMISSION_DENIED; system-context insert still succeeds).apiEnabled/apiMethodsare now真实 enforced at HTTP dispatch (packages/runtime/src/api-exposure.ts, ADR-0049 / [P0][security] Object apiEnabled/apiMethods not enforced by REST #1889 — 404/405).What remains open (from this issue's asks)
sys_team.object.tsstill advertisesapiMethods: ['get','list','create','update','delete']while its comment says generic CRUD is suppressed. Same forsys_member,sys_organization,sys_user,sys_account,sys_invitation,sys_api_key(others likesys_oauth_application/sys_sso_providerare already tightened to read-only).managedBy === 'better-auth'; other buckets (e.g.managedBy: 'platform') are explicitly ignored (behavior pinned by a test). TheexternallyManaged/writeViadesign point still belongs to [P0] Metadata property liveness audit: ~half of all spec properties are dead; a cluster of security props is parsed-but-unenforced #1878 / ADR-0049.create_team).Development plan
Phase 1 — tighten
apiMethodson better-auth-managed objects (small, ships independently)managedBy: 'better-auth'object inpackages/platform-objects/src/identity/, remove the write verbs that the identity write guard rejects anyway; keep verbs a registered whitelist legitimately serves (sys_userkeepsupdatefor the profile-field whitelist).405(viaapi-exposure.ts) before the engine's 403 backstop; the engine guard remains as defense-in-depth for non-REST callers.checkApiExposureis a no-op for system/internal contexts, and the better-auth adapter doesn't route through HTTP dispatch — boot/seed unaffected. Verify with the existing dogfood suites.POST /api/v1/data/sys_team→ 405; dogfoodsingle-tenant-identity-createupdated to assert the new status; UI affordances already clamped by the/me/permissionsmanaged-write clamp (no UI regression expected).Phase 2 — registration-time consistency validation
managedByand advertises generic write verbs inapiMethodswithout a corresponding update whitelist — making the contradiction impossible to reintroduce.packages/objectql/src/registry.ts(wheremanagedByis already consulted).Phase 3 — generalize the guard (fold into #1878 / ADR-0049)
managedBy: 'better-auth'special-casing into a spec-level capability (externallyManaged/writeVia) so third-party-managed andmanagedBy: 'platform'objects get the same engine-level treatment, with the same system-context bypass and per-object whitelist mechanism.Phase 4 (optional polish) — enrich the guard's rejection message to name the object's canonical action(s) from its
actionsmetadata (e.g. "use actioncreate_team").Proposal: land Phase 1+2 as one small PR referencing this issue, then close this issue; Phase 3 continues under #1878. Phase 4 is opportunistic.
Generated by Claude Code
fix(identity): reconcile managedBy:'better-auth' apiMethods with the …
fix(identity): close generic-write apiMethods hole on sys_presence & …