Skip to content

Commit 4394a55

Browse files
committed
Merge branch 'ebg1223/main'
2 parents 5dafed8 + b98573f commit 4394a55

File tree

2 files changed

+225
-7
lines changed

2 files changed

+225
-7
lines changed

packages/convex-helpers/server/rowLevelSecurity.test.ts

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { convexTest } from "convex-test";
22
import { v } from "convex/values";
33
import { describe, expect, test } from "vitest";
4-
import { wrapDatabaseWriter } from "./rowLevelSecurity.js";
4+
import { wrapDatabaseReader, wrapDatabaseWriter } from "./rowLevelSecurity.js";
55
import type {
66
Auth,
77
DataModelFromSchemaDefinition,
@@ -26,6 +26,13 @@ const schema = defineSchema({
2626
note: v.string(),
2727
userId: v.id("users"),
2828
}),
29+
publicData: defineTable({
30+
content: v.string(),
31+
}),
32+
privateData: defineTable({
33+
content: v.string(),
34+
ownerId: v.id("users"),
35+
}),
2936
});
3037

3138
type DataModel = DataModelFromSchemaDefinition<typeof schema>;
@@ -100,6 +107,196 @@ describe("row level security", () => {
100107
return rls.db.delete(noteId);
101108
});
102109
});
110+
111+
test("default allow policy permits access to tables without rules", async () => {
112+
const t = convexTest(schema, modules);
113+
await t.run(async (ctx) => {
114+
const userId = await ctx.db.insert("users", {
115+
tokenIdentifier: "Person A",
116+
});
117+
await ctx.db.insert("publicData", { content: "Public content" });
118+
await ctx.db.insert("privateData", {
119+
content: "Private content",
120+
ownerId: userId,
121+
});
122+
});
123+
124+
const asA = t.withIdentity({ tokenIdentifier: "Person A" });
125+
const result = await asA.run(async (ctx) => {
126+
const tokenIdentifier = (await ctx.auth.getUserIdentity())
127+
?.tokenIdentifier;
128+
if (!tokenIdentifier) throw new Error("Unauthenticated");
129+
130+
// Default allow - no config specified
131+
const db = wrapDatabaseReader({ tokenIdentifier }, ctx.db, {
132+
notes: {
133+
read: async ({ tokenIdentifier }, doc) => {
134+
const author = await ctx.db.get(doc.userId);
135+
return tokenIdentifier === author?.tokenIdentifier;
136+
},
137+
},
138+
});
139+
140+
// Should be able to read publicData (no rules defined)
141+
const publicData = await db.query("publicData").collect();
142+
// Should be able to read privateData (no rules defined)
143+
const privateData = await db.query("privateData").collect();
144+
145+
return { publicData, privateData };
146+
});
147+
148+
expect(result.publicData).toHaveLength(1);
149+
expect(result.privateData).toHaveLength(1);
150+
});
151+
152+
test("default deny policy blocks access to tables without rules", async () => {
153+
const t = convexTest(schema, modules);
154+
await t.run(async (ctx) => {
155+
const userId = await ctx.db.insert("users", {
156+
tokenIdentifier: "Person A",
157+
});
158+
await ctx.db.insert("publicData", { content: "Public content" });
159+
await ctx.db.insert("privateData", {
160+
content: "Private content",
161+
ownerId: userId,
162+
});
163+
});
164+
165+
const asA = t.withIdentity({ tokenIdentifier: "Person A" });
166+
const result = await asA.run(async (ctx) => {
167+
const tokenIdentifier = (await ctx.auth.getUserIdentity())
168+
?.tokenIdentifier;
169+
if (!tokenIdentifier) throw new Error("Unauthenticated");
170+
171+
// Default deny policy
172+
const db = wrapDatabaseReader(
173+
{ tokenIdentifier },
174+
ctx.db,
175+
{
176+
notes: {
177+
read: async ({ tokenIdentifier }, doc) => {
178+
const author = await ctx.db.get(doc.userId);
179+
return tokenIdentifier === author?.tokenIdentifier;
180+
},
181+
},
182+
// Explicitly allow publicData
183+
publicData: {
184+
read: async () => true,
185+
},
186+
},
187+
{ defaultPolicy: "deny" },
188+
);
189+
190+
// Should be able to read publicData (has explicit allow rule)
191+
const publicData = await db.query("publicData").collect();
192+
// Should NOT be able to read privateData (no rules, default deny)
193+
const privateData = await db.query("privateData").collect();
194+
195+
return { publicData, privateData };
196+
});
197+
198+
expect(result.publicData).toHaveLength(1);
199+
expect(result.privateData).toHaveLength(0);
200+
});
201+
202+
test("default deny policy blocks inserts to tables without rules", async () => {
203+
const t = convexTest(schema, modules);
204+
await t.run(async (ctx) => {
205+
await ctx.db.insert("users", { tokenIdentifier: "Person A" });
206+
});
207+
208+
const asA = t.withIdentity({ tokenIdentifier: "Person A" });
209+
210+
// Test with default allow
211+
await asA.run(async (ctx) => {
212+
const tokenIdentifier = (await ctx.auth.getUserIdentity())
213+
?.tokenIdentifier;
214+
if (!tokenIdentifier) throw new Error("Unauthenticated");
215+
216+
const db = wrapDatabaseWriter(
217+
{ tokenIdentifier },
218+
ctx.db,
219+
{},
220+
{ defaultPolicy: "allow" },
221+
);
222+
223+
// Should be able to insert (no rules, default allow)
224+
await db.insert("publicData", { content: "Allowed content" });
225+
});
226+
227+
// Test with default deny
228+
await expect(() =>
229+
asA.run(async (ctx) => {
230+
const tokenIdentifier = (await ctx.auth.getUserIdentity())
231+
?.tokenIdentifier;
232+
if (!tokenIdentifier) throw new Error("Unauthenticated");
233+
234+
const db = wrapDatabaseWriter(
235+
{ tokenIdentifier },
236+
ctx.db,
237+
{},
238+
{ defaultPolicy: "deny" },
239+
);
240+
241+
// Should NOT be able to insert (no rules, default deny)
242+
await db.insert("publicData", { content: "Blocked content" });
243+
}),
244+
).rejects.toThrow(/insert access not allowed/);
245+
});
246+
247+
test("default deny policy blocks modifications to tables without rules", async () => {
248+
const t = convexTest(schema, modules);
249+
const docId = await t.run(async (ctx) => {
250+
await ctx.db.insert("users", { tokenIdentifier: "Person A" });
251+
return ctx.db.insert("publicData", { content: "Initial content" });
252+
});
253+
254+
const asA = t.withIdentity({ tokenIdentifier: "Person A" });
255+
256+
// Test with default allow
257+
await asA.run(async (ctx) => {
258+
const tokenIdentifier = (await ctx.auth.getUserIdentity())
259+
?.tokenIdentifier;
260+
if (!tokenIdentifier) throw new Error("Unauthenticated");
261+
262+
const db = wrapDatabaseWriter(
263+
{ tokenIdentifier },
264+
ctx.db,
265+
{
266+
publicData: {
267+
read: async () => true, // Allow reads
268+
},
269+
},
270+
{ defaultPolicy: "allow" },
271+
);
272+
273+
// Should be able to modify (no modify rule, default allow)
274+
await db.patch(docId, { content: "Modified content" });
275+
});
276+
277+
// Test with default deny
278+
await expect(() =>
279+
asA.run(async (ctx) => {
280+
const tokenIdentifier = (await ctx.auth.getUserIdentity())
281+
?.tokenIdentifier;
282+
if (!tokenIdentifier) throw new Error("Unauthenticated");
283+
284+
const db = wrapDatabaseWriter(
285+
{ tokenIdentifier },
286+
ctx.db,
287+
{
288+
publicData: {
289+
read: async () => true, // Allow reads but no modify rule
290+
},
291+
},
292+
{ defaultPolicy: "deny" },
293+
);
294+
295+
// Should NOT be able to modify (no modify rule, default deny)
296+
await db.patch(docId, { content: "Blocked modification" });
297+
}),
298+
).rejects.toThrow(/write access not allowed/);
299+
});
103300
});
104301

