Skip to content

Commit 0180a29

Browse files
os-zhuangclaude
andcommitted
fix(security): enforce RLS on by-id writes — close member-edits-others hole
#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>
1 parent 84249a4 commit 0180a29

4 files changed

Lines changed: 215 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
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).

packages/plugins/plugin-security/src/objects/default-permission-sets.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,17 +295,29 @@ export const defaultPermissionSets: PermissionSet[] = [
295295
operation: 'all',
296296
using: 'organization_id = current_user.organization_id',
297297
},
298+
// Owner-scoped writes/deletes for rank-and-file members: you may modify
299+
// and delete the records you created, not other users'. Keyed on
300+
// `created_by` — the column the engine stamps on EVERY record — rather
301+
// than `owner_id`, which author-defined objects almost never declare. The
302+
// old `owner_id` key referenced a missing column on real objects, so
303+
// `computeRlsFilter` dropped the policy and the scoping silently no-op'd
304+
// (any member could edit/delete any record — #1985). These policies are
305+
// ENFORCED on writes via the security middleware's pre-image check (a
306+
// by-id update/delete never builds an RLS `where`, so the predicate is
307+
// verified against the target row before the mutation). Objects that
308+
// model transferable ownership with a dedicated owner field should
309+
// override these with a per-object policy.
298310
{
299311
name: 'owner_only_writes',
300312
object: '*',
301313
operation: 'update',
302-
using: 'owner_id = current_user.id',
314+
using: 'created_by = current_user.id',
303315
},
304316
{
305317
name: 'owner_only_deletes',
306318
object: '*',
307319
operation: 'delete',
308-
using: 'owner_id = current_user.id',
320+
using: 'created_by = current_user.id',
309321
},
310322
// ── better-auth system tables that lack `organization_id` and would
311323
// otherwise be left unprotected by the wildcard rule above. ────

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,20 @@ describe('SecurityPlugin', () => {
9797
// wildcard `current_user.organization_id` RLS policies. Otherwise it
9898
// strips them so single-tenant deployments aren't filtered to nothing.
9999
// -------------------------------------------------------------------------
100-
const makeMiddlewareCtx = (overrides: { permissionSets: PermissionSet[]; objectFields?: string[]; schemaExtra?: Record<string, any>; orgScoping?: boolean }) => {
100+
const makeMiddlewareCtx = (overrides: { permissionSets: PermissionSet[]; objectFields?: string[]; schemaExtra?: Record<string, any>; orgScoping?: boolean; findOneImpl?: (query: any) => any }) => {
101101
const fields: Record<string, any> = {};
102102
for (const f of overrides.objectFields ?? ['id', 'organization_id', 'owner_id', 'name']) {
103103
fields[f] = { name: f };
104104
}
105105
const baseSchema: any = { name: 'task', fields, ...(overrides.schemaExtra ?? {}) };
106106
let middleware: any;
107+
// The pre-image write-authorization check re-reads the target row via
108+
// `ql.findOne(object, { where: { $and: [{ id }, writeFilter] }, … })`.
109+
// `findOneImpl` lets a test decide whether that row is "visible" (owned /
110+
// in-tenant) or filtered out (someone else's row → null → deny).
111+
const findOne = vi.fn(async (_object: string, query: any) =>
112+
overrides.findOneImpl ? overrides.findOneImpl(query) : null,
113+
);
107114
const ql = {
108115
registerMiddleware: (mw: any) => {
109116
// Capture only the FIRST middleware (the security CRUD one);
@@ -112,6 +119,7 @@ describe('SecurityPlugin', () => {
112119
if (!middleware) middleware = mw;
113120
},
114121
getSchema: () => baseSchema,
122+
findOne,
115123
};
116124
const metadata = {
117125
get: async () => baseSchema,
@@ -136,6 +144,7 @@ describe('SecurityPlugin', () => {
136144
};
137145
return {
138146
ctx,
147+
findOne,
139148
run: async (opCtx: any) => {
140149
await middleware(opCtx, async () => {});
141150
return opCtx;
@@ -226,6 +235,98 @@ describe('SecurityPlugin', () => {
226235
expect(opCtx.ast.where).toBeUndefined();
227236
});
228237

238+
// ── Row-level WRITE authorization (pre-image check, #1985) ──────────────
239+
// A by-id update/delete never builds an RLS `where`, so the owner/tenant
240+
// predicate must be enforced by re-reading the target row before mutating.
241+
describe('pre-image write authorization', () => {
242+
const ownerPolicySet: PermissionSet = {
243+
name: 'member_default',
244+
label: 'Member',
245+
isProfile: true,
246+
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
247+
rowLevelSecurity: [
248+
{ name: 'owner_only_writes', object: '*', operation: 'update', using: 'created_by = current_user.id' },
249+
{ name: 'owner_only_deletes', object: '*', operation: 'delete', using: 'created_by = current_user.id' },
250+
],
251+
} as any;
252+
const memberCtx = { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] };
253+
const ownerFields = ['id', 'created_by', 'name'];
254+
255+
it('DENIES an update when the target row is not visible under the write filter (not the owner)', async () => {
256+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
257+
const harness = makeMiddlewareCtx({
258+
permissionSets: [ownerPolicySet],
259+
objectFields: ownerFields,
260+
findOneImpl: () => null, // row exists but filtered out by created_by → not visible
261+
});
262+
await plugin.init(harness.ctx);
263+
await plugin.start(harness.ctx);
264+
const opCtx: any = {
265+
object: 'task', operation: 'update',
266+
data: { id: 'r1', name: 'hijack' }, options: { where: { id: 'r1' } },
267+
context: memberCtx,
268+
};
269+
await expect(harness.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' });
270+
expect(harness.findOne).toHaveBeenCalledTimes(1);
271+
// the re-read ANDs the row id with the owner write filter
272+
const [, query] = harness.findOne.mock.calls[0];
273+
expect(query.where.$and[0]).toEqual({ id: 'r1' });
274+
});
275+
276+
it('ALLOWS an update when the target row IS visible under the write filter (the owner)', async () => {
277+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
278+
const harness = makeMiddlewareCtx({
279+
permissionSets: [ownerPolicySet],
280+
objectFields: ownerFields,
281+
findOneImpl: () => ({ id: 'r1', created_by: 'u1', name: 'mine' }),
282+
});
283+
await plugin.init(harness.ctx);
284+
await plugin.start(harness.ctx);
285+
const opCtx: any = {
286+
object: 'task', operation: 'delete',
287+
options: { where: { id: 'r1' } },
288+
context: memberCtx,
289+
};
290+
await expect(harness.run(opCtx)).resolves.toBeDefined();
291+
expect(harness.findOne).toHaveBeenCalledTimes(1);
292+
});
293+
294+
it('SKIPS the check when no RLS policy applies (e.g. modifyAllRecords / admin) — no extra read', async () => {
295+
const adminSet: PermissionSet = {
296+
name: 'admin_full_access', label: 'Admin', isProfile: true,
297+
objects: { '*': { allowRead: true, allowEdit: true, allowDelete: true, modifyAllRecords: true, viewAllRecords: true } },
298+
// no rowLevelSecurity
299+
} as any;
300+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' });
301+
const harness = makeMiddlewareCtx({ permissionSets: [adminSet], objectFields: ownerFields });
302+
await plugin.init(harness.ctx);
303+
await plugin.start(harness.ctx);
304+
const opCtx: any = {
305+
object: 'task', operation: 'update',
306+
data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } },
307+
context: { userId: 'admin', roles: ['admin_full_access'], permissions: [] },
308+
};
309+
await expect(harness.run(opCtx)).resolves.toBeDefined();
310+
expect(harness.findOne).not.toHaveBeenCalled();
311+
});
312+
313+
it('SKIPS the check for a multi-row predicate id ({$in}) — only single-id by-pk writes are guarded', async () => {
314+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
315+
const harness = makeMiddlewareCtx({
316+
permissionSets: [ownerPolicySet], objectFields: ownerFields, findOneImpl: () => null,
317+
});
318+
await plugin.init(harness.ctx);
319+
await plugin.start(harness.ctx);
320+
const opCtx: any = {
321+
object: 'task', operation: 'update', multi: true,
322+
data: { name: 'bulk' }, options: { multi: true, where: { id: { $in: ['r1', 'r2'] } } },
323+
context: memberCtx,
324+
};
325+
await harness.run(opCtx);
326+
expect(harness.findOne).not.toHaveBeenCalled();
327+
});
328+
});
329+
229330
it('tenancy.enabled=false via systemFields.tenant=false — also skipped', async () => {
230331
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
231332
const harness = makeMiddlewareCtx({

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,67 @@ export class SecurityPlugin implements Plugin {
338338
}
339339
}
340340

341+
// 2.7. Row-level WRITE authorization (pre-image check).
342+
//
343+
// RLS is injected as a `where` filter on the read path (step 3, via
344+
// `opCtx.ast`), but a single-id update/delete goes straight to
345+
// `driver.update(object, id, …)` / `driver.delete(object, id)` — it builds
346+
// no `ast`, so the row-level predicate is NEVER applied to by-id writes.
347+
// The result (#1985): the CRUD check passes (member_default grants edit/
348+
// delete) and the owner/tenant RLS that was supposed to scope the write is
349+
// silently bypassed — any member could modify another user's record.
350+
//
351+
// Fix: before the mutation, compute the write-operation RLS filter and
352+
// verify the TARGET row satisfies it. We re-read the row through the
353+
// engine with `{ id } AND <writeFilter>`; a `find` does not re-enter this
354+
// block, so there is no recursion, and read-side RLS/tenant scoping
355+
// compose naturally. A `null` result means the row is either gone or
356+
// RLS-hidden → deny. When `computeRlsFilter` returns `null` (no policy
357+
// applies — e.g. an admin set with no RLS, or `modifyAllRecords`) the
358+
// check is skipped and behaviour is unchanged.
359+
if (
360+
(opCtx.operation === 'update' || opCtx.operation === 'delete') &&
361+
permissionSets.length > 0 &&
362+
!!opCtx.context?.userId &&
363+
this.ql
364+
) {
365+
const targetId = this.extractSingleId(opCtx);
366+
if (targetId != null) {
367+
const writeFilter = await this.computeRlsFilter(
368+
permissionSets,
369+
opCtx.object,
370+
opCtx.operation,
371+
opCtx.context,
372+
);
373+
if (writeFilter) {
374+
let visible: unknown = null;
375+
try {
376+
visible = await this.ql.findOne(opCtx.object, {
377+
where: { $and: [{ id: targetId }, writeFilter] },
378+
context: opCtx.context,
379+
});
380+
} catch {
381+
// A read denial (e.g. no read permission) is itself a "cannot
382+
// touch this row" signal — fall through to the deny below.
383+
visible = null;
384+
}
385+
if (!visible) {
386+
throw new PermissionDeniedError(
387+
`[Security] Access denied: not permitted to ${opCtx.operation} this ` +
388+
`'${opCtx.object}' record (row-level security)`,
389+
{
390+
operation: opCtx.operation,
391+
object: opCtx.object,
392+
roles,
393+
permissionSets: explicitPermissionSets,
394+
recordId: targetId,
395+
},
396+
);
397+
}
398+
}
399+
}
400+
}
401+
341402
// 2.5. Field-Level Security write enforcement.
342403
//
343404
// The client-side masker (ObjectForm / inline grid) already hides
@@ -659,6 +720,28 @@ export class SecurityPlugin implements Plugin {
659720
return permissionSets;
660721
}
661722

723+
/**
724+
* Resolve a single scalar primary-key id from an update/delete operation
725+
* context, mirroring the engine's "single-id vs predicate" rule
726+
* (`engine.ts` update/delete): only a scalar `data.id` or `where.id`
727+
* identifies one row. An operator object (`{ $in: [...] }`, …) is a
728+
* multi-row predicate and returns `null` (multi-row writes route through the
729+
* `*Many` paths, out of scope for the by-id pre-image check).
730+
*/
731+
private extractSingleId(opCtx: any): string | number | bigint | null {
732+
const isScalar = (v: unknown): v is string | number | bigint =>
733+
v !== null && (typeof v === 'string' || typeof v === 'number' || typeof v === 'bigint');
734+
const data = opCtx?.data;
735+
if (data && typeof data === 'object' && !Array.isArray(data) && isScalar(data.id)) {
736+
return data.id;
737+
}
738+
const where = opCtx?.options?.where;
739+
if (where && typeof where === 'object' && 'id' in where && isScalar((where as any).id)) {
740+
return (where as any).id;
741+
}
742+
return null;
743+
}
744+
662745
/**
663746
* Compile the applicable RLS policies for (object, operation) into a single
664747
* `FilterCondition`, applying the field-existence safety net (wildcard

0 commit comments

Comments
 (0)