Building a full-fledged multi-tenant application can be very challenging. Besides having a flexible sign-up and sign-in system, you also need to implement several other essential pieces:
- Creating and managing tenants
- User invitation flow
- Managing roles and permissions
- Enforcing data segregation and access control throughout the entire application
It sounds like lots of work, and it indeed is. You may have done this multiple times if you're a veteran SaaS developer.
Better-auth is an emerging open-source TypeScript authentication framework that offers a comprehensive set of features and great extensibility. Besides supporting a wide range of identity providers, its powerful plugin system allows you to add new features that contribute extensions across the entire stack - data model, backend API, and frontend hooks. A good example is the Organization plugin, which sets the foundation for implementing multi-tenant apps with access control.
While better-auth solves the problem of determining a user's identity and roles, ZenStack continues from there and uses such information to control what actions the user can perform on a piece of data. ZenStack is built above Prisma ORM and extends Prisma's power with flexible access control and automatic CRUD API. Since better-auth has built-in integration with Prisma, the two can make a perfect combination for building secure multi-tenant applications. This post will walk you through the steps of creating one.
The goal and the stack
The target application we'll build is a Todo List. Its core functionalities are simple: creating lists and managing todos within them. However, the focus will be on the multi-tenancy and access control aspects:
- Organization management
Users can create organizations and invite others to join. They can manage members and set their roles.
- Current context
Users can choose an organization to be the active one.
- Data segregation
Only data within the active organization can be accessed.
-
Role-based access control
- Admin members have full access to all data within their organization.
- Regular members have full access to the todo lists they own.
- Regular members can view the other members' todo lists and manage their content.
The essential weapons we'll use to build the app are:
- Next.js: the full-stack framework
- Better-Auth: user authentication and organization management
- Prisma: the ORM that we use to talk to the database
- ZenStack: enhancing Prisma with access control and automatic CRUD API
- TanStack Query: the data fetching/caching library
The benefit of this stack is that everything runs embedded inside Next.js. There are no third-party cloud services or self-hosted ones. The only thing you need is a Next.js hoster and a database provider.
You can find the link to the completed project at the end of the post.
Base setup
Better-auth's Next.js Demo provides a great starting point for us, which already includes:
- Authentication configuration
- User sign-up and sign-in flow
- Organization plugin for organization management and member invitation flow
- Dashboard for self-serviced user management and organization management
- Admin UI for managing users
One major change that we'll make to the demo is switching from Kysely to Prisma as the database client.
// lib/auth.ts
import { prismaAdapter } from 'better-auth/adapters/prisma';
export const auth = betterAuth({
appName: 'Better Auth Demo',
database: prismaAdapter(prisma, {
provider: 'sqlite',
}),
...
});
Then, install Prisma packages and generate a schema with the better-auth CLI:
npm install -D prisma
npm install @prisma/client
npx @better-auth/cli generate
The generated schema file should contain the following models:
- User: a registered user
- Session: a user session
- Account: OAuth account (not used)
- Verification: sign-up verification record
- Organization: an organization
-
Member: a member of an organization (join table between
User
andOrganization
) - Invitation: an invitation to join an organization
The initial dashboard UI looks like:
Setting up ZenStack
In the following sections, we'll use ZenStack to implement the access control requirements. ZenStack uses its own DSL called ZModel to define data models and access policy rules. ZModel is a superset of the Prisma schema language. The ZenStack CLI can generate a Prisma schema from a ZModel file so that downstream Prisma consumers (like better-auth) will continue to work seamlessly.
Let's initialize the project with ZenStack:
npx zenstack@latest init
The command will install the necessary dependencies, and copies the "prisma/schema.prisma" file to "/schema.zmodel". Moving forward, we'll modify "schema.zmodel" and use the ZenStack CLI to regenerate the Prisma schema.
npx zenstack generate
Preparing data models
Better-auth helped us generate the authentication-related data models, leaving us to work on the application-specific ones: Todo List and Todo. As mentioned previously, we should update "schema.zmodel" to define them:
// schema.zmodel
model TodoList {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
owner User @relation(fields: [ownerId], references: [id])
ownerId String
organization Organization? @relation(fields: [organizationId], references: [id])
organizationId String?
todos Todo[]
}
model Todo {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
done Boolean @default(false)
listId String
list TodoList @relation(fields: [listId], references: [id])
}
Then regenerate Prisma schema and push changes to the database:
npx zenstack generate
npx prisma db push
Finally, create a "/lib/db.ts" file to export the Prisma client:
// lib/db.ts
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
Mounting automatic CRUD API
ZenStack provides a Next.js server adapter that automatically exposes Prisma-style CRUD APIs. To mount it, install the server adapter package:
npm install @zenstackhq/server
, and then create a "/app/api/[...path]/router.ts" file:
// app/api/[...path]/router.ts"
import { prisma } from '@/lib/db';
import { NextRequestHandler } from '@zenstackhq/server/next';
async function getPrisma() {
return prisma;
}
const handler = NextRequestHandler({ getPrisma, useAppDir: true });
export {
handler as DELETE,
handler as GET,
handler as PATCH,
handler as POST,
handler as PUT,
};
This will expose a set of CRUD endpoints like /api/model/TodoList/findMany
, /api/model/TodoList/create
, etc. You can find more details here.
Although we can call these APIs with fetch
directly, a much easier way is to leverage ZenStack's TanStack Query plugin to generate client-side hooks.
npm install @zenstackhq/tanstack-query
// schema.zmodel
plugin hooks {
provider = "@zenstackhq/tanstack-query"
target = "react"
output = "./hooks/model"
}
npx zenstack generate
You can then enjoy type-safe Prisma-style hooks in the frontend code.
import { useFindManyTodoList } from '@/hooks/model';
export function MyComponent() {
const { data: lists, isLoading } = useFindManyTodoList({ include: { owner: true }});
...
}
Implementing access control
Now, we can manipulate the database from the frontend through the generated hooks and automatic API. However, the APIs are open to all without any protection, which is obviously not what we want.
The biggest value ZenStack adds above Prisma is access control, which can be implemented directly inside the schema using the @@allow
and @@deny
attributes. At runtime, ZenStack lets you create a wrapper around PrismaClient
(called enhanced PrismaClient) that automatically enforces these policy rules. Access is rejected by default unless explicitly granted with an @@allow
rule and not rejected by any @@deny
rule. When using an enhanced client to access the database, inaccessible records are filtered out during read, and mutations with insufficient permissions are rejected.
In real-world applications, authorization is always connected to authentication: you'll determine a user's access based on his identity and other information (like organization membership, roles, etc.). In our context, we'll use better-auth to retrieve the current user's identity, active organization, and role in the organization and use this information as the "user context" when creating the enhanced PrismaClient
. Since the auto APIs use the enhanced client, they are also secured.
// app/api/model/[...path]/route.ts
async function getPrisma() {
const reqHeaders = await headers();
const sessionResult = await auth.api.getSession({
headers: reqHeaders,
});
if (!sessionResult) {
// anonymous user, create enhanced client without user context
return enhance(prisma);
}
let organizationId: string | undefined = undefined;
let organizationRole: string | undefined = undefined;
const { session } = sessionResult;
if (session.activeOrganizationId) {
// if there's an active orgId, get the role of the user in the org
organizationId = session.activeOrganizationId;
const org = await auth.api.getFullOrganization({ headers: reqHeaders });
if (org?.members) {
const myMember = org.members.find(
(m) => m.userId === session.userId
);
organizationRole = myMember?.role;
}
}
// create enhanced client with user context
const userContext = {
userId: session.userId,
organizationId,
organizationRole,
};
return enhance(prisma, { user: userContext });
}
The user context will be accessible in ZModel policy rules via the special auth()
function. To get it to work, we'll use a type to define the shape of auth()
:
// schema.zmodel
type Auth {
userId String @id
organizationId String?
organizationRole String?
@@auth
}
Now, we're ready to write the policy rules. You can find more information about access polices here.
1. Tenant segregation
model TodoList {
...
// deny anonymous users
@@deny('all', auth() == null)
// deny access to lists that don't belong to the user's active organization
@@deny('all', auth().organizationId != organizationId)
}
2. Users can only create lists for themselves
model TodoList {
...
// users can create lists for themselves
@@allow('create', auth().userId == ownerId)
}
3. Owner and admins have full access
By default, better-auth's organization members can have "owner", "admin", or "member" role.
model TodoList {
...
// full access to: list owner, org owner, and org admins
@@allow('all',
auth().userId == ownerId ||
auth().organizationRole == 'owner' ||
auth().organizationRole == 'admin')
}
4. Readable to organization members
model TodoList {
...
// if the list belongs to an org, it's readable to all members
@@allow('read', organizationId != null)
}
5. Owner and organization cannot be changed
You can use @allow
and @deny
attributes (note the single @
sign) to define field-level rules.
model TodoList {
...
ownerId String @allow('update', false)
organizationId String? @allow('update', false)
}
6. A user as full access to Todo
if he can read its parent TodoList
We've managed to protect the TodoList
model, and rules for the Todo
model are yet to be defined. Fortunately, ZenStack allows you to reference relations in policy rules. The check()
helper allows you to directly delegate permission check to a relation (here Todo
-> TodoList
).
model Todo {
...
// `check()` delegates permission check to a relation
@@allow('all', check(list, 'read'))
}
Finally, the Todo list UI
With the CRUD APIs secured and frontend hooks generated, implementing the UI for managing TodoList
s becomes very straightforward. I'm only showing part of the implementation here.
// app/dashboard/todo-lists-card.tsx
export default function TodoListsCard() {
// Note that you don't need to filter for the current user and the active organization
// because the ZModel rules have taken care of it
const { data: todoLists } = useFindManyTodoList({
orderBy: { createdAt: 'desc' },
});
const { mutateAsync: del, isPending: isDeleting } = useDeleteTodoList();
async function onDelete(id: string) {
await del({ where: { id } });
}
return (
<Card>
<CardHeader>
<CardTitle>Todo List</CardTitle>
</CardHeader>
<CardContent>
<div>
{todoLists?.map((list) => (
<div key={list.id}>
<p>{list.name}</p>
<p>{list.createdAt.toLocaleString()}</p>
<Button disabled={isDeleting} onClick={() => onDelete(list.id)}>
Delete
</Button>
</div>
))}
</div>
</CardContent>
</Card>
);
}
You can find the fully completed code below:
ymc9 / better-auth-zenstack-multitenancy
A multi-tenant Todo app built with better-auth and ZenStack
This is the companion project of the blog post When Embedded AuthN Meets Embedded AuthZ - Building Multi-Tenant Apps With Better-Auth and ZenStack.
The project is based on better-auth's Next.js demo project.
Getting Started
-
Copy ".env.example" to ".env" and fill in the variables.
-
Install dependencies
npm install
-
Prepare the database
npx zenstack generate npx prisma db push
-
Start dev server
npm run dev
Conclusion
Authentication and authorization are two cornerstones of most applications. They can be especially challenging to build for multi-tenant ones. This post demonstrated how the work can be significantly simplified and streamlined by combining better-auth and ZenStack. The end result is a secure application with great flexibility and little boilerplate code.
Better-auth also supports defining custom permissions for organizations. Although not covered in this post, with some tweaking, you should be able to leverage it to define access policies. That way, you can manage permissions with better-auth's API and have ZenStack enforce them at runtime.
Top comments (0)