Multi-Tenant Authorization with Zenstack

BB

Scaling a solo-developer project with an open source authorization solution.

I recently launched a SaaS product that utilizes a multi-tenant architecture where users could be associated with multiple accounts. At the start, I was using Supabase RLS to manage authorization policies, but I found that this approach was difficult to manage and debug. Because I'm the only developer working on the project, I realized that I needed tighter coupling with my application code so I could iterate more quickly.

Luckily, I found Zenstack before I went off on a side-quest trying to solve Authorization on the API-level.

Zenstack extends the Prisma ORM to add an authorization layer and code generation. It's a great way to manage the complexity of authorization while dropping a huge chunk of boilerplate code in favor of generated code based on your database schema.

My techstack was Sveltekit and Supabase, but I ended up adding Prisma and Zenstack to the mix.

Creating Multi-Tenant Access Control Policies

Zenstack extends the prisma schema file with schema.zmodel and your authorization policies are defined in the schema file.

You first need to create a typical Profile or User model, which is combined with a user's session and zenstack policies to control access to the data.

model Profile {
  id               String                    @id @db.Uuid

  accountMembers   AccountMember[]

  // Zenstack authorization policies applied to this model
  // Declare that this model is used for auth()
  @@auth()

  // Must be an authenticated request
  @@deny('all', auth() == null)

  // Don't allow a user to create or delete their own profile
  @@deny('create, delete', true)

  // User can only read and update own profile
  @@allow('update', auth().id == id)

  // can be read by users sharing a account
  @@allow('read', (auth().id == id) || (accountMembers?[account.accountMembers?[userId == auth().id]]))
}

Supabase already has a private users table, but it can't be used in the schema file. The workaround is to create a public table that mirrors the Supabase auth.users table. You can use supabase triggers to keep the two tables in sync. Reference their official guide to learn how to do this.

The authorization rules are applied to the model and the @@auth() directive is used to resolve the auth() function used in other models.

If you want to require that a user is authenticated in order to read rows in a table, you can add an access policy with @@deny('all', auth() == null).

model Account {
  // Require login to read any account
  @@deny('all', auth() == null)
}

Moving the Authorization logic from Supabase RLS policies into a schema file adds tighter coupling with the application code, which ended up being ideal for me. I found the schema file to be intuitive (and Zenstack has thorough documentation) and could track down issues more quickly.

Zenstack schema is similar to Prisma schema, but you can declare authorization policies for the entire model.

model Account {
  id                String             @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  accountMembers    AccountMember[]

  // require login
  @@deny('all', auth() == null)
  
  // an account cannot be deleted by a user
  @@deny('delete', true)

  // everyone can create an account where they are the owner
  @@allow('create', accountMembers?[userId == auth().id && role == 'super_admin'])

  // any user in the account can read the account
  @@allow('read', accountMembers?[userId == auth().id])

  // account owner can update
  @@allow('update', accountMembers?[userId == auth().id && role == 'super_admin'])
}

There are some cases where it's ideal to make a field read-only or hidden. I prefer the Zenstack approach to field-level access control compared to using Supabase triggers.

model AccountMember {
  id                       String                @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  userId                   String                @db.Uuid @allow('update', false)
  accountId                String                @db.Uuid @allow('update', false)
  role                     String                @default("member")
  ...
}

There's also the future() function that can be used to check the future state for an update attempt.

model AccountMember {
  ...
  // the super_admin role cannot be changed
  @@deny('update', future().role == 'super_admin')
}

Tying the User's Session to the Authorization Policies

But how do you go from modeling the data to applying the authorization policies in the actual application?

It starts with the enhanced prisma client where you pass the user's session information. In my application, I use Supabase for authentication and pass the user's ID and email to the client.

export function getEnhancedPrisma(userId?: string, email?: string) {
	return enhance(prisma, {
		user: userId ? { id: userId, email: email } : undefined,
	});
}

In the schema.zmodel file, the auth() function can be used to reference the ID and email of the logged in user with auth().id and auth().email.

Instead of calling the Prisma client directly, you call the enhanced Prisma client.

Since I'm using Sveltekit, I updated hooks.server.ts to initialize the enhanced Prisma client.

const dbClientHandle: Handle = async ({ event, resolve }) => {
  const session = await event.locals.getSession();

  const id = session.user.id;
  const email = session.user.email;

  event.locals.db = getEnhancedPrisma(id, email);

  return await resolve(event);
};

I use the enhanced Prisma client in server load functions to query the database using standard Prisma queries.

export const load = async ({ fetch, url, locals, request, depends }) => {
    const session = await locals.getSession();
    const user = await locals.db.profile.findUniqueOrThrow({
        // this isn't necessary if you're using the enhanced client with access control policies
        where: {
            id: session.user.id
        }
    });
    ...

Zenstack's Sveltekit guide walks though this entire process.

Hook Generation

One of the biggest advantages of Zenstack is code generation based on your models. I normally find myself re-creating the same hooks and API routes, but I was able to delete a huge portion of my codebase and drop in Zenstack's generated code.

Zenstack offers numerous plugins, which are easily added by updated the schema.zmodel file. For example, the @zenstackhq/tanstack-query plugin generates hooks based on the CRUD operations you have defined in your model.

You can add plugins to the schema.zmodel file like this:

plugin hooks {
  provider = '@zenstackhq/tanstack-query'
  output = 'src/lib/hooks/zenstack'
  target = 'svelte'
}

And then Zenstack generates a folder with a file for each model containing the hooks.

Here are a few examples of the hooks that are generated for an Account model.

useCreateAccount
useCreateManyAccount
useFindManyAccount
useFindUniqueAccount
useFindFirstAccount
...

Hook generation is thorough and covers all the CRUD operations.

You can pass a prisma query in the first argument and the second argument is the standard tanstack query options.

useFindUniqueAccount(
    {
        where: {
            id: $page.params.accountId
        }
    },
    {
      initialData: data.account
    }
  );

One of the more impressive features of the generated hooks is that they are fully typed, even if you are joining data.

From Server to Client

To take advantage of SSR to pre-fetch the data, and maybe even redirect or gate access if the data was not found, it's useful to query the data on the server before the page is rendered. In my case, I used Sveltekit's load function to do this.

// +page.server.ts
export const load = async (context) => {
    ...
	const account = await AccountQueries.findUniqueQuery(prisma, {
		id: accountId
	});

	if (!account) {
		redirect(303, "/create-account");
	}

	return {
		account,
	};
};

Then pass the data to the initialData property of the hook and use tanstack query as normal.

<script lang="ts">
...
  const account = useFindUniqueAccount(
    AccountQueries.findUniqueArgs({ id: $page.params.accountId }),
    {
      initialData: data.account
    }
  );
</script>

{#if $account.isLoading}
...
{#if $account.isError}
...

Now you can leverage caching and take advantage of the features provided by tanstack-query.

Migrating from Supabase RLS to Zenstack

I heavily depend on Supabase for Auth, database management, and webhooks, but ultimately moved away from their RLS policies in favor of Zenstack.

The migration was easier than I thought it would be, but I discovered a few things along the way.

First, once you remove RLS policies, you still need to keep RLS enabled on all tables in your Supabase project. You just dont need to set any policies. If you don't enable RLS, your tables will be public and anyone can read or write to them.

Next, you need to integrate Prisma with Supabase. Previously, I was using the Supabase SDK with generated database types. Adding in the Prisma ORM on top of this took some work, but it was ultimately worth it. Prisma has better type generation when you are joining tables, so this was an added bonus.

Finally, you add Zenstack to the project and pull your schema changes from Prisma. Going forward, all schema changes are made through Zenstack's schema file. You should never update the Prisma schema or supabase schema directly

Deploying changes

Once you add Zenstack to your project, you sync the Prisma schema. Going forward, you don't update the Prisma schema - all changes are made in the Zenstack schema file to ensure everything stays in sync.

The development workflow for schema changes looks like this:

  1. Update Zenstack schema file with changes
  2. Run zenstack generate to generate the Prisma schema and the application code based on the schema (depending on the plugins you are using). For example, if you are using the hooks plugin, it will re-generate hooks for you.
  3. Run prisma db push to push the changes to the database (Supabase in my case).
  4. Confirm the changes are good to go, then prisma migrate dev (or supabase db diff if you are using Supabase)

Supabase Migrations

One thing I want to note when working with Supabase in your stack is that you should use supabase migrations instead of Prisma migrations. This is because Supabase offers some features that cannot be handled using Prisma migrations.

For example, Supabase triggers and webhooks don't seem to be supported by prisma migrations at the moment. Fortunately, it's simple enough to run supabase db diff to generate a migration file.

Results

Integrating Zenstack into my project allowed me to delete about 20% of my codebase. I had created my own hooks, API routes, and wrappers for supabase queries (to solve for Typing issues) which were no longer needed. The time I spent migrating over to Zenstack was quickly made up for by the speed at which I was able to iterate due to the tight coupling of authorization policies with my application code.