How to build a collaborative SaaS product using Next.js and ZenStack's access control policy
Almost all the SaaS now is collaborative, from the originator Salesforce to the newly emerging one like Notion. To make it collaborative, technically, we need to build the foundation to support tenant isolation with an access control policy. A classic challenge here is striking a balance between app security and dev productivity.
This tutorial demonstrates how to build a SaaS product using Next.js and ZenStack and the benefit of using a data model as the single source of truth to handle access control.
Preface
Initially, I was planning to write it in two series, step by step:
- How to build a non-collaborative ToDo product
- How to add the collaborative part
When one of my friends, who happened to be building a SaaS product, knew about it, she told me that she didn’t think anyone would be interested in the first of the series as no one would actually build a ToDo product. Instead, she is very interested in how to build the collaborative part of the SaaS product properly.
It reminds me of a famous discussion about the MVP, which is usually illustrated as:
However, there is a problem with it:
It looks just like the problem of mine my friend told me about.😂 So, I decided first to show you how to build a collaborative SaaS team space firstly in this article.
Background
Technically one of the most important things to consider for SaaS products is tenant isolation. To put it in a single word:
users from tenant A should not be able to access data from tenant B, and vice-versa.
The idea behind tenant isolation is access control.
A classic challenge when laying foundations for B2B SaaS is striking a balance between app security and dev productivity.
There is a typical way of dealing with it using physical isolation, which means each tenant will have its own database/schema. Definitely, it provides the best security, but it will also make your architecture more complicated and cost you more effort to deal with the database especially considering the cross-database resources.
Another alternative way is to do virtual isolation, which means flagging all tenant-specific records in your DB with a tenantId
key. Each incoming request that hits the DB must then be scoped against the current tenantId
. To make it DRY(Don't repeat yourself), usually you would adopt some middleware to handle it like below:
const databaseMiddleware = (req, res, next) => {
const incomingMessage = req as IncomingMessage;
const serverResponse = res as ServerResponse;
// Get the tenant ID from the request object
const tenantId = incomingMessage.tenantId;
// Add the tenant ID filter to all database queries
addTenantIdFilter(tenantId);
next();
};
const addTenantIdFilter = (tenantId: string) => {
// Implement the logic to add the tenant ID filter to all database queries
// ...
};
It probably works well at the beginning. But as your product grows with more kinds of resources together with a specific access control policy, it might become error-prone because the access control logic is dispersed in the middleware and specific API logic.
One of the most important things ZenStack is trying to solve is allowing you to define access policies directly inside your data model, so it's easier to keep the policies in sync when your data models evolve. So Let’s see how we could achieve that using the declarative way provided by ZenStack.
Prerequisite
-
Make sure you have Node.js 18 or above installed.
-
Install the VSCode extension for editing data models.
-
You already have a basic idea about Prisma. If not, you can either check its website or read this article.
Building the app
If you don't want to follow the detailed steps, you can also find the deployed version and complete code below:
- https://zenstack-nextjs-saas-app-sigma.vercel.app/
- https://github.com/jiashengguo/zenstack-nextjs-saas-app
1. Create a new project
Create a Next.js project with create-t3-app
with Prisma, NextAuth and TailwindCSS:
npx create-t3-app@latest --prisma --nextAuth --tailwind --CI my-saas-app
cd my-saas-app
npm run dev
2. Initialize the project for ZenStack
Let's run the zenstack
CLI to prepare your project for using ZenStack.
npx zenstack@latest init
The command installs a few NPM dependencies and copies the Prisma schema from prisma/schema.prisma
to schema.zmodel.
The only models we still need are Account and User, you can remove other models.
3. Define schema
Relations
Tenant is more from the technical world, from the business world we usually call Space. So let’s create a model for it:
model Space {
id String @id @default(uuid()
name String @length(4, 50)
slug String @unique @regex('^[0-9a-zA-Z]{4,16}$')
}
In addition to the Prisma schema, it has two attributes @length
and @regex
, which is the standard validation attribute provided by ZenStack library. Actually, one enhancement to Prisma of ZenStack is to be able to define your own custom attribute.
Then we need to model the relationship between Space
and User
. It’s a typical many-to-many relation. So we need to add a relation model SpaceUser
:
/*
* Model representing membership of a user in a space
*/
model SpaceUser {
id String @id @default(uuid())
space Space @relation(fields:[spaceId], references: [id], onDelete: Cascade)
spaceId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
role SpaceUserRole
}
enum SpaceUserRole {
USER
ADMIN
}
Then we should also add this relation in both Space
and User
model User
{
...
spaces SpaceUser[]
...
}
model Space
{
...
members SpaceUser[]
...
}
Access Policy
Access policies are expressed with the @@allow
and @@deny
model attributes. The attributes take two parameters. The first is the operation: create/read/update/delete. You can use a comma-separated string to pass multiple operations or use 'all' to abbreviate all operations. The second parameter is a boolean expression indicating if the rule should be activated.
@@allow
opens up permissions while @@deny
turns them off. You can use them multiple times and combine them in a model. Whether an operation is permitted is determined as follows:
- If any
@@deny
rule evaluates to true, it's denied. - If any
@@allow
rule evaluates to true, it's allowed. - Otherwise, it's denied.
-
User
the access rule forUser
should be defined as:model User {
...
spaces SpaceUser[]
// can be created by anyone, even not logged in
@@allow('create', true)
// can be read by users sharing any space
@@allow('read', spaces?[space.members?[user == auth()]])
// full access by oneself
@@allow('all', auth() == this)
}auth()
is a special function provided by ZenStack to return the current authenticatedUser
instance, which represents the user identity of the current data request, after wrapping Prisma client, which I will show you how to do it later. The first and third rules should be straightforward. So let’s talk about the second rule, which actually implements tenant isolation. It uses the Any condition syntax in Collection Predicate Expression :<collection>?[condition]
It means any element in
collection
matchescondition
. And thecondition
expression has direct access to fields defined in the model ofcollection
. It might be a little hard to understand because there are actually two “users” here. One is the authenticated user currently initiating the data request, the other one is the target user data the request is trying to access. To make it easy to understand, let’s use Auth to refer to the current user and use Bob to refer to the data as below:- the outer condition
spaces?[…]
means if the inner condition satisfies any space Bob belongs to, then Bob could be read. - The inner condition
space.members?[user == auth()]
means if Auth belongs to the space. So combining the logic together, if Auth belongs to any of theSpace
of Bob, then Bob could be read. It’s might be a little hard to follow the logic at the beginning, but once you get familiar with it, you will feel natural and intuitive to use it.
- the outer condition
-
Space
model Space {
...
members SpaceUser[]
// require login
@@deny('all', auth() == null)
// everyone can create a space
@@allow('create', true)
// any user in the space can read the space
@@allow('read', members?[user == auth()])
// space admin can update and delete
@@allow('update,delete', members?[user == auth() && role == ADMIN])
} -
SpaceUser
model SpaceUser {
...
space Space @relation(fields:[spaceId], references: [id], onDelete: Cascade)
...
// require login
@@deny('all', auth() == null)
// space admin can create/update/delete
@@allow('create,update,delete', space.members?[user == auth() && role == ADMIN])
// user can read entries for spaces which he's a member of
@@allow('read', space.members?[user == auth()])
}
I bet the rules for Space
and SpaceUser
already looks intuitive to you, right? 😄.
Notice the first
allow
rule in SpaceUser restrict that only the space admin could manage the space member. We will see how it takes effect later.
That’s all we need to do to support tenant isolation. You could use the Prisma-style react hooks or the ZenStack-wrapped Prisma client as usual, the access control just works as you defined in the schema under the hood. How? ZenStack generates the safeguard for all the API calls based on your access policy. You will see it soon.
Last change
For simplicity, we'll use username/password-based authentication in this project. So let’s add password
field in User
model:
model User
{
...
password String? @password @omit
...
}
@password
marks the field to be based before saving.@omit
indicates the field should be dropped when returning from a query.
Since we defined an Enum SpaceUserRole
, which is not supported by the SQLite, we need to use a real database provider. I will use PostgreSQL in this project. So let’s change it in the schema:
datasource db {
provider = 'postgresql'
url = env('DATABASE_URL')
}
If you don't have a Postgres database, the simple way to get one is to get a docker instance or a free one from Supabase.
Now let’s run the below command to flush the change to the Prisma schema and database:
npx zenstack generate && npx prisma db push
After running successfully, you can open this file node_modules/.zenstack/policy.ts
and see the function like this:
function User_read(context: QueryContext): any {
const user = context.user ?? null;
return {
OR: [
{
spaces: {
some: {
space: {
is: {
members: {
some: !user
? { OR: [] } // false condition
: {
user: {
is: {
id: {
equals: user ? user.id : null,
},
},
},
},
},
},
},
},
},
},
!user
? { OR: [] } // false condition
: {
id: {
equals: user ? user.id : null,
},
},
],
};
}
That’s the safeguard I mentioned above, which translates the access policy defined in the schema to the actual code.
4. Sign-up Sign-in
NextAuth
Let's update /src/pages/api/auth/[...nextauth].ts
to the content below to use credentials auth and JWT-based session:
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { PrismaClient } from '@prisma/client';
import { compare } from 'bcryptjs';
import NextAuth, { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { prisma } from 'server/db';
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
session: {
strategy: 'jwt',
},
providers: [
CredentialsProvider({
credentials: {
email: {
type: 'email',
},
password: {
type: 'password',
},
},
authorize: authorize(prisma),
}),
],
callbacks: {
async session({ session, token }) {
return {
...session,
user: {
...session.user,
id: token.sub!,
},
};
},
},
};
function authorize(prisma: PrismaClient) {
return async (credentials: Record<'email' | 'password', string> | undefined) => {
if (!credentials) {
throw new Error('Missing credentials');
}
if (!credentials.email) {
throw new Error('"email" is required in credentials');
}
if (!credentials.password) {
throw new Error('"password" is required in credentials');
}
const maybeUser = await prisma.user.findFirst({
where: {
email: credentials.email,
},
select: {
id: true,
email: true,
password: true,
},
});
if (!maybeUser || !maybeUser.password) {
return null;
}
const isValid = await compare(credentials.password, maybeUser.password);
if (!isValid) {
return null;
}
return {
id: maybeUser.id,
email: maybeUser.email,
};
};
}
export default NextAuth(authOptions);
Then let’s add the add NEXTAUTH_SECRET
environment variable in .env file and set it to an arbitrary value like the below:
NEXTAUTH_SECRET = abc123;
Mount CRUD service & generate hooks
ZenStack has built-in support for Next.js and can provide database CRUD services automatically, so you don't need to write it yourself.
First install the @zenstackhq/next
and @zenstackhq/swr
packages:
npm install @zenstackhq/next @zenstackhq/swr
Let’s create a new file src/server/enhanced-db.ts
import { enhance } from '@zenstackhq/runtime';
import type { GetServerSidePropsContext } from 'next';
import { getServerAuthSession } from './auth';
import { prisma } from './db';
export async function getEnhancedPrisma(ctx: {
req: GetServerSidePropsContext['req'];
res: GetServerSidePropsContext['res'];
}) {
const session = await getServerAuthSession(ctx);
// create a wrapper of Prisma client that enforces access policy,
// data validation, and @password, @omit behaviors
return enhance(prisma, { user: session?.user });
}
Then whenever you want to use Prisma client, you can use getEnhancedPrisma
instead which
automatically validates access policies, field validation rules etc., during CRUD operations.
Now let’s create our API endpoint in /src/pages/api/model/[...path].
and fill in the content below:
import { requestHandler } from '@zenstackhq/next';
import { getEnhancedPrisma } from 'server/enhanced-db';
export default requestHandler({
getPrisma: (req, res) => getEnhancedPrisma({ req, res }),
});
The /api/model
route is now ready to access database queries and mutation requests. However, manually calling the service will be tedious. Fortunately, ZenStack can automatically generate React hooks for you.
Let's enable it by adding the following snippet at the top level to schema.zmodel
:
plugin swr {
provider = '@zenstackhq/swr'
output = "./src/lib/hook
}
Now run npx zenstack generate
again; you'll find the hooks generated under /src/lib/hooks
folder:
npx zenstack generate
Now we're ready to implement the signup/signin flow.
Sign-up
First, let’s create a signup page under /src/pages/singup.tsx
To make the post not too long, I will only show the logic code snippets. For the complete part, you can take a look at the code repository
import { useUser } from "../lib/hooks/user";
import { signIn } from "next-auth/react";
import { FormEvent, useState } from "react";
export default function Signup() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { create: signup } = useUser();
async function onSignup(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
try {
await signup({ data: { email, password } });
} catch (err: any) {
if (err.info?.prisma === true) {
if (err.info.code === "P2002") {
alert("User already exists");
} else {
alert(`Unexpected Prisma error: ${err.info.code as string}`);
}
} else {
alert(`Error occurred: ${JSON.stringify(err)}`);
}
return;
}
const signInResult = await signIn("credentials", {
redirect: false,
email,
password,
});
if (signInResult?.ok) {
window.location.href = "/";
} else {
console.error("Signin failed:", signInResult?.error);
}
}
// html
return (...);
}
Conceptually, signup is to create a user. So the same applies to the code, singup
is the create
hook of User
ZenStack generated for you.
Sign-in
let’s create a sign-in page under /src/pages/singin.tsx
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import { FormEvent, useEffect, useState } from "react";
export default function Signin() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const router = useRouter();
async function onSignin(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const signInResult = await signIn("credentials", {
redirect: false,
email,
password,
});
if (signInResult?.ok) {
window.location.href = "/";
} else {
alert(`Signin failed. Please check your email and password.`);
}
}
// html
return (...);
}
The sign-in part is still the same as you always do use NextAuth.
Now, you can do the sign-up sign-in as below:
Let’s create the first user admin@zenstack.com
5. Home page
Show spaces
The most important thing we need to show on the home page is the spaces this user belongs to. So let’s change the /src/index.tsx
:
import { Space } from '@prisma/client';
import Spaces from '../components/Spaces';
import WithNavBar from '../components/WithNavBar';
import type { GetServerSideProps, NextPage } from 'next';
import Link from 'next/link';
import { getEnhancedPrisma } from '../server/enhanced-db';
import { useCurrentUser } from '../lib/context';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/router';
type Props = {
spaces: Space[];
};
const Home: NextPage<Props> = ({ spaces }) => {
const user = useCurrentUser();
const router = useRouter();
const { status } = useSession();
if (status === 'unauthenticated') {
void router.push('/signin');
return <></>;
}
return (
<WithNavBar>
{user && (
<div className="mt-8 flex w-full flex-col items-center text-center">
<h1 className="text-2xl text-gray-800">Welcome {user.name || user.email}!</h1>
<div className="w-full p-8">
<h2 className="mb-8 text-left text-lg text-gray-700 md:text-xl">
Choose a space to start, or{' '}
<Link className="link-primary underline" href="/create-space">
create a new one.
</Link>
</h2>
<Spaces spaces={spaces} />
</div>
</div>
)}
</WithNavBar>
);
};
export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
const db = await getEnhancedPrisma(ctx);
const spaces = await db.space.findMany();
return {
props: { spaces },
};
};
export default Home;
Without ZenStack, normally to get the result you will need manually write the filter as below:
const session = await getServerAuthSession(ctx);
const spaces = await db.space.findMany({
where: {
members: {
some: {
userId: session?.user?.id,
},
},
},
});
With ZenStack, you can simply query all the spaces as await db.space.findMany().
That’s the beauty of ZenStack when you write the query logic, you don’t need to worry about access control at all. You will write as if there is no such thing, and ZenStack will take care of it as it’s defined in the schema.
Space Home Page
Each space will have a unique URL, including its slug, so let’s create the page for it under src/pages/space/[slug]/index.tsx
import { Space } from '@prisma/client';
import BreadCrumb from '../../../components/BreadCrumb';
import SpaceMembers from '../../../components/SpaceMembers';
import WithNavBar from '../../../components/WithNavBar';
import { GetServerSideProps } from 'next';
import { useRouter } from 'next/router';
import { getEnhancedPrisma } from '../../../server/enhanced-db';
type Props = {
space: Space;
};
export default function SpaceHome(props: Props) {
const router = useRouter();
return (
<WithNavBar>
<div className="px-8 py-2">
<BreadCrumb space={props.space} />
</div>
<div className="p-8">
<div className="w-full flex flex-col md:flex-row mb-8 space-y-4 md:space-y-0 md:space-x-4">
<SpaceMembers />
</div>
</div>
</WithNavBar>
);
}
export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res, params }) => {
const db = await getEnhancedPrisma({ req, res });
const space = await db.space.findUnique({
where: { slug: params!.slug as string },
});
if (!space) {
return {
notFound: true,
};
}
return {
props: { space },
};
};
Like the home page, it simply queries the space using slug as the only filter regardless of the access policy. If the space can’t be found, it does not necessarily indicate that the space does not exist. It is possible that you are not authorized to access it.
Create Space
Creating space is similar to creating a user. Let’s create src/pages/create-space.tsx
for it:
import { useSpace } from "../lib/hooks";
import { SpaceUserRole } from "@prisma/client";
import WithNavBar from "../components/WithNavBar";
import { NextPage } from "next";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { FormEvent, useState } from "react";
const CreateSpace: NextPage = () => {
const { data: session } = useSession();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const { create } = useSpace();
const router = useRouter();
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
try {
const space = await create({
data: {
name,
slug,
members: {
create: [
{
userId: session!.user.id,
role: SpaceUserRole.ADMIN,
},
],
},
},
});
alert("Space created successfull! You'll be redirected.");
setTimeout(() => {
if (space) {
void router.push(`/space/${space.slug}`);
}
}, 2000);
} catch (err: any) {
console.error(err);
if (err.info?.prisma === true) {
if (err.info.code === "P2002") {
alert("Space slug already in use");
} else {
alert(`Unexpected Prisma error: ${err.info.code as string}`);
}
} else {
alert(JSON.stringify(err));
}
}
};
// html
return (...);
};
export default CreateSpace;
Now let’s create the new space “ZenStack Team”, after which it will redirect to the team space with the space URL http://localhost:3000/space/zenstack. If you go back to the home page, you will see the ZenStack team.
6. Team management
Let’s create the team management dialog under src/components/ManageMembers.tsx
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import { useCurrentUser } from '../lib/context';
import { useSpaceUser } from '../lib/hooks';
import { Space, SpaceUserRole } from '@prisma/client';
import { ChangeEvent, KeyboardEvent, useState } from 'react';
import Avatar from './Avatar';
type Props = {
space: Space;
};
export default function ManageMembers({ space }: Props) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<SpaceUserRole>(SpaceUserRole.USER);
const user = useCurrentUser();
const { findMany, create: addMember, del: delMember } = useSpaceUser();
const { data: members } = findMany({
where: {
spaceId: space.id,
},
include: {
user: true,
},
orderBy: {
role: 'desc',
},
});
const inviteUser = async () => {
try {
const r = await addMember({
data: {
user: {
connect: {
email,
},
},
space: {
connect: {
id: space.id,
},
},
role,
},
});
console.log('SpaceUser created:', r);
} catch (err: any) {
console.error(err);
if (err.info?.prisma === true) {
if (err.info.code === 'P2002') {
alert('User is already a member of the space');
} else if (err.info.code === 'P2025') {
alert('User is not found for this email');
} else {
alert(`Unexpected Prisma error: ${err.info.code as string}`);
}
} else {
alert(`Error occurred: ${JSON.stringify(err)}`);
}
}
};
const removeMember = async (id: string) => {
if (confirm(`Are you sure to remove this member from space?`)) {
await delMember({ where: { id } });
}
};
//html
return (...);
}
The dialog would look like below:
All done. You can see there is no actual code logic handling the access control, but let’s see whether it is there.
7. Test
Remember our friend Bob? let’s create an account for him with bob@gmail.com, and create a space as Bob’s Family.
Then let’s invite bob to ZenStack’s space as a USER:
Then Bob will see ZenStack’s space on his home page:
Although Bob’s a USER
who cannot manage the team member according to the access control defined in the schema, we don't make that restriction from UI. Let’s try to remove admin@zenstack.com to see what will happen:
After confirmation, nothing really happens, which means the removal operation doesn’t succeed.
Open the developer tool of the browser, you can see a 403 message below:
with the message body:
{
"prisma": true,
"rejectedByPolicy": true,
"code": "P2004",
"message": "denied by policy: spaceUser entities failed 'delete' check, 1 entities failed policy check"
}
You can try to add a new user, you will see a similar result. Therefore, the API is indeed secured by access control.
Now Bob could access ZenStack’s team space home page by accessing its URL http://localhost:3000/space/zenstack. Let’s remove Bob from ZenStack’s team space using admin@zenstack.com. Then let’s access that page again. you will see the 404 page now:
Conclusion
I hope now it already can show you the benefit of ZenStack in using the data model as the single source of truth to handle access control.
Feel free to contact us on our Discord or GitHub if you have any questions about it.
Next
In next article, I will show you how to make it a ToDo SaaS:
How to Build a Fully Functional ToDo SaaS Using Next.js and ZenStack's Access Control Policy