convex-helpers
Version:
A collection of useful code to complement the official convex package.
251 lines (247 loc) • 8.59 kB
JavaScript
import { filter } from "./filter.js";
/**
* Apply row level security (RLS) to queries and mutations with the returned
* middleware functions.
* @deprecated Use `wrapDatabaseReader`/`Writer` with `customFunction` instead.
*
* Example:
* ```
* // Defined in a common file so it can be used by all queries and mutations.
* import { Auth } from "convex/server";
* import { DataModel } from "./_generated/dataModel";
* import { DatabaseReader } from "./_generated/server";
* import { RowLevelSecurity } from "./rowLevelSecurity";
*
* export const {withMutationRLS} = RowLevelSecurity<{auth: Auth, db: DatabaseReader}, DataModel>(
* {
* cookies: {
* read: async ({auth}, cookie) => !cookie.eaten,
* modify: async ({auth, db}, cookie) => {
* const user = await getUser(auth, db);
* return user.isParent; // only parents can reach the cookies.
* },
* },
* { defaultPolicy: "deny" }
* );
* // Mutation with row level security enabled.
* export const eatCookie = mutation(withMutationRLS(
* async ({db}, {cookieId}) => {
* // throws "does not exist" error if cookie is already eaten or doesn't exist.
* // throws "write access" error if authorized user is not a parent.
* await db.patch(cookieId, {eaten: true});
* }));
* ```
*
* Notes:
* * Rules may read any row in `db` -- rules do not apply recursively within the
* rule functions themselves.
* * Tables with no rule have `defaultPolicy` set to "allow" by default.
* * Middleware functions like `withUser` can be composed with RowLevelSecurity
* to cache fetches in `ctx`. e.g.
* ```
* const {withQueryRLS} = RowLevelSecurity<{user: Doc<"users">}, DataModel>(
* {
* cookies: async ({user}, cookie) => user.isParent,
* },
* { defaultPolicy: "deny" }
* );
* export default query(withUser(withRLS(...)));
* ```
*
* @param rules - rule for each table, determining whether a row is accessible.
* - "read" rule says whether a document should be visible.
* - "modify" rule says whether to throw an error on `replace`, `patch`, and `delete`.
* - "insert" rule says whether to throw an error on `insert`.
*
* @returns Functions `withQueryRLS` and `withMutationRLS` to be passed to
* `query` or `mutation` respectively.
* For each row read, modified, or inserted, the security rules are applied.
*/
export const RowLevelSecurity = (rules, config) => {
const withMutationRLS = (f) => {
return ((ctx, ...args) => {
const wrappedDb = new WrapWriter(ctx, ctx.db, rules, config);
return f({ ...ctx, db: wrappedDb }, ...args);
});
};
const withQueryRLS = (f) => {
return ((ctx, ...args) => {
const wrappedDb = new WrapReader(ctx, ctx.db, rules, config);
return f({ ...ctx, db: wrappedDb }, ...args);
});
};
return {
withMutationRLS,
withQueryRLS,
};
};
/**
* If you just want to read from the DB, you can copy this.
* Later, you can use `generateQueryWithMiddleware` along
* with a custom function using wrapQueryDB with rules that
* depend on values generated once at the start of the function.
* E.g. Looking up a user to use for your rules:
* //TODO: Add example
export function BasicRowLevelSecurity(
rules: Rules<GenericQueryCtx<DataModel>, DataModel>
) {
return {
queryWithRLS: customQuery(
query,
customCtx((ctx) => ({ db: wrapDatabaseReader(ctx, ctx.db, rules) }))
),
mutationWithRLS: customMutation(
mutation,
customCtx((ctx) => ({ db: wrapDatabaseWriter(ctx, ctx.db, rules) }))
),
internalQueryWithRLS: customQuery(
internalQuery,
customCtx((ctx) => ({ db: wrapDatabaseReader(ctx, ctx.db, rules) }))
),
internalMutationWithRLS: customMutation(
internalMutation,
customCtx((ctx) => ({ db: wrapDatabaseWriter(ctx, ctx.db, rules) }))
),
};
}
*/
export function wrapDatabaseReader(ctx, db, rules, config) {
return new WrapReader(ctx, db, rules, config);
}
export function wrapDatabaseWriter(ctx, db, rules, config) {
return new WrapWriter(ctx, db, rules, config);
}
class WrapReader {
ctx;
db;
system;
rules;
config;
constructor(ctx, db, rules, config) {
this.ctx = ctx;
this.db = db;
this.system = db.system;
this.rules = rules;
this.config = config ?? { defaultPolicy: "allow" };
}
normalizeId(tableName, id) {
return this.db.normalizeId(tableName, id);
}
tableName(id) {
for (const tableName of Object.keys(this.rules)) {
if (this.db.normalizeId(tableName, id)) {
return tableName;
}
}
return null;
}
async predicate(tableName, doc) {
if (!this.rules[tableName]?.read) {
return (this.config.defaultPolicy ?? "allow") === "allow";
}
return await this.rules[tableName].read(this.ctx, doc);
}
async get(arg0, arg1) {
const [tableName, id] = arg1 !== undefined ? [arg0, arg1] : [this.tableName(arg0), arg0];
const doc = await this.db.get(id);
if (doc) {
if (tableName && !(await this.predicate(tableName, doc))) {
return null;
}
return doc;
}
return null;
}
query(tableName) {
return filter(this.db.query(tableName), (d) => this.predicate(tableName, d));
}
}
class WrapWriter {
ctx;
db;
system;
reader;
rules;
config;
async modifyPredicate(tableName, doc) {
if (!this.rules[tableName]?.modify) {
return (this.config.defaultPolicy ?? "allow") === "allow";
}
return await this.rules[tableName].modify(this.ctx, doc);
}
constructor(ctx, db, rules, config) {
this.ctx = ctx;
this.db = db;
this.system = db.system;
this.reader = new WrapReader(ctx, db, rules, config);
this.rules = rules;
this.config = config ?? { defaultPolicy: "allow" };
}
normalizeId(tableName, id) {
return this.db.normalizeId(tableName, id);
}
async insert(table, value) {
const rules = this.rules[table];
if (rules?.insert) {
if (!(await rules.insert(this.ctx, value))) {
throw new Error("insert access not allowed");
}
}
else if ((this.config.defaultPolicy ?? "allow") === "deny") {
throw new Error("insert access not allowed");
}
return await this.db.insert(table, value);
}
tableName(id) {
for (const tableName of Object.keys(this.rules)) {
if (this.db.normalizeId(tableName, id)) {
return tableName;
}
}
return null;
}
async checkAuth(tableNameArg, id) {
// 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 = tableNameArg
? await this.get(tableNameArg, id)
: await this.get(id);
if (doc === null) {
throw new Error("no read access or doc does not exist");
}
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(arg0, arg1, arg2) {
const [tableName, id, value] = arg2 !== undefined ? [arg0, arg1, arg2] : [null, arg0, arg1];
await this.checkAuth(tableName, id);
return tableName
? this.db.patch(tableName, id, value)
: this.db.patch(id, value);
}
async replace(arg0, arg1, arg2) {
const [tableName, id, value] = arg2 !== undefined ? [arg0, arg1, arg2] : [null, arg0, arg1];
await this.checkAuth(tableName, id);
return tableName
? this.db.replace(tableName, id, value)
: this.db.replace(id, value);
}
async delete(arg0, arg1) {
const [tableName, id] = arg1 !== undefined ? [arg0, arg1] : [null, arg0];
await this.checkAuth(tableName, id);
return tableName ? this.db.delete(tableName, id) : this.db.delete(id);
}
get(arg0, arg1) {
return this.reader.get(arg0, arg1);
}
query(tableName) {
return this.reader.query(tableName);
}
}