在任何一个SaaS产品中,多租户权限系统的设计都是绕不开的核心难题。传统的做法通常是在关系型数据库中使用大量的中间表来维护用户、角色、租户、权限之间的关系。这种模型在业务初期尚可应付,但随着权限维度的增加——例如引入项目组、资源标签等概念——查询会变得异常复杂且性能低下。一次简单的权限校验可能需要经历5到6次JOIN
操作,这在真实项目中是不可接受的。
我们的团队面临的正是这样一个挑战:需要构建一个支持复杂组织架构、权限可动态配置、且能保证租户间数据严格隔离的系统。性能和可维护性是首要考量。
初步构想与技术选型决策
放弃关系型数据库几乎是第一时间就达成的共识。我们的目光转向了图数据库,因为它能以一种更自然的方式来描述实体间的复杂关系。初步构想是:
- 数据层: 使用图数据库对权限模型进行建模。用户、租户、角色、权限、资源等都可以作为图中的节点,它们之间的关系则为边。
- 服务层: 采用GraphQL作为API层,这不仅与图数据库的查询心智模型天然契合,还能为前端提供灵活的数据获取能力。
- 部署架构: Serverless。权限校验这类计算密集但非持续高负载的场景,非常适合函数计算模型,可以最大化地降低闲置成本。
- 前端状态: 需要一个足够简单且强大的响应式状态管理库,能够根据用户权限的动态变化实时更新UI。
基于以上构想,我们最终的技术栈锁定为:Dgraph + Serverless (AWS Lambda) + MobX。
为什么是Dgraph? Dgraph是为GraphQL而生的分布式图数据库。它的杀手锏在于可以直接将GraphQL Schema转化为数据库Schema,并自动生成大部分CRUD的Resolver。更重要的是,它的查询语言GraphQL+(现为DQL)在处理深度图遍历时性能极高,这对于沿着权限关系链进行校验至关重要。
为什么是Serverless (AWS Lambda)? 我们的认证和授权逻辑是无状态的。每个API请求都携带身份凭证(如JWT),函数接收请求、执行校验、查询数据、返回结果。这个流程完美契合Lambda的事件驱动模型。结合API Gateway,我们可以轻松构建一个高可用、自动扩缩容的GraphQL端点,且只需为实际的计算时间付费。
为什么是MobX? 在权限系统中,用户的状态(登录与否、所属角色、可见菜单等)是驱动整个UI的核心。MobX通过其透明的函数响应式编程(TFRP)模型,能够让我们以一种非常直观的方式将状态与UI组件关联起来。当权限状态变更时,相关的UI会自动更新,无需手动编写繁琐的更新逻辑。这相比Redux等库,样板代码更少,心智负担更低。
步骤化实现:从Schema到响应式UI
1. Dgraph Schema 定义:权限模型的基石
首先,我们需要在Dgraph中定义我们的图模型。这里的核心是清晰地表达出多租户的隔离性以及用户-角色-权限的继承关系。
# Dgraph GraphQL Schema (schema.graphql)
# Tenant是数据隔离的最高单元
type Tenant {
id: ID!
name: String! @search(by: [hash])
users: [User!] @hasInverse(field: tenant) # 一个租户下有多个用户
roles: [Role!] @hasInverse(field: tenant) # 租户可以自定义角色
}
# User是系统的操作实体
type User {
id: ID!
email: String! @id @search(by: [hash]) # email作为唯一标识
tenant: Tenant! # 每个用户必须属于一个租户
roles: [Role!] @hasInverse(field: users) # 用户可以拥有多个角色
}
# Role是权限的集合
type Role {
id: ID!
name: String! @search(by: [term])
tenant: Tenant! # 角色归属于特定租户
users: [User!]
permissions: [Permission!] @hasInverse(field: roles) # 角色包含多个权限
}
# Permission是原子操作权限定义
# 例如:READ_DOCUMENT, WRITE_DOCUMENT
type Permission {
id: ID!
action: String! @id @search(by: [hash]) # 权限动作,全局唯一
roles: [Role!]
}
# 受保护的资源示例
type Document {
id: ID!
title: String!
content: String
tenantId: String! @search(by: [hash]) # 关键:用tenantId做硬隔离
}
设计解读:
-
@id
指令将User.email
和Permission.action
定义为外部唯一标识符,方便我们通过这些有业务含义的字段直接进行查找和关联。 - 每个核心实体都与
Tenant
直接或间接关联。特别是Document
这样的业务数据,我们显式地加入tenantId
字段。虽然通过图关系也能追溯到租户,但在查询时直接使用tenantId
进行过滤是实现数据硬隔离的最有效手段,避免了跨租户数据泄露的风险。这是一个在真实项目中至关重要的安全实践。 -
@hasInverse
指令帮助Dgraph自动管理双向关系,简化了数据操作。
2. Serverless 后端:带权限校验的GraphQL端点
我们将使用AWS Lambda、API Gateway和Apollo Server for Lambda来构建GraphQL服务。
项目结构:
/graphql-lambda
- src/
- schema.graphql
- resolvers.js # 自定义Resolver逻辑
- context.js # 创建GraphQL上下文,处理认证
- DgraphClient.js # Dgraph客户端配置
- handler.js # Lambda入口文件
- serverless.yml # Serverless Framework配置文件
- package.json
serverless.yml
配置:
service: multi-tenant-graphql-api
frameworkVersion: '3'
provider:
name: aws
runtime: nodejs18.x
region: us-east-1
environment:
DGRAPH_ENDPOINT: ${ssm:/myapp/dgraph-endpoint} # 从参数存储中安全地获取敏感信息
functions:
graphql:
handler: handler.graphqlHandler
events:
- http:
path: graphql
method: post
cors: true
- http:
path: graphql
method: get # for GraphQL Playground in dev
cors: true
plugins:
- serverless-offline
解读:
- 使用
serverless-offline
插件方便本地开发调试。 - 一个常见的错误是将敏感信息如数据库地址硬编码。这里我们通过
${ssm:...}
语法从AWS Systems Manager Parameter Store中读取,这是生产环境的最佳实践。
src/context.js
: 认证与上下文构建
这是整个权限系统的咽喉。它负责解析请求头中的JWT,验证其有效性,并从Token的payload中提取用户信息(如userId
, tenantId
),注入到GraphQL的context
对象中,供后续所有Resolver使用。
// src/context.js
import { AuthenticationError } from 'apollo-server-lambda';
import jwt from 'jsonwebtoken';
import { getDgraphClient } from './DgraphClient';
const JWT_SECRET = process.env.JWT_SECRET || 'a-very-secret-key';
/**
* 从Dgraph查询用户的完整权限信息
* @param {string} userId - 用户ID
* @returns {Promise<object>} - 包含租户ID和权限列表的对象
*/
const fetchUserPermissions = async (userId) => {
const dgraphClient = getDgraphClient();
const query = `
query getUserContext($email: String!) {
queryUser(filter: { email: { eq: $email } }) {
id
tenant {
id
}
roles {
permissions {
action
}
}
}
}
`;
// 在真实项目中,Dgraph Client应该是单例或可复用的
const txn = dgraphClient.newTxn({ readOnly: true });
try {
const res = await txn.queryWithVars(query, { $email: userId });
const userData = res.getJson().queryUser[0];
if (!userData) {
// 日志记录:严重的安全事件,Token有效但用户不存在
console.error(`CRITICAL: User not found for valid token. UserID: ${userId}`);
throw new Error('User not found');
}
// 将权限扁平化为一个Set,方便快速查找
const permissions = new Set();
userData.roles.forEach(role => {
role.permissions.forEach(p => permissions.add(p.action));
});
return {
userId: userData.id,
tenantId: userData.tenant.id,
permissions: permissions,
};
} catch (error) {
console.error('Error fetching user permissions:', error);
// 抛出通用错误,避免泄露内部实现细节
throw new AuthenticationError('Could not retrieve user permissions.');
} finally {
await txn.discard();
}
};
export const createContext = async ({ event }) => {
const token = event.headers.authorization || '';
if (!token) {
return { user: null }; // 允许匿名访问某些查询
}
try {
const decoded = jwt.verify(token.replace('Bearer ', ''), JWT_SECRET);
// Token有效,现在从数据库获取最新的、权威的权限信息
// 这里的坑在于:不能完全相信JWT payload中的权限信息,因为它可能已经过时。
// 每次请求都重新查询数据库可以保证权限的实时性。
// 对于高性能要求的场景,可以引入一层短时缓存 (e.g., Redis)。
const userContext = await fetchUserPermissions(decoded.email);
return { user: userContext };
} catch (err) {
console.warn(`JWT validation failed: ${err.message}`);
// 对于过期的token或者无效签名,我们依然可以返回一个空的user对象
// 或者直接抛出AuthenticationError,取决于你的业务是否允许部分匿名访问。
throw new AuthenticationError('Your session is invalid or has expired.');
}
};
src/resolvers.js
: 实现数据隔离和权限校验
现在,我们可以在Resolver中利用context
对象来强制执行业务规则。
// src/resolvers.js
import { ForbiddenError } from 'apollo-server-lambda';
import { getDgraphClient } from './DgraphClient';
/**
* 权限校验辅助函数
* @param {object} userContext - 从GraphQL context中获取的用户信息
* @param {string} requiredPermission - 所需的原子权限
*/
const requirePermission = (userContext, requiredPermission) => {
if (!userContext || !userContext.permissions.has(requiredPermission)) {
// 记录授权失败事件,用于安全审计
console.warn(`Authorization failed for user ${userContext?.userId}. Missing permission: ${requiredPermission}`);
throw new ForbiddenError('You do not have sufficient permissions to perform this action.');
}
};
export const resolvers = {
Query: {
// 获取当前租户下的所有文档
listDocuments: async (_, __, context) => {
requirePermission(context.user, 'READ_DOCUMENT');
const { tenantId } = context.user;
const dgraphClient = getDgraphClient();
// Dgraph的GraphQL API会自动生成queryDocument,但我们自定义以加入租户过滤
// 这里的实现方式是调用Dgraph的原生DQL (或 GraphQL+- ),以获得最大灵活性
const query = `
query getDocuments($tenantId: String!) {
queryDocument(filter: { tenantId: { eq: $tenantId } }) {
id
title
content
}
}
`;
const txn = dgraphClient.newTxn({ readOnly: true });
try {
const res = await txn.queryWithVars(query, { $tenantId: tenantId });
return res.getJson().queryDocument || [];
} finally {
await txn.discard();
}
},
},
Mutation: {
createDocument: async (_, { title, content }, context) => {
requirePermission(context.user, 'WRITE_DOCUMENT');
const { tenantId, userId } = context.user;
// Dgraph的add... mutation是自动生成的,但它无法自动注入tenantId
// 因此我们需要自定义mutation
// 在生产环境中,推荐使用Dgraph的自定义DQL mutation来实现原子性操作
// 这里为简化示例,我们使用GraphQL mutation并手动添加tenantId
const dgraphClient = getDgraphClient();
const mutation = `
mutation addDoc($title: String!, $content: String, $tenantId: String!) {
addDocument(input: [{
title: $title,
content: $content,
tenantId: $tenantId
}]) {
document {
id
}
}
}
`;
const txn = dgraphClient.newTxn();
try {
const vars = { $title: title, $content: content, $tenantId: tenantId };
const res = await txn.mutate({ mutation, vars });
await txn.commit();
// 返回新创建的文档信息,注意从mutation响应中正确解析
return res.getJson().addDocument.document[0];
} catch (err) {
console.error(`Failed to create document for tenant ${tenantId} by user ${userId}`, err);
await txn.discard();
throw new Error('Failed to create document.');
}
}
}
};
代码解读:
-
requirePermission
: 这是一个核心的抽象。所有需要权限控制的Resolver都应该在执行业务逻辑前调用它。 - 数据隔离: 在
listDocuments
查询中,filter: { tenantId: { eq: $tenantId } }
是关键。它确保了即使用户拥有READ_DOCUMENT
权限,也只能查询到自己租户下的数据。这是多租户系统中最重要的一道防线。 - 自定义Mutation: Dgraph自动生成的
addDocument
无法满足我们注入tenantId
的需求。因此,我们编写了自定义的mutation。在真实项目中,这部分逻辑会更复杂,可能需要用DQL在一个事务中创建文档并建立与其他节点(如创建者)的关联。
请求流程可视化:
sequenceDiagram participant Client participant API Gateway participant Lambda as "GraphQL Lambda" participant Dgraph Client->>API Gateway: POST /graphql (Query + JWT) API Gateway->>Lambda: Invoke function with event Lambda->>Lambda: context.js: Verify JWT Lambda->>Dgraph: Query user roles & permissions Dgraph-->>Lambda: Return user context data Lambda->>Lambda: resolvers.js: requirePermission('...') alt Permission OK Lambda->>Dgraph: Query documents with tenantId filter Dgraph-->>Lambda: Return filtered documents Lambda-->>API Gateway: 200 OK with data API Gateway-->>Client: GraphQL Response else Permission Denied Lambda-->>API Gateway: 4xx Error (Forbidden) API Gateway-->>Client: GraphQL Error Response end
3. MobX 前端实践:构建响应式权限UI
前端的目标是:
- 安全地存储JWT。
- 在API请求中自动附加Token。
- 根据用户权限状态,动态地渲染UI。
AuthStore.js
// src/stores/AuthStore.ts
import { makeAutoObservable, reaction, runInAction } from 'mobx';
import { ApolloClient, InMemoryCache, createHttpLink, gql } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
// 这是一个简化的用户权限模型,与后端对应
interface UserProfile {
userId: string;
tenantId: string;
permissions: Set<string>;
}
export class AuthStore {
token: string | null = localStorage.getItem('jwt_token');
user: UserProfile | null = null;
isLoading: boolean = false;
apolloClient: ApolloClient<any>;
constructor() {
makeAutoObservable(this);
this.apolloClient = this.createApolloClient();
// 当token变化时,自动触发用户信息的加载或清除
reaction(
() => this.token,
(token) => {
if (token) {
this.loadUserProfile();
} else {
this.user = null;
}
// 重新创建 Apollo Client 以更新 auth link
this.apolloClient = this.createApolloClient();
},
{ fireImmediately: true } // 实例化时立即执行一次
);
}
// 核心:创建一个附加认证头的Apollo Client
private createApolloClient() {
const httpLink = createHttpLink({
uri: 'YOUR_GRAPHQL_ENDPOINT',
});
const authLink = setContext((_, { headers }) => {
return {
headers: {
...headers,
authorization: this.token ? `Bearer ${this.token}` : "",
}
}
});
return new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
});
}
login = async (email, password) => {
this.isLoading = true;
try {
// 模拟登录获取token
const fetchedToken = await this.mockLoginApi(email, password);
runInAction(() => {
this.token = fetchedToken;
localStorage.setItem('jwt_token', fetchedToken);
this.isLoading = false;
});
} catch (error) {
runInAction(() => {
this.isLoading = false;
// 处理登录错误
});
}
}
logout = () => {
this.token = null;
localStorage.removeItem('jwt_token');
// 清理Apollo缓存,防止数据泄露
this.apolloClient.clearStore();
}
// 检查用户是否拥有特定权限
hasPermission = (permission: string): boolean => {
return this.user?.permissions.has(permission) ?? false;
}
private loadUserProfile = async () => {
if (!this.token) return;
// 从后端获取当前用户的权限信息
const GET_USER_PROFILE = gql`
query GetUserProfile {
me { # 假设有一个'me'查询返回当前用户信息
id
tenant { id }
roles { permissions { action } }
}
}
`;
try {
const { data } = await this.apolloClient.query({ query: GET_USER_PROFILE });
const profile = data.me;
const permissions = new Set<string>(
profile.roles.flatMap(r => r.permissions.map(p => p.action))
);
runInAction(() => {
this.user = {
userId: profile.id,
tenantId: profile.tenant.id,
permissions: permissions,
};
});
} catch (error) {
// Token无效或过期,自动登出
console.error("Failed to load user profile, logging out.", error);
this.logout();
}
}
// 模拟API
private mockLoginApi = (email, pass) => Promise.resolve('fake-jwt-token-for-' + email);
}
React组件中的使用:
// src/components/ProtectedComponent.tsx
import React from 'react';
import { observer } from 'mobx-react-lite';
import { useStores } from '../hooks/useStores';
const CreateDocumentButton = observer(() => {
const { authStore } = useStores();
// MobX的魔力:当authStore.user或其permissions变化时,组件会自动重渲染
if (!authStore.hasPermission('WRITE_DOCUMENT')) {
return null; // 用户没有权限,直接不渲染该组件
}
const handleCreate = () => {
// 调用一个使用authStore.apolloClient的mutation
console.log("Creating document...");
};
return <button onClick={handleCreate}>Create New Document</button>;
});
const UserProfileDisplay = observer(() => {
const { authStore } = useStores();
if (!authStore.user) {
return <div>Please log in.</div>;
}
return (
<div>
<p>User ID: {authStore.user.userId}</p>
<p>Tenant ID: {authStore.user.tenantId}</p>
{/* 其他UI元素 */}
<CreateDocumentButton />
</div>
);
});
export default UserProfileDisplay;
前端实践要点:
- 状态驱动UI:
observer
HOC和authStore.hasPermission
的结合,使得权限控制逻辑变得声明式且优雅。业务组件无需关心权限如何获取和更新,只需消费authStore
提供的状态即可。 - 自动认证头: 通过
setContext
创建的authLink
,确保了所有发往后端的GraphQL请求都自动携带了最新的JWT。 - 响应式数据流:
reaction
的使用展示了MobX的强大之处。它创建了一个响应式副作用,当token
这个被观察的状态变化时,自动执行数据加载或清理逻辑,整个认证流程被串联起来,清晰且健壮。 - 登出安全:
logout
时必须调用apolloClient.clearStore()
,这是一个容易被忽略但极为重要的安全措施,可以防止下一个登录的用户看到上一个用户的缓存数据。
局限性与未来迭代路径
这个架构虽然解决了核心问题,但在生产环境中还存在一些需要权衡和优化的点。
Serverless冷启动: AWS Lambda的冷启动可能会给某些对延迟敏感的操作带来影响。对于需要持续低延迟的场景,可以考虑使用预置并发(Provisioned Concurrency),但这会增加成本。或者,对于读密集型操作,可以在API Gateway前加一层CDN或缓存。
权限校验的性能: 当前方案在每次请求时都会查询Dgraph获取实时权限,这保证了安全性但增加了数据库负载和请求延迟。一个可行的优化路径是引入一个高速缓存层(如Redis),将用户的权限集合缓存几秒钟或一分钟。但这需要在数据一致性和性能之间做出权衡。
复杂的授权逻辑:
requirePermission
这种简单的权限检查适用于大多数场景。但如果未来出现更复杂的ABAC(基于属性的访问控制)或ReBAC(基于关系的访问控制)需求,例如“只有文档的创建者或者同部门的主管才能编辑”,那么授权逻辑就需要从Resolver中剥离出来,形成一个独立的、可重用的授权服务或模块,甚至可以引入OPA (Open Policy Agent) 这类策略引擎。事务与数据一致性: 示例中的
createDocument
是一个简单的单点写入。如果一个操作需要原子性地修改图中的多个节点和边,就需要更深入地使用Dgraph的DQL事务,以确保操作的ACID特性。Serverless函数的短暂生命周期也给长事务带来了挑战。