构建基于Serverless与Dgraph的响应式多租户权限系统及MobX前端实践


在任何一个SaaS产品中,多租户权限系统的设计都是绕不开的核心难题。传统的做法通常是在关系型数据库中使用大量的中间表来维护用户、角色、租户、权限之间的关系。这种模型在业务初期尚可应付,但随着权限维度的增加——例如引入项目组、资源标签等概念——查询会变得异常复杂且性能低下。一次简单的权限校验可能需要经历5到6次JOIN操作,这在真实项目中是不可接受的。

我们的团队面临的正是这样一个挑战:需要构建一个支持复杂组织架构、权限可动态配置、且能保证租户间数据严格隔离的系统。性能和可维护性是首要考量。

初步构想与技术选型决策

放弃关系型数据库几乎是第一时间就达成的共识。我们的目光转向了图数据库,因为它能以一种更自然的方式来描述实体间的复杂关系。初步构想是:

  1. 数据层: 使用图数据库对权限模型进行建模。用户、租户、角色、权限、资源等都可以作为图中的节点,它们之间的关系则为边。
  2. 服务层: 采用GraphQL作为API层,这不仅与图数据库的查询心智模型天然契合,还能为前端提供灵活的数据获取能力。
  3. 部署架构: Serverless。权限校验这类计算密集但非持续高负载的场景,非常适合函数计算模型,可以最大化地降低闲置成本。
  4. 前端状态: 需要一个足够简单且强大的响应式状态管理库,能够根据用户权限的动态变化实时更新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.emailPermission.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.');
      }
    }
  }
};

代码解读:

  1. requirePermission: 这是一个核心的抽象。所有需要权限控制的Resolver都应该在执行业务逻辑前调用它。
  2. 数据隔离: 在listDocuments查询中,filter: { tenantId: { eq: $tenantId } }是关键。它确保了即使用户拥有READ_DOCUMENT权限,也只能查询到自己租户下的数据。这是多租户系统中最重要的一道防线。
  3. 自定义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(),这是一个容易被忽略但极为重要的安全措施,可以防止下一个登录的用户看到上一个用户的缓存数据。

局限性与未来迭代路径

这个架构虽然解决了核心问题,但在生产环境中还存在一些需要权衡和优化的点。

  1. Serverless冷启动: AWS Lambda的冷启动可能会给某些对延迟敏感的操作带来影响。对于需要持续低延迟的场景,可以考虑使用预置并发(Provisioned Concurrency),但这会增加成本。或者,对于读密集型操作,可以在API Gateway前加一层CDN或缓存。

  2. 权限校验的性能: 当前方案在每次请求时都会查询Dgraph获取实时权限,这保证了安全性但增加了数据库负载和请求延迟。一个可行的优化路径是引入一个高速缓存层(如Redis),将用户的权限集合缓存几秒钟或一分钟。但这需要在数据一致性和性能之间做出权衡。

  3. 复杂的授权逻辑: requirePermission这种简单的权限检查适用于大多数场景。但如果未来出现更复杂的ABAC(基于属性的访问控制)或ReBAC(基于关系的访问控制)需求,例如“只有文档的创建者或者同部门的主管才能编辑”,那么授权逻辑就需要从Resolver中剥离出来,形成一个独立的、可重用的授权服务或模块,甚至可以引入OPA (Open Policy Agent) 这类策略引擎。

  4. 事务与数据一致性: 示例中的createDocument是一个简单的单点写入。如果一个操作需要原子性地修改图中的多个节点和边,就需要更深入地使用Dgraph的DQL事务,以确保操作的ACID特性。Serverless函数的短暂生命周期也给长事务带来了挑战。


  目录