You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 92d75ca
Browse filesBrowse the repository at this point in the historyBrowse files
fix(security): enforce RLS on by-id writes — close member-edits-others hole (#1994)
#1985 (P0). A single-id update/delete goes straight to driver.update(object,
id)/driver.delete(object, id) and builds no query ast, so the RLS where filter
the middleware injects on the read path was never applied to by-id writes. With
member_default granting *: {edit,delete} (meant to be owner-scoped via the
owner_only_writes/deletes RLS), the owner predicate was silently bypassed — any
authenticated member could modify/delete another user's records (verified: a
member PATCH'd an admin's record and it persisted).
Two coordinated fixes:
1. Pre-image authorization check (security-plugin.ts): before a single-id
update/delete, compute the write-op RLS filter and re-read the target row
with { id } AND <writeFilter>; if not visible → PermissionDeniedError (403).
Reuses existing RLS/tenant machinery, recursion-safe (find doesn't re-trigger
it), skipped when no RLS policy applies (admins / modifyAllRecords unchanged).
2. Owner scoping keyed on a column that exists (default-permission-sets.ts):
owner_only_writes/deletes keyed on owner_id, which author objects almost never
declare → computeRlsFilter dropped the policy (the no-op that hid the bypass).
Now keyed on created_by, the ownership column the engine stamps universally.
Members may now write only the records they created; admins unrestricted.
Audit-opt-out objects (no created_by) fail CLOSED for member writes.
Verified live on app-crm (2 users): member→others' PATCH/DELETE = 403
(unmutated); member→own = 200; admin→any = 200. plugin-security 91 (+4),
org-scoping 15, runtime 382, objectql 639 green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(security): enforce row-level security on by-id writes — close the member-can-edit-others'-records hole (#1985).
6
+
7
+
A single-id `update`/`delete` goes straight to `driver.update(object, id, …)` / `driver.delete(object, id)` and builds no query `ast`, so the RLS `where` filter the middleware injects on the read path was **never applied to by-id writes**. Combined with `member_default` granting `*: { edit, delete }` (scoped, by design, via the `owner_only_writes/deletes` RLS), this meant the owner predicate was silently bypassed: **any authenticated member could modify or delete another user's records** (verified end-to-end — a member PATCH'd an admin's record and the change persisted).
8
+
9
+
Two coordinated changes:
10
+
11
+
-**Enforce a pre-image authorization check.** Before a single-id `update`/`delete`, the security middleware computes the write-operation RLS filter and re-reads the target row with `{ id } AND <writeFilter>`; if the row isn't visible (someone else's, or RLS-hidden) it throws `PermissionDeniedError` (403). Reuses the existing RLS/tenant machinery, is recursion-safe (a `find` doesn't trigger the check), and is skipped when no RLS policy applies (e.g. admin sets, `modifyAllRecords`) so admins and unguarded objects are unchanged.
12
+
-**Repoint owner scoping to a column that exists.**`owner_only_writes`/`owner_only_deletes` keyed on `owner_id`, which author-defined objects almost never declare — so the policy referenced a missing column and `computeRlsFilter` dropped it (the no-op that made the bypass invisible). Now keyed on `created_by`, the ownership column the engine stamps on every object.
13
+
14
+
Result: a member may edit/delete the records they created, not others'; admins (and any set with `modifyAllRecords` or no RLS) are unrestricted. Objects that opt out of audit fields (`systemFields.audit: false`) have no `created_by` and now fail **closed** for member writes (grant `modifyAllRecords` or a per-object policy to allow). Objects modeling transferable ownership should override with a per-object owner policy.
15
+
16
+
Verified live on app-crm (2 users): member→others' record PATCH/DELETE = 403 (unmutated); member→own = 200; admin→any = 200. Note: cross-tenant write isolation additionally depends on an organization being assigned at sign-up (tracked separately in #1985).
0 commit comments