105302
const mutation = mutationGeneric as MutationBuilder<DataModel, "public">;

packages/convex-helpers/server/rowLevelSecurity.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ export type Rules<Ctx, DataModel extends GenericDataModel> = {
2626
};
2727
};
2828

29+
export type RLSConfig = {
30+
/**
31+
* Default policy when no rule is defined for a table.
32+
* - "allow": Allow access by default (default behavior)
33+
* - "deny": Deny access by default
34+
*/
35+
defaultPolicy?: "allow" | "deny";
36+
};
37+
2938
/**
3039
* Apply row level security (RLS) to queries and mutations with the returned
3140
* middleware functions.
@@ -153,16 +162,18 @@ export function wrapDatabaseReader<Ctx, DataModel extends GenericDataModel>(
153162
ctx: Ctx,
154163
db: GenericDatabaseReader<DataModel>,
155164
rules: Rules<Ctx, DataModel>,
165+
config?: RLSConfig,
156166
): GenericDatabaseReader<DataModel> {
157-
return new WrapReader(ctx, db, rules);
167+
return new WrapReader(ctx, db, rules, config);
158168
}
159169

160170
export function wrapDatabaseWriter<Ctx, DataModel extends GenericDataModel>(
161171
ctx: Ctx,
162172
db: GenericDatabaseWriter<DataModel>,
163173
rules: Rules<Ctx, DataModel>,
174+
config?: RLSConfig,
164175
): GenericDatabaseWriter<DataModel> {
165-
return new WrapWriter(ctx, db, rules);
176+
return new WrapWriter(ctx, db, rules, config);
166177
}
167178

168179
type ArgsArray = [] | [FunctionArgs<any>];
@@ -178,16 +189,19 @@ class WrapReader<Ctx, DataModel extends GenericDataModel>
178189
db: GenericDatabaseReader<DataModel>;
179190
system: GenericDatabaseReader<DataModel>["system"];
180191
rules: Rules<Ctx, DataModel>;
192+
config: RLSConfig;
181193

182194
constructor(
183195
ctx: Ctx,
184196
db: GenericDatabaseReader<DataModel>,
185197
rules: Rules<Ctx, DataModel>,
198+
config?: RLSConfig,
186199
) {
187200
this.ctx = ctx;
188201
this.db = db;
189202
this.system = db.system;
190203
this.rules = rules;
204+
this.config = config ?? { defaultPolicy: "allow" };
191205
}
192206

193207
normalizeId<TableName extends TableNamesInDataModel<DataModel>>(
@@ -213,7 +227,7 @@ class WrapReader<Ctx, DataModel extends GenericDataModel>
213227
doc: DocumentByInfo<T>,
214228
): Promise<boolean> {
215229
if (!this.rules[tableName]?.read) {
216-
return true;
230+
return (this.config.defaultPolicy ?? "allow") === "allow";
217231
}
218232
return await this.rules[tableName]!.read!(this.ctx, doc);
219233
}
@@ -249,13 +263,14 @@ class WrapWriter<Ctx, DataModel extends GenericDataModel>
249263
system: GenericDatabaseWriter<DataModel>["system"];
250264
reader: GenericDatabaseReader<DataModel>;
251265
rules: Rules<Ctx, DataModel>;
266+
config: RLSConfig;
252267

253268
async modifyPredicate<T extends GenericTableInfo>(
254269
tableName: string,
255270
doc: DocumentByInfo<T>,
256271
): Promise<boolean> {
257272
if (!this.rules[tableName]?.modify) {
258-
return true;
273+
return (this.config.defaultPolicy ?? "allow") === "allow";
259274
}
260275
return await this.rules[tableName]!.modify!(this.ctx, doc);
261276
}
@@ -264,12 +279,14 @@ class WrapWriter<Ctx, DataModel extends GenericDataModel>
264279
ctx: Ctx,
265280
db: GenericDatabaseWriter<DataModel>,
266281
rules: Rules<Ctx, DataModel>,
282+
config?: RLSConfig,
267283
) {
268284
this.ctx = ctx;
269285
this.db = db;
270286
this.system = db.system;
271-
this.reader = new WrapReader(ctx, db, rules);
287+
this.reader = new WrapReader(ctx, db, rules, config);
272288
this.rules = rules;
289+
this.config = config ?? { defaultPolicy: "allow" };
273290
}
274291
normalizeId<TableName extends TableNamesInDataModel<DataModel>>(
275292
tableName: TableName,
@@ -282,7 +299,11 @@ class WrapWriter<Ctx, DataModel extends GenericDataModel>
282299
value: any,
283300
): Promise<any> {
284301
const rules = this.rules[table];
285-
if (rules?.insert && !(await rules.insert(this.ctx, value))) {
302+
if (rules?.insert) {
303+
if (!(await rules.insert(this.ctx, value))) {
304+
throw new Error("insert access not allowed");
305+
}
306+
} else if ((this.config.defaultPolicy ?? "allow") === "deny") {
286307
throw new Error("insert access not allowed");
287308
}
288309
return await this.db.insert(table, value);

0 commit comments

Comments
 (0)