Skip to content

[security][P0] Broken access control — any authenticated member can read AND modify other users' records #1985

Closed

Description

@os-zhuang

⚠️ Corrected + escalated (was mis-titled "fail-open on zero permission sets")

My first diagnosis was wrong. member_default is applied (the fallbackPermissionSet baseline works). The real, verified issue is more serious: a non-admin member can read and mutate any other user's records.

Verified repro (live CRM, two fresh users)

admin signs up (user A, auto-promoted to admin_full_access)
member signs up (user B → member_default)
A creates crm_account "ADMIN-ORIGINAL"
B  PATCH /data/crm_account/<A's id>  {"name":"HIJACKED-BY-MEMBER"}   → 200
A  GET   /data/crm_account/<A's id>  → name == "HIJACKED-BY-MEMBER"   ← actually mutated
B  DELETE /data/crm_account/<A's id> → 200

Confirmed by re-reading the row: the member's write persisted. Not a silent no-op.

Root cause (3 interacting gaps)

member_default grants objects: { '*': {read,create,edit,delete} } and relies on RLS to scope via two policies: tenant_isolation (organization_id = current_user.organization_id) and owner_only_writes/deletes (owner_id = current_user.id). All three of these fail on a real app:

  1. owner-scoping is inert on author objects. The middleware only auto-injects owner_id on insert if the object declares an owner_id field (security-plugin.ts:397-414). Author objects (e.g. crm_account) don't — the engine injects created_by/updated_by/organization_id, not owner_id. So owner_only_writes references a missing column and contributes no real constraint.
  2. tenant isolation doesn't bind on sign-up. Both fresh users had activeOrganizationId = None, so current_user.organization_id doesn't scope and the wildcard org policy doesn't isolate them.
  3. RLS does not gate by-id writes. Even setting (1)/(2) aside, the member's PATCH/DELETE by id succeeded and persisted — so the owner/tenant predicate is not enforced as a guard on update/delete-by-id (it's effective on the read/find path only).

Why it's not auto-fixed

I tried the obvious one-liner — repoint owner_only_writes/deletes from owner_id to the always-injected created_by — and re-tested: the member could still mutate the admin's record. So the column was a red herring; the binding gap is the write-path RLS enforcement (gap 3). Reverted. This is core security architecture (write-path authorization + org-on-signup), high blast radius, and needs design + thorough 2-user/2-org e2e coverage — not a speculative patch.

Recommended fix (focused security workstream)

  • Enforce RLS predicates on update/delete-by-id (pre-image check: load the row, verify it satisfies the owner/tenant predicate, else PermissionDeniedError) — this is the load-bearing fix.
  • Bind ownership to a column that always exists — key owner-scoping on created_by (engine-guaranteed), or universally auto-inject owner_id. (Necessary but, per the test, not sufficient alone.)
  • Assign an organization on sign-up so tenant_isolation actually scopes (or make the wildcard org policy fail-closed when current_user.organization_id is absent).
  • Ship with a 2-user × 2-org e2e matrix: member edits own ✓, member edits other's ✗, cross-tenant read/write ✗, admin unrestricted ✓.

Severity: P0 — authenticated horizontal privilege escalation (and likely cross-tenant once orgs differ). Discovered driving app-crm end-to-end (alongside the now-merged #1984).

Activity

  1. changed the title [-][security] Object permissions fail-open when a user resolves to zero permission sets[/-] [+][security][P0] Broken access control — any authenticated member can read AND modify other users' records[/+] on Jun 16, 2026
  2. os-zhuang commented on Jun 17, 2026

    @os-zhuang
    ContributorAuthor

    Owner (intra-tenant) write hole — fixed in #1994

    The verified P0 — any member could modify/delete another user's records — is fixed in #1994:

    • Pre-image write authorization: by-id update/delete now evaluate the write-op RLS filter against the target row before mutating (the path previously built no ast, so RLS was never applied to by-id writes).
    • Owner scoping repointed to created_by (universally engine-injected) instead of the usually-absent owner_id.

    Verified live (2 users): member→others' PATCH/DELETE = 403 (unmutated), member→own = 200, admin→any = 200. plugin-security 91 / org-scoping 15 / runtime 382 / objectql 639 green.

    Remaining in this issue — cross-tenant / org-on-signup

    The tenant half is still open and stays tracked here:

    • Fresh sign-ups have activeOrganizationId = None, so tenant_isolation (organization_id = current_user.organization_id) doesn't bind, and (without org-scoping installed) the policy is stripped entirely. Two users currently share one effective (null) tenant.
    • Fix direction: assign/activate an organization at sign-up (or fail-closed when current_user.organization_id is absent on a tenancy-enabled object). Once org context is present, fix(security)[P0]: enforce RLS on by-id writes — close member-edits-others'-records hole (#1985) #1994's pre-image check already enforces the tenant predicate on writes too.

    Reclassifying: the P0 horizontal-privesc (owner) is addressed by #1994; this issue now tracks the cross-tenant/org-assignment follow-up.

  3. os-zhuang commented on Jun 17, 2026

    @os-zhuang
    ContributorAuthor

    Cross-tenant follow-up — investigated; NOT a framework bug (no fix needed)

    Dug into the tenant half and traced it end-to-end. Conclusion: there is no cross-tenant fail-open at the framework level — the earlier observation was single-tenant behavior by design.

    The RLS compiler already fails CLOSED for a user with no active org. When org-scoping is installed, tenant_isolation is applicable but current_user.organization_id is null → compileExpression skips the term → compileFilter sees zero compiled filters for applicable policies → returns RLS_DENY_FILTER (matches no rows). rls-compiler.ts:94-100, with the explicit comment "prevents the 'user without an active org sees every tenant's data' class of bug," and covered by tests (security-plugin.test.ts: fail-closed: wildcard org policy … → deny, a logged-in user with no organization → deny sentinel). With #1994, that fail-closed now applies to writes too.

    Why the CRM looked cross-tenant-leaky: it does not install @objectstack/plugin-org-scoping, so it runs single-tenant — collectRLSPolicies strips the current_user.organization_id policies entirely and all users share one dataset by design (a single company's CRM). Confirmed empirically: the member could read the admin's record, i.e. no tenant filter was active. For a single-tenant app that's correct; record integrity is protected by #1994 (owner-scoped writes).

    The residual is onboarding, not security: in a multi-tenant (org-scoping) deployment, a freshly signed-up user has no org → fail-closed → sees nothing until an org is assigned. Assignment is handled by plugin-org-scoping (ensure-default-organization / claim-orphan-org-rows) and the provisioning layer, and the policy (personal-org-per-signup vs invite-into-shared-org) is app-specific — correctly NOT a hardcoded framework default (auto-personal-org would break single-tenant team apps).

    Disposition: the P0 (owner horizontal-privesc) is fixed by #1994. The tenant half is fail-closed by design + an app/provisioning onboarding concern. Recommend closing this issue once #1994 merges; no further framework security change is warranted.

  4. os-zhuang commented on Jun 17, 2026

    @os-zhuang
    ContributorAuthor

    Resolved. The P0 — any member could read & modify other users' records — is fixed and merged in #1994 (92d75ca): by-id update/delete now run a pre-image RLS authorization check, and owner scoping is keyed on the always-injected created_by. Verified live (member→others' = 403, member→own = 200, admin→any = 200) + unit tests.

    The cross-tenant half was investigated and is not a framework bug: the RLS compiler already fails CLOSED for a user with no active org (org-scoping installed), and the CRM's shared-data behavior is single-tenant by design (org-scoping not installed). Remaining org-on-signup is an app/provisioning onboarding concern, not a security gap. Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority:p0Critical: blocker, must ship before MVPsecurity

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions