Repository navigation
managedBy is not enforced: generic CRUD bypasses better-auth on sys_team (data-integrity / security) #1591
Description
Activity
xuyushun441-sys commented
on Jun 5, 2026 CollaboratorAuthorMore actionsRelated set — three rungs of "the data API must honor object metadata before hitting the DB", surfaced together by
LOCAL-E2E-CHECKLISTB7:- Data API: reject unknown payload fields (validateRecord ignores keys not in schema) #1590 — reject unknown payload fields (schema integrity)
- managedBy is not enforced: generic CRUD bypasses better-auth on sys_team (data-integrity / security) #1591 — enforce
managedBy(who owns writes) - validateRecord SKIP_FIELDS matches by name: required org_id on managed objects silently becomes NULL #1592 — provenance-aware system-field skip (silent NULL on real
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).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:
- This is literally an "enforce a parsed-but-unenforced property" item — the exact class [P0] Metadata property liveness audit: ~half of all spec properties are dead; a cluster of security props is parsed-but-unenforced #1878 / ADR-0049 owns, with active coordinated work. A one-off patch here would likely conflict with the holistic design (generalizing
managedBy:'better-auth'→externallyManaged/writeVia, REST-layer vs engine-layer enforcement, the full set of managed tables). - Confirmed it is a live gap, not already mitigated: object-level there is no write restriction on
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.) - Natural mount point when ADR-0049 picks it up: extend
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.)
- This is literally an "enforce a parsed-but-unenforced property" item — the exact class [P0] Metadata property liveness audit: ~half of all spec properties are dead; a cluster of security props is parsed-but-unenforced #1878 / ADR-0049 owns, with active coordinated work. A one-off patch here would likely conflict with the holistic design (generalizing
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:- Engine-level guard (defense-in-depth, covers non-REST callers) —
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. - Reads stay open (get/list untouched), exactly as proposed here. The only write opening is a per-object UPDATE whitelist (currently
sys_user → {name, image}). - Blast-radius caveat from the previous comment is addressed by construction: better-auth's adapter calls carry no session context and plugin/system writes stamp
isSystem— both bypass the guard, so boot/seed/adapter paths are unaffected. - Proven: unit suite
identity-write-guard.test.ts+ e2e dogfoodsingle-tenant-identity-create.dogfood.test.ts(POST /data/sys_team→ 403PERMISSION_DENIED; system-context insert still succeeds). - Separately,
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)
- Metadata contradiction not reconciled —
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). - Generalization not done — the guard only recognizes
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. - Minor: the 403 message gives a generic "use the dedicated auth surface" hint rather than naming the object's canonical action (e.g.
create_team).
Development plan
Phase 1 — tighten
apiMethodson better-auth-managed objects (small, ships independently)- For each
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). - Result: the HTTP layer answers with a semantically-correct
405(viaapi-exposure.ts) before the engine's 403 backstop; the engine guard remains as defense-in-depth for non-REST callers. - Risk check:
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. - Acceptance:
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
- Fail (or warn-then-derive) at object registration when a schema declares an external
managedByand advertises generic write verbs inapiMethodswithout a corresponding update whitelist — making the contradiction impossible to reintroduce. - Mount point: schema normalization in
packages/objectql/src/registry.ts(wheremanagedByis already consulted). - Acceptance: a registry unit test registering a contradictory schema asserts the error/derivation.
Phase 3 — generalize the guard (fold into #1878 / ADR-0049)
- Promote
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. - This is the ADR-scale piece — tracked under [P0] Metadata property liveness audit: ~half of all spec properties are dead; a cluster of security props is parsed-but-unenforced #1878's "enforce-or-remove" program rather than here.
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
- Engine-level guard (defense-in-depth, covers non-REST callers) —
- added a commit that references this issue
on Jul 18, 2026 - added a commit that references this issue
on Jul 18, 2026
Summary
managedBy: 'better-auth'is documented as "generic CRUD is suppressed — use the action endpoints" but nothing enforces it. The generic data API will happilyINSERT/UPDATE/DELETEagainst an externally-managed table, bypassing the owning subsystem's invariants, hooks, and authz path.Evidence
packages/platform-objects/src/identity/sys-team.object.ts:packages/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_teamruns a generic INSERT. The canonical path is thecreate_teamaction →POST /api/v1/auth/organization/create-team, which lets better-auth ownid/organizationId, enforce org-membership invariants, and apply its own authz.Why this matters (not just an error-message bug)
sys_teamrow better-auth doesn't know about, or one that violates its team↔org consistency / cascade-on-delete assumptions./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 clear405/409that names the canonical action (e.g. "use actioncreate_team"), while leaving read (get/list) open (the UI needs it).Also reconcile the contradictory metadata: either derive
apiMethodsfrommanagedBy, or fail object registration when an externally-managed object advertises generic write verbs.Open design points
managedBy: 'better-auth'into a broader capability (externallyManaged/writeVia: '<action>') so third-party-managed objects get the same treatment, not just better-auth.Related
managedBy/injection boundary (linked below).fix/rest-map-schema-errors.Found running cloud
LOCAL-E2E-CHECKLISTB7.