Stack logo
Sync up on the latest from Convex.
Lee Danilek's avatar
Lee Danilek
2 years ago

Row Level Security

image of row and security shield to represent row level security

With Convex you can implement authorization a number of ways. In this post we’ll look at implementing a row-level security abstraction by adding a layer of indirection which will validate the authorization of the user to read, write, or modify each document they interact with.

Authorization: limiting access control

One of the goals in making a secure app is restricting which users can do what. Logged-out sessions should not be allowed to view private data, and logged-in users shouldn’t be able to delete other users’ data. I’ve worked on locking down access to Dropbox files, Spark deployments, and private messages. So I got to thinking about how developers using Convex can manage permissions in their apps.

Authorization via code

Convex allows developers to write arbitrary Javascript, which runs on the server within a transaction, that can check authorization. If you want to enforce an authorization rule, you can define it in code and check it whenever you want. Maybe before a user can “Like” a post, you require that the authenticated user is connected to the post’s author through the graph of friendships.

export default likePost = mutation(async ({db, auth}, {postId}) => {
	const post = await db.get(postId);
	if (!await connectedInGraph(db, await auth.getUserIdentity(), post.author)) {
		throw new Error("you can't like that");
	}
	await db.patch(postId, {likes: post.likes + 1});
});

Although Convex allows flexible rules, it could become unruly if you need to do the same authorization check in different places. “Liking” a post should run the same authorization code as “Sharing” or “Reposting,” because the check isn’t a property of the mutation as much as it’s a property of the post. Even if everything is configured perfectly, it could all fall apart if a new engineer joins the team, makes a new mutation, and forgets the authorization check. Suddenly you have an IDOR vulnerability.

Authorization via row-level security

We want some way of saying “if you’re accessing data in the ‘posts’ table, you need to run the access check.” Finding the right layer to put this access check can be tricky — I spent a year at Dropbox moving access checks for files into a central service. One layer that works well is row-level-security (RLS) where authorization is defined on individual rows, and the checks automatically run whenever code tries to read or write the row. How would you build that in Convex?

Let’s design a simple app where users create messages. Only the message’s author can edit a message or publish it. Logged-in users can view all messages, but logged-out sessions can only view published messages. We can codify these rules in code.

// in convex/rls.js
import { customCtx, customMutation, customQuery } from "convex-helpers/server/customFunctions";
import { Rules, wrapDatabaseReader, wrapDatabaseWriter } from "convex-helpers/server/rowLevelSecurity";
import { DataModel } from "./_generated/dataModel";
import { mutation, query, QueryCtx } from "./_generated/server";

async function rlsRules(ctx: QueryCtx) {
  const identity = await ctx.auth.getUserIdentity();
  return {
    messages: {
		  read: async ({ auth }, message) => {
	      if (identity === null) {
	        return message.published;
	      }
	      return true;
	    },
			write: async ({ auth }, message) => {
	      if (identity === null) {
	        return false;
	      }
	      return message.author === identity.tokenIdentifier;
	    },
	  },
    },
  } satisfies Rules<QueryCtx, DataModel>;
}

export const queryWithRLS = customQuery(
  query,
  customCtx(async (ctx) => ({
    db: wrapDatabaseReader(ctx, ctx.db, await rlsRules(ctx)),
  })),
);

export const mutationWithRLS = customMutation(
  mutation,
  customCtx(async (ctx) => ({
    db: wrapDatabaseWriter(ctx, ctx.db, await rlsRules(ctx)),
  })),
);

Here we define some custom functions along with helper functions in the rowLevelSecurity module of the convex-helpers package. You can then use them like:

// in convex/messages.js
import { queryWithRLS, mutationWithRLS } from "./rls";

export const list = queryWithRLS({
  args: {},
	handler: async (ctx) => {
    return await ctx.db.query("messages").collect();
	},
});

export const publish = mutationWithRLS({
  args: { messageId: v.id("messages") },
	handler: async (ctx, args) => {
    await ctx.db.patch(args.messageId, {published: true});
	},
});

The custom functions wrap the ctx.db object. The wrapper intercepts each row that would be returned from db.get or db.query and filters out rows based on your read rules. It intercepts each row that would be modified by db.patch, db.replace, or db.delete and confirms that the write is allowed by modify rules, and db.insert with the insert rules.

Now we have defined the authorization checks in a single place. The access rules can depend on the authorized user through auth and they can do database reads through db. Convex runs functions close to the database and caches query results, making it efficient to run the same authorization check on each document. However, you can also compose this pattern with other wrappers to provide user-level, team-level, or otherwise checks.

Extending access functions

Customizing the rule ctx

The rules you define for reading and writing documents are given the context that is provided to the function, including the db, auth, and other objects. If you want to optimize and avoid fetching the same thing multiple times, you can customize it with other things you fetch in your custom functions.

const rules: Rules<{ viewer: User, roles: Role[] }, DataModel> = {
	users: {
		read: async ({ viewer }, user) => {
			if (!viewer) return false;
			return true;
		},
		insert: async ({ roles }, user) => {
			return roles.includes("user.create");
		},
		modify: async ({ viewer, roles }, user) => {
			if (!viewer) throw new Error("Must be authenticated to modify a user");
			if (roles.includes("admin")) return true;
			return viewer._id === user._id;
		},
	},
}

const myCustomQuery = customQuery(
  query,
  customCtx(async (ctx) => {
	  const viewer = await getCurrentUser(ctx);
		const roles = await getRoles(ctx, user);
		return {
      db: wrapDatabaseReader( { viewer, roles }, ctx.db, rules),
    })),
);

Mixing RLS with bespoke authorization rules

One somewhat-obvious thing to point out is that, while you can use this abstraction to add RLS to your app, you can also decide where to not use it, or when to do other, more complex authorization with regular functions. Convex queries and mutations run on the server, so you can safely write access checks in code, whereas with some other platforms you’re limited to a special authorization markup and your code only runs on the client.

Summary

In this post we looked at adding row-level security to endpoints by wrapping the database interface with per-document checks. As long as your documents express a logical concept that can have access control rules, you can implement security in your Convex app today, by using rowLevelSecurity in convex-helpers.

This is one of many ways to authorize access for your Convex app. Please let us know what your favorite way of managing authorization is in our Discord. Thanks for reading.

Build in minutes, scale forever.

Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started