使用Next.js构建多租户SaaS应用(后端集成)
我使用日常技术工具构建了一个功能性的多租户SaaS应用程序(教育技术应用),你也可以做到同样的事情。
首先,什么是多租户SaaS应用程序?
多租户SaaS应用程序允许你从单一代码库服务多个客户。但要做到这一点,你需要管理安全和租户特定的访问,这在手动完成时可能具有挑战性。这就是为什么我决定使用Permit,一个现代化的授权工具,可以简化这个过程。
在本文中,我将向你展示如何使用Permit简化SaaS应用程序的授权,通过一个逐步示例来构建一个演示应用,该应用具有租户隔离和基于角色的访问控制(RBAC),使用Next.js和Appwrite。
Next.js和Appwrite是什么,为什么我们需要它们?
Next.js
Next.js是一个基于React的框架,提供开箱即用的服务器端渲染(SSR)、静态站点生成(SSG)、API路由和性能优化。
在这个项目中,我使用Next.js是因为:
- 它允许页面预渲染,从而提高性能和SEO
- 其内置路由使得管理页面转换和动态内容变得容易
- 它可以轻松与后端服务(如Appwrite和Permit.io)集成,用于认证和授权
Appwrite
Appwrite是一个后端即服务(BaaS)平台,提供用户认证、数据库、存储和无服务器功能。使用像Appwrite这样的服务消除了从头构建后端的需要,因此你可以专注于前端开发,同时拥有后端能力。
在这个项目中,我使用Appwrite:
- 处理用户注册、登录和会话管理
- 提供结构化的NoSQL数据库来存储租户特定数据
同时使用Next.js和Appwrite使我能够创建一个可扩展、高性能的多租户SaaS应用,同时保持开发过程高效。
多租户SaaS授权介绍
多租户SaaS应用是一种软件,使用单一应用程序实例服务多个用户或用户组,称为租户。
这意味着在多租户SaaS架构中,多个客户(租户)共享相同的应用程序基础设施或使用相同的应用程序,但保持数据隔离。
一个实际的例子是像Trello这样的项目管理工具:
- 它是一个单一基础设施,在共享服务器上运行,并为所有用户拥有相同的代码库
- 使用Trello的每个公司(例如公司A和公司B)都是一个租户
- 它隔离数据:公司A的员工只能看到他们的项目、任务和看板,公司B的员工无法访问或查看公司A的数据,反之亦然
这确保了虽然资源共享,但每个租户的数据和活动都是私密和安全的。
在多租户应用程序中,即使在租户内部,一些用户对某些信息具有更高的访问权限,而一些成员将被限制访问某些资源。
此类应用程序中的授权必须:
- 确保用户无法访问其他租户或客户的数据或资源(这称为租户隔离)
- 通过提供细粒度访问控制,确保租户内的用户只能访问其角色允许的资源
- 处理更多用户、租户和角色而不会减慢或降低性能
租户隔离和细粒度访问控制的重要性
租户隔离通过确保每个客户的信息保持私密来保护数据安全,而细粒度访问控制确保组织内的用户只获得他们需要的权限。
在SaaS应用中实现授权可能复杂且棘手,但当你拥有像Permit这样的授权工具时,就不必如此。
Permit是什么,它有什么好处?
Permit是一个易于使用的授权工具,用于管理任何应用程序中的访问,包括多租户应用。在你的应用中使用Permit.io可以轻松定义和分配具有特定权限的角色,用于应用程序内的访问控制。除了在应用程序内创建角色外,你还可以基于用户或资源属性添加条件和规则,以指定每个用户可以做什么和不可以做什么。
既然你已经了解了Permit及其好处的大部分内容,让我们进入主要内容——使用Next.js构建SaaS应用并集成Permit进行授权。
为了展示Permit的强大功能,我们将构建一个多租户教育技术SaaS平台。
构建教育技术SaaS平台涉及多个挑战,包括用户认证、基于角色的访问控制(RBAC)和多租户。我们将使用Next.js作为前端,Appwrite用于认证和数据库管理,Permit用于细粒度授权。
技术栈概述
技术 |
用途 |
Next.js |
前端框架 |
ShadCN + Tailwindcss |
UI组件和样式 |
Zustand |
状态管理 |
Appwrite |
认证和后台 |
Permit.io |
基于角色的访问控制 |
系统架构
应用程序遵循后端优先的方法:
后端(Node.js + Express)
- 处理API请求和业务逻辑
- 使用Appwrite进行认证和数据库管理
- 实现Permit进行授权,定义角色和权限
- 确保在数据访问之前验证每个请求
前端(Next.js)
- 安全地连接到后端获取数据
- 使用基于角色的UI渲染,意味着用户只能看到他们被授权访问的内容
- 基于权限限制操作(如创建作业)
通过在API级别强制执行授权,我们确保即使用户操纵前端,也无法绕过限制。
在本指南结束时,你将拥有一个功能完整的多租户教育技术SaaS应用,其中:
- 管理员可以添加和查看学生
- 教师可以添加和查看学生,以及创建作业
- 学生只能查看分配给他们的课程作业
本文提供了我如何实现Permit来处理授权以构建此项目的逐步分解,所以请跟随并构建你自己的项目。
使用Permit的后端实现
为了强制执行基于角色的访问控制(RBAC)和租户隔离,我们需要:
- 设置Permit并定义角色、租户和策略
- 在后端(Node.js + Express)中集成Permit
- 使用中间件保护API路由,在允许请求之前检查权限
让我们逐步进行。
1. 设置Permit
在编写任何代码之前,你需要:
创建Permit账户
你将会看到入门指南,但一旦输入组织名称,你可以跳过设置。
创建资源和操作
导航到策略部分,在那里你将创建一个资源和可以在该资源上执行的操作。
完成资源创建后,它应该看起来像这样:
创建角色
创建资源后,使用角色选项卡导航到角色页面。你会看到一些角色已自动分配。
删除这些角色并创建新角色。每个角色都将有特定的规则与之关联,关于用户可以做什么和不可以做什么。首先创建管理员角色,因为它稍后将作为RBAC条件的构建块。点击顶部的添加角色按钮并创建角色。
完成角色创建后,它应该看起来像这样:
很好!现在你已经创建了资源和角色,你可以在策略编辑器中配置权限。
在策略编辑器中配置权限
返回策略编辑器,现在角色将看起来像这样,每个单独的资源都已定义,你可以选择操作。你现在准备给角色授予权限,以对资源执行选定的操作。
完成为每个角色选择操作后,点击页面右下角的保存更改按钮。
复制API密钥
最后,要使用Permit的云PDP,你将需要当前环境的API密钥。对于这个项目,你将使用开发环境密钥。转到设置并点击API密钥,向下滚动到环境API密钥,点击"显示密钥",然后复制它。
设置好Permit仪表板后,你现在可以转到你的后端。
2. 安装依赖项
要开始,你需要在计算机上安装Node.js。确保系统上安装了Node.js后,按照以下步骤操作:
首先使用以下命令创建新项目:
1
2
3
|
mkdir backend
cd backend
npm init -y
|
然后安装以下包:
1
|
npm install express dotenv permitio cors appwrite axios jsonwebtoken
|
在Express中配置Permit。在你的.env文件中存储API密钥:
1
|
PERMIT_API_KEY=your-permit-key-you-copied-earlier
|
3. 设置Appwrite
转到Appwrite并通过输入项目名称和选择区域创建新项目。记下你的项目ID和API端点;这是你将在.env文件中输入的值。你的ENV文件应该看起来像这样:
1
2
3
|
PERMIT_API_KEY=your-permit-key-you-copied-earlier
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=your-project-id
|
现在继续创建数据库,然后复制数据库ID粘贴到你的ENV文件中。
你的ENV文件现在应该看起来像这样:
1
2
3
4
|
PERMIT_API_KEY=your-permit-key-you-copied-earlier
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=your-project-id
APPWRITE_DATABASE_ID=your-database-id
|
现在在Appwrite数据库中创建以下集合及其属性:
配置文件集合
学生集合
作业集合
此时你的ENV文件应该看起来像这样:
1
2
3
4
5
6
7
8
9
10
11
|
PERMIT_API_KEY=your-permit-key-you-copied-earlier
PERMIT_PROJECT_ID=copy-from-dashboard
PERMIT_ENV_ID=copy-from-dashboard
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=your-project-id
APPWRITE_DATABASE_ID=your-database-id
APPWRITE_PROFILE_COLLECTION_ID=your-id
APPWRITE_ASSIGNMENTS_COLLECTION_ID=your-id
APPWRITE_STUDENTS_COLLECTION_ID=your-id
JWT_SECRET=generate-this-by-running//openssl rand -base64 16
PORT=8080
|
4. 创建文件结构和文件
现在在文件根目录创建src文件夹。然后在根文件夹中生成tsconfig.json文件并粘贴以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
|
这个tsconfig.json配置TypeScript编译器以ES6为目标,使用CommonJS模块,并将文件输出到./dist。它强制执行严格类型检查,启用JSON模块解析,为src设置路径别名,并从编译中排除node_modules和dist。
在src文件夹内,创建以下文件夹:api、config、controllers、middleware、models和utils。
Utils文件夹
现在,在utils文件夹项目中创建一个新的permit.ts文件,使用以下代码初始化Permit:
1
2
3
4
5
6
7
8
9
10
11
12
|
import { Permit } from 'permitio';
import { PERMIT_API_KEY } from '../config/environment';
const permit = new Permit({
token: PERMIT_API_KEY,
pdp: 'https://cloudpdp.api.permit.io',
log: {
level: "debug",
},
});
export default permit;
|
这个文件初始化Permit的Node.js SDK,使用存储在环境中的API密钥连接到Permit PDP容器。它配置日志记录以进行调试,并设置SDK以静默处理错误,除非明确配置为抛出错误。
接下来,创建一个名为errorHandler.ts的文件并粘贴以下代码:
1
2
3
4
5
6
7
8
|
import { Request, Response, NextFunction } from 'express';
export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
console.error('Error:', err.message || err);
res.status(err.status || 500).json({
error: err.message || 'Internal Server Error',
});
};
|
这个文件定义了一个Express错误处理中间件,记录错误并发送带有错误消息和状态代码的JSON响应。如果没有提供特定状态,则默认为500状态代码。
Models文件夹
创建一个名为profile.ts的文件并粘贴以下代码:
1
2
3
4
5
6
|
export interface Profile {
name: string;
email: string;
role: 'Admin' | 'Teacher' | 'Student';
userId: string;
}
|
这个文件定义了一个TypeScript Profile接口,具有name、email、role和userId属性,其中role限制为特定值:Admin、Teacher或Student。
创建assignment.ts文件并粘贴以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
import { database, ID } from '../config/appwrite';
import { DATABASE_ID, ASSIGNMENTS_COLLECTION_ID } from '../config/environment';
export interface AssignmentData {
title: string;
subject: string;
className: string;
teacher: string;
dueDate: string;
creatorEmail: string;
}
export async function createAssignmentInDB(data: AssignmentData) {
return await database.createDocument(
DATABASE_ID,
ASSIGNMENTS_COLLECTION_ID,
ID.unique(),
data
);
}
export async function fetchAssignmentsFromDB() {
const response = await database.listDocuments(DATABASE_ID, ASSIGNMENTS_COLLECTION_ID);
return response.documents;
}
|
这个文件提供了与Appwrite数据库交互的函数,用于管理作业。它定义了AssignmentData接口,并包括创建新作业和从数据库获取所有作业的函数。
创建student.ts文件并粘贴以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
import { database, ID, Permission, Role, Query } from '../config/appwrite';
import { DATABASE_ID, STUDENTS_COLLECTION_ID } from '../config/environment';
export interface StudentData {
firstName: string;
lastName: string;
gender: 'girl' | 'boy' | 'Boy' | 'Girl';
className: string;
age: number;
creatorEmail: string;
}
export async function createStudentInDB(data: StudentData) {
return await database.createDocument(
DATABASE_ID,
STUDENTS_COLLECTION_ID,
ID.unique(),
data,
[
Permission.read(Role.any()),
]
);
}
export async function fetchStudentsFromDB() {
const response = await database.listDocuments(DATABASE_ID, STUDENTS_COLLECTION_ID);
return response.documents;
}
|
这个文件提供了管理Appwrite数据库中学生数据的函数。它定义了StudentData接口,并包括创建具有公共读取权限的新学生和从数据库获取所有学生的函数。
Middleware文件夹
创建auth.ts文件并粘贴以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
interface AuthenticatedRequest extends Request {
user?: {
id: string;
role: string;
};
}
const authMiddleware = (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'Unauthorized. No token provided' });
return
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { id: string; role: string };
req.user = decoded;
next();
} catch (error) {
res.status(403).json({ error: 'Invalid token' });
return
}
};
export default authMiddleware;
|
这个文件定义了一个基于JWT的Express认证中间件。它检查请求头中的有效令牌,使用密钥验证它,并将解码的用户信息(ID和角色)附加到请求对象。如果令牌缺失或无效,则返回适当的错误响应。
创建permit.ts并粘贴以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
import permit from '../utils/permit';
export const checkUsertoPermitStudents = async (email: string, action: string, resource: string): Promise<boolean> => {
try {
const permitted = await permit.check(email, action, resource);
console.log("Permitted", permitted);
return permitted;
} catch (error) {
console.error(`Error syncing user ${email} to Permit.io:`, error);
return false;
}
};
export const checkUserToPermitAssignment = async (email: string, action: string, resource: string): Promise<boolean> => {
try {
const permitted = await permit.check(email, action, resource);
console.log("Permitted", permitted);
return permitted;
} catch (error) {
console.error(`Error syncing user ${email} to Permit.io:`, error);
return false;
}
};
|
这个文件定义了实用函数checkUsertoPermitStudents和checkUserToPermitAssignment,用于检查用户在Permit中对特定操作和资源的权限。两个函数都优雅地处理错误,记录问题并在权限检查失败时返回false。它们用于在应用程序中强制执行授权。
Controllers文件夹
创建auth.ts文件并粘贴以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
import { account, ID } from '../config/appwrite';
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET as string;
export const signUp = async (req: Request, res: Response) => {
const { email, password, name } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ error: 'Name, email, and password are required.' });
}
try {
const user = await account.create(ID.unique(), email, password, name);
const token = jwt.sign({ email }, JWT_SECRET, { expiresIn: '8h' });
res.cookie('token', token, {
httpOnly: true,
sameSite: 'strict',
secure: true,
});
res.status(201).json({ success: true, user, token });
} catch (error: any) {
console.error('Sign-up Error:', error);
res.status(500).json({ success: false, message: error.message });
}
};
export const login = async (req: Request, res: Response) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required.' });
}
try {
const session = await account.createEmailPasswordSession(email, password);
const token = jwt.sign(
{ userId: session.userId, email },
JWT_SECRET,
{ expiresIn: '8h' }
);
res.cookie('token', token, {
httpOnly: true,
sameSite: 'strict',
secure: true,
});
res.status(200).json({ success: true, token, session });
} catch (error: any) {
console.error('Login Error:', error);
res.status
|