Skip to content

managedBy is not enforced: generic CRUD bypasses better-auth on sys_team (data-integrity / security) #1591

@xuyushun441-sys

Description

@xuyushun441-sys

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 comment
  • packages/objectql/src/engine.ts — no managedBy check in the insert/update/delete path (assertWriteAllowed() only gates external datasources).
  • packages/objectql/src/registry.ts:~189managedBy is only used to skip system-field injection (if (schema.managedBy === 'better-auth') return schema), not to block ops.
  • packages/rest/src/rest-server.ts create handler calls p.createData(...) directly; no metadata/apiMethods gate.

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_team row 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 /data route 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions