Bright ideas and techniques for building with Convex.
Profile image
Lee Danilek
a year ago

Row Level Security: Wrappers as "Middleware"

Exciting news! There is an easier way to customize your queries, mutations, and actions. Check out this post to see how to use customFunction helpers from the convex-helpers npm package. The below post is still valid, but there's now a better way than mutation(withRLS(

Writing per-table rules for per-document read/modify/insert authorization.

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
export const {withQueryRLS, withMutationRLS} =
	RowLevelSecurity<{ auth: Auth }, DataModel>(
  {
    messages: {
		  read: async ({ auth }, message) => {
	      const identity = await auth.getUserIdentity();
	      if (identity === null) {
	        return message.published;
	      }
	      return true;
	    },
			write: async ({ auth }, message) => {
	      const identity = await auth.getUserIdentity();
	      if (identity === null) {
	        return false;
	      }
	      return message.author === identity.tokenIdentifier;
	    },
	  },
  }
);

Here we use some middleware, defined in rowLevelSecurity.ts in the convex-helpers repo.

// in convex/messages.js
import { withQueryRLS, withMutationRLS } from "./rls";

export const list = query(withQueryRLS(async ({ db }) => {
  return await db.query("messages").collect();
}));
export const publish = mutation(withMutationRLS(async ({ db }, { messageId }) => {
  await db.patch(messageId, {published: true});
}));

The middleware functions wrap queries and mutations, taking the db argument and wrapping it. 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

Composing RLS with data-fetching functions

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 compose RowLevelSecurity with middleware functions like withUser, which look up the user and pass it in the context object to the function.

export const list = query(withUser(withQueryRLS(async ({ db, user }) => {
  return await db.query("messages").collect();
})));

In this case, it will also be passed to the authorization rules, making it easy to write rules involving user properties:

// in convex/rls.js
export const {withQueryRLS, withMutationRLS} =
	RowLevelSecurity<{ auth: Auth }, DataModel>(
  {
    messages: {
		  read: async ({ user }, message) => {
        // Only show published posts to logged-in, active users.
	      if (user?.status === "active") {
	        return true;
	      }
	      return message.published;
	    },
...

You could extend this pattern to also look up the user’s team, role, and more.

Filtering out fields

This middleware pattern can be extended to filtering out individual document fields, or blocking writes to certain fields. You could have the authorization function return the modified object, for instance. By building this abstraction in a library, you’re free to replace and extend the interface to meet your needs.

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 copying rowLevelSecurity.ts into your project.

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 backend application platform with everything you need to build your project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started