Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 142 additions & 50 deletions packages/convex-helpers/server/rowLevelSecurity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,27 +84,59 @@ describe("row level security", () => {
expect(notesB).toMatchObject([{ note: "Hello from Person B" }]);
});

test("cannot delete someone else's note", async () => {
const t = convexTest(schema, modules);
const noteId = await t.run(async (ctx) => {
const aId = await ctx.db.insert("users", { tokenIdentifier: "Person A" });
await ctx.db.insert("users", { tokenIdentifier: "Person B" });
return ctx.db.insert("notes", {
note: "Hello from Person A",
userId: aId,
describe("cannot delete someone else's note", () => {
test("implicit table names", async () => {
const t = convexTest(schema, modules);
const noteId = await t.run(async (ctx) => {
const aId = await ctx.db.insert("users", {
tokenIdentifier: "Person A",
});
await ctx.db.insert("users", { tokenIdentifier: "Person B" });
return ctx.db.insert("notes", {
note: "Hello from Person A",
userId: aId,
});
});
});
const asA = t.withIdentity({ tokenIdentifier: "Person A" });
const asB = t.withIdentity({ tokenIdentifier: "Person B" });
await expect(() =>
asB.run(async (ctx) => {
const asA = t.withIdentity({ tokenIdentifier: "Person A" });
const asB = t.withIdentity({ tokenIdentifier: "Person B" });
await expect(() =>
asB.run(async (ctx) => {
const rls = await withRLS(ctx);
return rls.db.delete(noteId);
}),
).rejects.toThrow(/no read access/);
await asA.run(async (ctx) => {
const rls = await withRLS(ctx);
return rls.db.delete(noteId);
}),
).rejects.toThrow(/no read access/);
await asA.run(async (ctx) => {
const rls = await withRLS(ctx);
return rls.db.delete(noteId);
});
});

test("explicit table names", async () => {
const t = convexTest(schema, modules);
const noteId = await t.run(async (ctx) => {
const aId = await ctx.db.insert("users", {
tokenIdentifier: "Person A",
});
await ctx.db.insert("users", { tokenIdentifier: "Person B" });
return ctx.db.insert("notes", {
note: "Hello from Person A",
userId: aId,
});
});
const asA = t.withIdentity({ tokenIdentifier: "Person A" });
const asB = t.withIdentity({ tokenIdentifier: "Person B" });
await expect(() =>
asB.run(async (ctx) => {
const rls = await withRLS(ctx);
// @ts-expect-error - testing new explicit table name API
return rls.db.delete("notes", noteId);
}),
).rejects.toThrow(/no read access/);
await asA.run(async (ctx) => {
const rls = await withRLS(ctx);
// @ts-expect-error - testing new explicit table name API
return rls.db.delete("notes", noteId);
});
});
});

Expand Down Expand Up @@ -244,39 +276,72 @@ describe("row level security", () => {
).rejects.toThrow(/insert access not allowed/);
});

test("default deny policy blocks modifications to tables without rules", async () => {
const t = convexTest(schema, modules);
const docId = await t.run(async (ctx) => {
await ctx.db.insert("users", { tokenIdentifier: "Person A" });
return ctx.db.insert("publicData", { content: "Initial content" });
});
describe("default deny policy blocks modifications to tables without rules", () => {
test("implicit table names", async () => {
const t = convexTest(schema, modules);
const docId = await t.run(async (ctx) => {
await ctx.db.insert("users", { tokenIdentifier: "Person A" });
return ctx.db.insert("publicData", { content: "Initial content" });
});

const asA = t.withIdentity({ tokenIdentifier: "Person A" });
const asA = t.withIdentity({ tokenIdentifier: "Person A" });

// Test with default allow
await asA.run(async (ctx) => {
const tokenIdentifier = (await ctx.auth.getUserIdentity())
?.tokenIdentifier;
if (!tokenIdentifier) throw new Error("Unauthenticated");
// Test with default allow
await asA.run(async (ctx) => {
const tokenIdentifier = (await ctx.auth.getUserIdentity())
?.tokenIdentifier;
if (!tokenIdentifier) throw new Error("Unauthenticated");

const db = wrapDatabaseWriter(
{ tokenIdentifier },
ctx.db,
{
publicData: {
read: async () => true, // Allow reads
const db = wrapDatabaseWriter(
{ tokenIdentifier },
ctx.db,
{
publicData: {
read: async () => true, // Allow reads
},
},
},
{ defaultPolicy: "allow" },
);
{ defaultPolicy: "allow" },
);

// Should be able to modify (no modify rule, default allow)
await db.patch(docId, { content: "Modified content" });
});

// Test with default deny
await expect(() =>
asA.run(async (ctx) => {
const tokenIdentifier = (await ctx.auth.getUserIdentity())
?.tokenIdentifier;
if (!tokenIdentifier) throw new Error("Unauthenticated");

const db = wrapDatabaseWriter(
{ tokenIdentifier },
ctx.db,
{
publicData: {
read: async () => true, // Allow reads but no modify rule
},
},
{ defaultPolicy: "deny" },
);

// Should be able to modify (no modify rule, default allow)
await db.patch(docId, { content: "Modified content" });
// Should NOT be able to modify (no modify rule, default deny)
await db.patch(docId, { content: "Blocked modification" });
}),
).rejects.toThrow(/write access not allowed/);
});

// Test with default deny
await expect(() =>
asA.run(async (ctx) => {
test("explicit table names", async () => {
const t = convexTest(schema, modules);
const docId = await t.run(async (ctx) => {
await ctx.db.insert("users", { tokenIdentifier: "Person A" });
return ctx.db.insert("publicData", { content: "Initial content" });
});

const asA = t.withIdentity({ tokenIdentifier: "Person A" });

// Test with default allow
await asA.run(async (ctx) => {
const tokenIdentifier = (await ctx.auth.getUserIdentity())
?.tokenIdentifier;
if (!tokenIdentifier) throw new Error("Unauthenticated");
Expand All @@ -286,16 +351,43 @@ describe("row level security", () => {
ctx.db,
{
publicData: {
read: async () => true, // Allow reads but no modify rule
read: async () => true, // Allow reads
},
},
{ defaultPolicy: "deny" },
{ defaultPolicy: "allow" },
);

// Should NOT be able to modify (no modify rule, default deny)
await db.patch(docId, { content: "Blocked modification" });
}),
).rejects.toThrow(/write access not allowed/);
// Should be able to modify (no modify rule, default allow)
// @ts-expect-error - testing new explicit table name API
await db.patch("publicData", docId, { content: "Modified content" });
});

// Test with default deny
await expect(() =>
asA.run(async (ctx) => {
const tokenIdentifier = (await ctx.auth.getUserIdentity())
?.tokenIdentifier;
if (!tokenIdentifier) throw new Error("Unauthenticated");

const db = wrapDatabaseWriter(
{ tokenIdentifier },
ctx.db,
{
publicData: {
read: async () => true, // Allow reads but no modify rule
},
},
{ defaultPolicy: "deny" },
);

// Should NOT be able to modify (no modify rule, default deny)
// @ts-expect-error - testing new explicit table name API
await db.patch("publicData", docId, {
content: "Blocked modification",
});
}),
).rejects.toThrow(/write access not allowed/);
});
});
});

Expand Down
105 changes: 84 additions & 21 deletions packages/convex-helpers/server/rowLevelSecurity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
GenericQueryCtx,
QueryInitializer,
TableNamesInDataModel,
WithOptionalSystemFields,
WithoutSystemFields,
} from "convex/server";
import type { GenericId } from "convex/values";
Expand Down Expand Up @@ -235,12 +236,18 @@ class WrapReader<Ctx, DataModel extends GenericDataModel>
return await this.rules[tableName]!.read!(this.ctx, doc);
}

async get<TableName extends string>(
get<TableName extends TableNamesInDataModel<DataModel>>(
table: NonUnion<TableName>,
id: GenericId<TableName>,
): Promise<DocumentByName<DataModel, TableName> | null> {
): Promise<DocumentByName<DataModel, TableName> | null>;
get<TableName extends TableNamesInDataModel<DataModel>>(
id: GenericId<TableName>,
): Promise<DocumentByName<DataModel, TableName> | null>;
async get(arg0: any, arg1?: any): Promise<any> {
const [tableName, id]: [string | null, GenericId<string>] =
arg1 !== undefined ? [arg0, arg1] : [this.tableName(arg0), arg0];
const doc = await this.db.get(id);
if (doc) {
const tableName = this.tableName(id);
if (tableName && !(await this.predicate(tableName, doc))) {
return null;
}
Expand Down Expand Up @@ -291,12 +298,14 @@ class WrapWriter<Ctx, DataModel extends GenericDataModel>
this.rules = rules;
this.config = config ?? { defaultPolicy: "allow" };
}

normalizeId<TableName extends TableNamesInDataModel<DataModel>>(
tableName: TableName,
id: string,
): GenericId<TableName> | null {
return this.db.normalizeId(tableName, id);
}

async insert<TableName extends string>(
table: TableName,
value: any,
Expand All @@ -311,6 +320,7 @@ class WrapWriter<Ctx, DataModel extends GenericDataModel>
}
return await this.db.insert(table, value);
}

tableName<TableName extends string>(
id: GenericId<TableName>,
): TableName | null {
Expand All @@ -321,45 +331,98 @@ class WrapWriter<Ctx, DataModel extends GenericDataModel>
}
return null;
}
async checkAuth<TableName extends string>(id: GenericId<TableName>) {

async checkAuth<TableName extends string>(
tableNameArg: string | null,
id: GenericId<TableName>,
) {
// Note all writes already do a `db.get` internally, so this isn't
// an extra read; it's just populating the cache earlier.
// Since we call `this.get`, read access controls apply and this may return
// null even if the document exists.
const doc = await this.get(id);
const doc = tableNameArg
? await this.get(tableNameArg as any, id)
: await this.get(id);
if (doc === null) {
throw new Error("no read access or doc does not exist");
}
const tableName = this.tableName(id);
const tableName = tableNameArg ?? this.tableName(id);
if (tableName === null) {
return;
}
if (!(await this.modifyPredicate(tableName, doc))) {
throw new Error("write access not allowed");
}
}
async patch<TableName extends string>(

patch<TableName extends TableNamesInDataModel<DataModel>>(
table: NonUnion<TableName>,
id: GenericId<TableName>,
value: Partial<any>,
): Promise<void> {
await this.checkAuth(id);
return await this.db.patch(id, value);
value: Partial<DocumentByName<DataModel, TableName>>,
): Promise<void>;
patch<TableName extends TableNamesInDataModel<DataModel>>(
id: GenericId<TableName>,
value: Partial<DocumentByName<DataModel, TableName>>,
): Promise<void>;
async patch(arg0: any, arg1: any, arg2?: any): Promise<void> {
const [tableName, id, value]: [string | null, GenericId<string>, any] =
arg2 !== undefined ? [arg0, arg1, arg2] : [null, arg0, arg1];
await this.checkAuth(tableName, id);
return tableName
? // @ts-expect-error -- patch supports 3 args since [email protected]
this.db.patch(tableName, id, value)
: this.db.patch(id, value);
}
async replace<TableName extends string>(

replace<TableName extends TableNamesInDataModel<DataModel>>(
table: NonUnion<TableName>,
id: GenericId<TableName>,
value: any,
): Promise<void> {
await this.checkAuth(id);
return await this.db.replace(id, value);
value: WithOptionalSystemFields<DocumentByName<DataModel, TableName>>,
): Promise<void>;
replace<TableName extends TableNamesInDataModel<DataModel>>(
id: GenericId<TableName>,
value: WithOptionalSystemFields<DocumentByName<DataModel, TableName>>,
): Promise<void>;
async replace(arg0: any, arg1: any, arg2?: any): Promise<void> {
const [tableName, id, value]: [string | null, GenericId<string>, any] =
arg2 !== undefined ? [arg0, arg1, arg2] : [null, arg0, arg1];
await this.checkAuth(tableName, id);
return tableName
? // @ts-expect-error -- replace supports 3 args since [email protected]
this.db.replace(tableName, id, value)
: this.db.replace(id, value);
}
async delete(id: GenericId<string>): Promise<void> {
await this.checkAuth(id);
return await this.db.delete(id);

delete<TableName extends TableNamesInDataModel<DataModel>>(
table: NonUnion<TableName>,
id: GenericId<TableName>,
): Promise<void>;
delete(id: GenericId<TableNamesInDataModel<DataModel>>): Promise<void>;
async delete(arg0: any, arg1?: any): Promise<void> {
const [tableName, id]: [string | null, GenericId<string>] =
arg1 !== undefined ? [arg0, arg1] : [null, arg0];
await this.checkAuth(tableName, id);

return tableName
? // @ts-expect-error -- delete supports 2 args since [email protected]
this.db.delete(tableName, id)
: this.db.delete(id);
}
get<TableName extends string>(id: GenericId<TableName>): Promise<any> {
return this.reader.get(id);

get<TableName extends TableNamesInDataModel<DataModel>>(
table: NonUnion<TableName>,
id: GenericId<TableName>,
): Promise<DocumentByName<DataModel, TableName> | null>;
get<TableName extends TableNamesInDataModel<DataModel>>(
id: GenericId<TableName>,
): Promise<DocumentByName<DataModel, TableName> | null>;
get(arg0: any, arg1?: any): Promise<any> {
// @ts-expect-error -- get supports 2 args since [email protected]
return this.reader.get(arg0, arg1);
}
query<TableName extends string>(tableName: TableName): QueryInitializer<any> {
return this.reader.query(tableName);
}
}

type NonUnion<T> = T extends never ? never : T;
Loading