Simulating Row-Level Security in MySQL with Clerk and Prisma

MySQL doesn’t natively support RLS, so if you’re using PlanetScale like I do, you’re out of luck. However, there is a way of working around this by extending your Prisma client with a custom extension.

Oct 9, 2024

5 min read

Row-level security (RLS) is a popular PostgreSQL concept where tables can have row security policies that restrict, on a per-user basis, which rows can be returned by normal queries or inserted, updated, or deleted by data modification commands.

Unfortunately, MySQL doesn’t natively support this and PlanetScale have said they may look into it later. You can work around this by implementing views, which expose a specific set of rows on a table for a user. However, if you’re looking for a way that doesn’t involve messing around with the database or infrastructure, I may have something for you.

Configuring the Schema

First, let’s assume that we’re working with a simple Prisma schema with a Page model. We store pages with a userId (identifying the owner) and an organizationId (identifying the organization it belongs to).

model Page {
  id              String   @id @default(cuid())
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt
  name            String
  
  // We'll use these fields to authenticate
  userId          String
  organizationId  String

  @@map(name: "page")
}

Easy peasy.

Extending the Prisma Client

Next, we need to intercept operations made on this Page model. We can do this with a Prisma custom extension that uses the $allOperations method to modify all query methods present on the model.

For example, say we have a Prisma client like this:

import 'server-only';

import { Client, connect } from '@planetscale/database';
import { PrismaPlanetScale } from '@prisma/adapter-planetscale';
import { PrismaClient } from '@prisma/client';

const client = new Client({ url: process.env.databaseUrl });
const adapter = new PrismaPlanetScale(client);
const prisma = new PrismaClient({ adapter });;

export const database = prisma;

We can extend the exported database object with the $extends function:

export const database = prisma.$extends({
  query: {
    page: {
      async $allOperations({ operation, model, args, query }) {
        // Do intercept-y stuff here.

        return query(args);
      },
    },
  },
});

Securing operations

Now that we have a way of intercepting transactions, let’s bring in Clerk and use the current user and organization to authenticate requests.

After checking that the user is indeed logged in, we can intercept all non-create operations and inject a where clause. I say non-create as the create method doesn’t take where as it’s not a query, as opposed to select, update, delete, etc.

import 'server-only';

import { auth, currentUser } from '@clerk/nextjs/server';
import { Client, connect } from '@planetscale/database';
import { PrismaPlanetScale } from '@prisma/adapter-planetscale';
import { PrismaClient } from '@prisma/client';

const client = new Client({ url: process.env.databaseUrl });
const adapter = new PrismaPlanetScale(client);
const prisma = new PrismaClient({ adapter });;

export const database = prisma.$extends({
  query: {
    page: {
      async $allOperations({ operation, model, args, query }) {
        const { orgId } = await auth();
        const user = await currentUser();

        if (!user || !orgId) {
          throw new Error('Unauthorized');
        }

        if (!operation.includes('create')) {
          args.where = {
            ...(args.where ?? {}),
            organizationId: orgId,
            userId: user.id,
          };
        }

        return query(args);
      },
    },
  },
});

That’s it! We now have some rudimentary row-level security in our MySQL application. While it’s not at the database layer, at least we can rest easy knowing that our transactions are somewhat safer.

Join 2,000 readers and get infrequent updates on new projects.

+2.2K

I promise not to spam you or sell your email address.