How to Build a Collaborative SaaS Product Using Next.js and ZenStack's Access Control Policy

JS - Feb 5 '23 - - Dev Community

Preface

Initially, I was planning to write it in two series, step by step:

  1. How to build a non-collaborative ToDo product
  2. 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:

MVP

However, there is a problem with it:

MVP-Problem

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
    // ...
};
Enter fullscreen mode Exit fullscreen mode

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

  1. Make sure you have Node.js 16 or above installed.
  2. Install the VSCode extension for editing data models.

    VSCode

  3. 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:

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
Enter fullscreen mode Exit fullscreen mode

2. Initialize the project for ZenStack

Let's run the zenstack CLI to prepare your project for using ZenStack.

npx zenstack@latest init
Enter fullscreen mode Exit fullscreen mode

The command installs a few NPM dependencies and copies the Prisma schema from prisma/schema.prismato 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}$')
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Then we should also add this relation in both Space and User

model User
{
  ...
  spaces SpaceUser[]
  ...
}

model Space
{
  ...
  members SpaceUser[]
  ...
}
Enter fullscreen mode Exit fullscreen mode

Access Policy

Access policies are expressed with the @@allowand @@denymodel 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:

  1. If any @@deny rule evaluates to true, it's denied.
  2. If any @@allow rule evaluates to true, it's allowed.
  3. Otherwise, it's denied.
  • User
    the access rule for User 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 authenticated User 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 matches condition. And the condition expression has direct access to fields defined in the model of collection.
    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:
    collection-sample

    • 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 the Space 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.
  • 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
  ...
}
Enter fullscreen mode Exit fullscreen mode
  • @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')
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
                                        ? { zenstack_guard: false }
                                        : {
                                              user: {
                                                  is: {
                                                      id: {
                                                          equals: user ? user.id : null,
                                                      },
                                                  },
                                              },
                                          },
                                },
                            },
                        },
                    },
                },
            },
            !user
                ? { zenstack_guard: false }
                : {
                      id: {
                          equals: user ? user.id : null,
                      },
                  },
        ],
    };
}
Enter fullscreen mode Exit fullscreen mode

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].tsto 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);
Enter fullscreen mode Exit fullscreen mode

Then let’s add the add NEXTAUTH_SECRETenvironment variable in .env file and set it to an arbitrary value like the below:

NEXTAUTH_SECRET = abc123;
Enter fullscreen mode Exit fullscreen mode

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/nextand @zenstackhq/reactpackages:

npm install @zenstackhq/next @zenstackhq/react
Enter fullscreen mode Exit fullscreen mode

Let’s create a new file src/server/enhanced-db.ts

import { withPresets } 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 withPresets(prisma, { user: session?.user });
}
Enter fullscreen mode Exit fullscreen mode

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 }),
});
Enter fullscreen mode Exit fullscreen mode

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 reactHooks {
  provider = '@zenstackhq/react'
  output = "./src/lib/hook
}
Enter fullscreen mode Exit fullscreen mode

Now run npx zenstack generate again; you'll find the hooks generated under /src/lib/hooks folder:

npx zenstack generate
Enter fullscreen mode Exit fullscreen mode

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 (...);
}
Enter fullscreen mode Exit fullscreen mode

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 (...);
}
Enter fullscreen mode Exit fullscreen mode

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:

sign-up/in

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;
Enter fullscreen mode Exit fullscreen mode

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,
            },
        },
    },
});
Enter fullscreen mode Exit fullscreen mode

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 },
    };
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

ZenStack-Team-Space

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 (...);
  }
Enter fullscreen mode Exit fullscreen mode

The dialog would look like below:

Team-Management

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.

Bob-Space

Then let’s invite bob to ZenStack’s space as a USER:

Invite-Bob

Then Bob will see ZenStack’s space on his home page:

Bob-Homepage

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:

Remove-Bob

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:

403-Error

with the message body:

{
    "prisma": true,
    "rejectedByPolicy": true,
    "code": "P2004",
    "message": "denied by policy: spaceUser entities failed 'delete' check, 1 entities failed policy check"
}
Enter fullscreen mode Exit fullscreen mode

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:

404

Conclusion

I hope this tutorial 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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .