Skip to content

Commit 3fe7c65

Browse files
committed
blog: stack-auth multi-tenant app
1 parent dbb58f1 commit 3fe7c65

File tree

5 files changed

+308
-0
lines changed

5 files changed

+308
-0
lines changed
519 KB
Loading
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
---
2+
title: "Building Multi-Tenant Apps Using StackAuth's \"Teams\" and Next.js"
3+
description: StackAuth's "Teams" feature provides a powerful pre-built tenant management experience. Let's see how we can easily create a full-fledged multi-tenant application with it.
4+
tags: [auth, stack-auth, multi-tenancy]
5+
authors: yiming
6+
date: 2024-12-07
7+
image: ./cover.png
8+
---
9+
10+
# Building Multi-Tenant Apps Using StackAuth's \"Teams\" and Next.js
11+
12+
![Cover Image](cover.png)
13+
14+
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:
15+
16+
- Creating and managing tenants
17+
- User invitation flow
18+
- Managing roles and permissions
19+
- Enforcing data segregation and access control throughout the entire application
20+
21+
It sounds like lots of work, and it indeed is. You may have done this multiple times if you're a veteran SaaS developer.
22+
23+
<!--truncate-->
24+
25+
[StackAuth](https://stack-auth.com) is an open-source authentication and user management platform designed to integrate seamlessly into Next.js projects. Its combination of frontend/backend APIs and pre-built UI components dramatically simplifies the integration of such capabilities into your application. Similarly, its newer "Teams" feature provides an excellent starting point for creating multi-tenant applications. In this post, we'll explore leveraging it to build a non-trivial one while trying to keep our code simple and clean.
26+
27+
## The goal and the stack
28+
29+
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:
30+
31+
- **Team management**
32+
33+
Users can create teams and invite others to join. They can manage members and set their roles.
34+
35+
- **Current context**
36+
37+
Users can choose an team to be the current context.
38+
39+
- **Data segregation**
40+
41+
Only data within the current team can be accessed.
42+
43+
- **Role-based access control**
44+
45+
- Admin members have full access to all data within their team.
46+
- Regular members have full access to the todo lists they own.
47+
- Regular members can view the other members' todo lists and manage their content, as long as the list is not private.
48+
49+
Besides Next.js and StackAuth, we'll build the app with two other essential pieces of weapon:
50+
51+
- [Prisma](https://prisma.io): the ORM
52+
- [ZenStack](https://zenstack.dev): the access control layer on top of Prisma
53+
54+
You can find the link of the completed project at the end of the post.
55+
56+
## Adding team management
57+
58+
I assume you've created a Next.js project and completed the steps as described StackAuth's [setup guide](https://docs.stack-auth.com/getting-started/setup). Verify the basic sign-up/sign-in flow is working. Also, in StackAuth's management console, enable "Client-Side Team Creation" and "Automatic Team Creation" options in the "Team Settings" section.
59+
60+
![Team Settings](./team-settings.png)
61+
62+
Now, we can add the "SelectedTeamSwitcher" component into the layout.
63+
64+
```tsx title="src/app/layout.tsx"
65+
// highlight-next-line
66+
import { SelectedTeamSwitcher } from "@stackframe/stack";
67+
...
68+
69+
export default function RootLayout({ children }: { children: React.ReactNode }) {
70+
return (
71+
<html lang="en">
72+
<body>
73+
<StackProvider app={stackServerApp}>
74+
<StackTheme>
75+
<header>
76+
// highlight-next-line
77+
<SelectedTeamSwitcher />
78+
</header>
79+
<main>{children}</main>
80+
</StackTheme>
81+
</StackProvider>
82+
</body>
83+
</html>
84+
);
85+
}
86+
```
87+
88+
With this one-liner, you'll have a set of fully working UI components for managing teams and choosing an active one!
89+
90+
![Team Management](./team-management.png)
91+
92+
## Setting up the database
93+
94+
Our user and team data are stored on StackAuth's side. We need to store the todo lists and items in our own database. In this section, we'll set up Prisma and ZenStack and create the database schema.
95+
96+
Let's start with installing the necessary packages:
97+
98+
```bash
99+
npm install --save-dev prisma zenstack
100+
npm install @prisma/client @zenstackhq/runtime
101+
```
102+
103+
Then we can create the database schema. Please note that we're creating a **schema.zmodel** file (as a replacement of "schema.prisma"). The [ZModel language](/docs/the-complete-guide/part1/zmodel) is a superset of Prisma schema language, allowing you to model both the data schema and access control policies. In this section, we'll only focus on the data modeling part.
104+
105+
```zmodel title="/schema.zmodel"
106+
datasource db {
107+
provider = "postgresql"
108+
url = env("DATABASE_URL")
109+
}
110+
111+
generator js {
112+
provider = "prisma-client-js"
113+
}
114+
115+
// Todo list
116+
model List {
117+
id String @id @default(cuid())
118+
createdAt DateTime @default(now())
119+
title String
120+
private Boolean @default(false)
121+
orgId String?
122+
ownerId String
123+
todos Todo[]
124+
}
125+
126+
// Todo item
127+
model Todo {
128+
id String @id @default(cuid())
129+
title String
130+
completedAt DateTime?
131+
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
132+
listId String
133+
}
134+
```
135+
136+
You can then generate a regular Prisma schema file and push the schema to the database:
137+
138+
```bash
139+
# The `zenstack generate` command generates the "prisma/schema.prisma" file and runs "prisma generate"
140+
npx zenstack generate
141+
npx prisma db push
142+
```
143+
144+
Finally, create a "src/server/db.ts" file to export the Prisma client:
145+
146+
```ts title="src/server/db.ts"
147+
import { PrismaClient } from "@prisma/client";
148+
export const prisma = new PrismaClient();
149+
```
150+
151+
## Implementing access control
152+
153+
As mentioned, ZenStack allows you to model both data and access control in a single schema. Let's see how we can entirely implement our authorization requirements with it. The rules are defined with the `@@allow` and `@@deny` attributes. Access is rejected by default unless explicitly granted with an `@@allow` rule.
154+
155+
Although authorization is a distinct concept from authentication, it usually depends on authentication to work. For example, to determine if the current user has access to a list, a verdict must be made based on the user's id, current team, and role in the team. To access such information, let's first declare a type to express it:
156+
157+
```zmodel title="/schema.zmodel"
158+
// The shape of `auth()`
159+
type Auth {
160+
// Current user's ID
161+
userId String @id
162+
163+
// User's current team ID
164+
currentTeamId String?
165+
166+
// User's role in the current team
167+
currentTeamRole String?
168+
169+
@@auth
170+
}
171+
```
172+
173+
Then you can use the special `auth()` function in access policy rules to access the current user's information. Let's use the `List` model as an example to demonstrate how the rules are defined.
174+
175+
```zmodel title="/schema.zmodel"
176+
model List {
177+
...
178+
179+
// deny anonymous access
180+
@@deny('all', auth() == null)
181+
182+
// tenant segregation: deny access if the user's current org doesn't match
183+
@@deny('all', auth().currentOrgId != orgId)
184+
185+
// owner/admin has full access
186+
@@allow('all', auth().userId == ownerId || auth().currentOrgRole == 'org:admin')
187+
188+
// can be read by org members if not private
189+
@@allow('read', !private)
190+
191+
// when create, owner must be set to current user
192+
@@allow('create', ownerId == auth().userId)
193+
}
194+
```
195+
196+
The last piece of the puzzle is, as you may already be wondering, where the value of `auth()` comes from? At runtime, ZenStack offers an `enhance()` API to create an enhanced `PrismaClient` (a lightweighted wrapper) that automatically enforces the access policies. You pass in a user context (usually fetched from the authentication provider) when calling `enhance()`, and that context provides the value for `auth()`.
197+
198+
We'll see how it works in detail in the next section.
199+
200+
## Finally, the UI
201+
202+
Before diving into creating the UI, let's first make a helper to get an enhanced `PrismaClient` for the current user, team, and role.
203+
204+
```ts title="src/server/db.ts"
205+
import { enhance } from "@zenstackhq/runtime";
206+
import { stackServerApp } from "~/stack";
207+
208+
export async function getUserDb() {
209+
const stackAuthUser = await stackServerApp.getUser();
210+
const currentTeam = stackAuthUser?.selectedTeam;
211+
212+
// by default StackAuth's team members have "admin" or "member" role
213+
const perm =
214+
currentTeam && (await stackAuthUser.getPermission(currentTeam, "admin"));
215+
216+
const user = stackAuthUser
217+
? {
218+
userId: stackAuthUser.id,
219+
currentTeamId: stackAuthUser.selectedTeam?.id,
220+
currentTeamRole: perm ? "admin" : "member",
221+
}
222+
: undefined; // anonymous
223+
return enhance(prisma, { user });
224+
}
225+
```
226+
227+
Let's build the UI using [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) (RSC) and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). We'll also consistently use the `getUserDb()` helper to access the database with access control enforcement.
228+
229+
Here's the RSC that renders the todo lists for the current user (with styling omitted):
230+
231+
```tsx title="src/components/TodoList.tsx"
232+
// Component showing Todo list for the current user
233+
234+
export default async function TodoLists() {
235+
const db = await getUserDb();
236+
237+
// enhanced PrismaClient automatically filters out
238+
// the lists that the user doesn't have access to
239+
const lists = await db.list.findMany({
240+
orderBy: { updatedAt: "desc" },
241+
});
242+
243+
return (
244+
<div>
245+
<div>
246+
{/* client component for creating a new List */}
247+
<CreateList />
248+
249+
<ul>
250+
{lists?.map((list) => (
251+
<Link href={`/lists/${list.id}`} key={list.id}>
252+
<li>{list.title}</li>
253+
</Link>
254+
))}
255+
</ul>
256+
</div>
257+
</div>
258+
);
259+
}
260+
```
261+
262+
A client component that creates a new list by calling into a server action:
263+
264+
```tsx title="src/components/CreateList.tsx"
265+
"use client";
266+
267+
import { createList } from "~/app/actions";
268+
269+
export default function CreateList() {
270+
function onCreate() {
271+
const title = prompt("Enter a title for your list");
272+
if (title) {
273+
createList(title);
274+
}
275+
}
276+
277+
return (
278+
<button onClick={onCreate}>
279+
Create a list
280+
</button>
281+
);
282+
}
283+
```
284+
285+
```ts title="src/app/actions.ts"
286+
'use server';
287+
288+
import { revalidatePath } from "next/cache";
289+
import { getUserDb } from "~/server/db";
290+
291+
export async function createList(title: string) {
292+
const db = await getUserDb();
293+
await db.list.create({ data: { title } });
294+
revalidatePath("/");
295+
}
296+
```
297+
298+
<div align="center">
299+
<img src={require('./list-ui.gif').default} width="640" />
300+
</div>
301+
302+
The components that manage Todo items are not shown for brevity, but the ideas are similar. You can find the fully completed code [here](https://github.com/ymc9/stackauth-zenstack-multitenancy).
303+
304+
## Conclusion
305+
306+
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 StackAuth's "Teams" feature and ZenStack's access control capabilities. The end result is a secure application with great flexibility and little boilerplate code.
307+
308+
StackAuth also supports defining [custom permissions](https://docs.stack-auth.com/concepts/permissions) for teams. 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 StackAuth's dashboard and have ZenStack enforce them at runtime.
323 KB
Loading
323 KB
Loading
523 KB
Loading

0 commit comments

Comments
 (0)