Repository navigation
[security][P0] Broken access control — any authenticated member can read AND modify other users' records #1985
Description
⚠️ 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:
- owner-scoping is inert on author objects. The middleware only auto-injects
owner_idon insert if the object declares anowner_idfield (security-plugin.ts:397-414). Author objects (e.g.crm_account) don't — the engine injectscreated_by/updated_by/organization_id, notowner_id. Soowner_only_writesreferences a missing column and contributes no real constraint. - tenant isolation doesn't bind on sign-up. Both fresh users had
activeOrganizationId = None, socurrent_user.organization_iddoesn't scope and the wildcard org policy doesn't isolate them. - RLS does not gate by-id writes. Even setting (1)/(2) aside, the member's
PATCH/DELETEby 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-injectowner_id. (Necessary but, per the test, not sufficient alone.) - Assign an organization on sign-up so
tenant_isolationactually scopes (or make the wildcard org policy fail-closed whencurrent_user.organization_idis 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
[-][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[/+]os-zhuang commentedon Jun 17, 2026
Owner (intra-tenant) write hole — fixed in #1994
The verified P0 — any member could modify/delete another user's records — is fixed in #1994:
update/deletenow evaluate the write-op RLS filter against the target row before mutating (the path previously built noast, so RLS was never applied to by-id writes).created_by(universally engine-injected) instead of the usually-absentowner_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:
activeOrganizationId = None, sotenant_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.current_user.organization_idis 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.
os-zhuang commentedon Jun 17, 2026
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_isolationis applicable butcurrent_user.organization_idis null →compileExpressionskips the term →compileFiltersees zero compiled filters for applicable policies → returnsRLS_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 —collectRLSPoliciesstrips thecurrent_user.organization_idpolicies 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.
fix(security): enforce RLS on by-id writes — close member-edits-other…
os-zhuang commentedon Jun 17, 2026
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.
fix(security): enforce the RLS `positions` applicability domain; scop…
feat(security): ADR-0090 P2 — everyone/guest audience anchors, additi…