When Embedded AuthN Meets Embedded AuthZ - Building Multi-Tenant Apps With Better-Auth and ZenStack
Publish Date: Dec 17 '24
20 0
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:
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.
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 and Organization)
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:
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.tsasyncfunctiongetPrisma(){constreqHeaders=awaitheaders();constsessionResult=awaitauth.api.getSession({headers:reqHeaders,});if (!sessionResult){// anonymous user, create enhanced client without user contextreturnenhance(prisma);}letorganizationId:string|undefined=undefined;letorganizationRole:string|undefined=undefined;const{session}=sessionResult;if (session.activeOrganizationId){// if there's an active orgId, get the role of the user in the orgorganizationId=session.activeOrganizationId;constorg=awaitauth.api.getFullOrganization({headers:reqHeaders});if (org?.members){constmyMember=org.members.find((m)=>m.userId===session.userId);organizationRole=myMember?.role;}}// create enhanced client with user contextconstuserContext={userId:session.userId,organizationId,organizationRole,};returnenhance(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():
Now, we're ready to write the policy rules. You can find more information about access polices here.
1. Tenant segregation
modelTodoList{...// 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
modelTodoList{...// 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.
modelTodoList{...// 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
modelTodoList{...// 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.
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).
modelTodo{...// `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 TodoLists becomes very straightforward. I'm only showing part of the implementation here.
// app/dashboard/todo-lists-card.tsxexportdefaultfunctionTodoListsCard(){// Note that you don't need to filter for the current user and the active organization// because the ZModel rules have taken care of itconst{data:todoLists}=useFindManyTodoList({orderBy:{createdAt:'desc'},});const{mutateAsync:del,isPending:isDeleting}=useDeleteTodoList();asyncfunctiononDelete(id:string){awaitdel({where:{id}});}return (<Card><CardHeader><CardTitle>Todo List</CardTitle></CardHeader><CardContent><div>{todoLists?.map((list)=>(<divkey={list.id}><p>{list.name}</p><p>{list.createdAt.toLocaleString()}</p><Buttondisabled={isDeleting}onClick={()=>onDelete(list.id)}>
Delete
</Button></div>))}</div></CardContent></Card>);}
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.