使用 Elixir 与 ArangoDB 图模型构建多租户系统的动态权限校验层


在构建一个复杂的多租户SaaS平台时,权限管理很快就从一个看似简单的角色分配问题演变成一场噩梦。最初,我们用关系型数据库和几张关联表(users_roles, roles_permissions)来支撑,这在租户结构扁平、权限单一的初期运行良好。但随着业务发展,客户要求更细粒度的控制:项目级别的角色、团队继承的权限、甚至针对特定资源的临时授权。关系模型瞬间变得臃ycyj——查询需要深度递归(Recursive CTEs),一次权限检查可能涉及 5 到 6 次 JOIN 操作,数据表的结构也愈发僵化,每次权限模型的调整都意味着一次痛苦的数据迁移。

性能瓶颈和维护成本的双重压力迫使我们重新审视整个技术栈。我们需要一个能够原生表达“关系”的模型,一个能处理高并发租户请求且具备容错能力的运行时,以及一个能在客户端和服务端之间安全传递身份和权限的标准化协议。

初步构想是:用图数据库来描述权限,因为权限本质上就是“主体-关系-客体”的图。Elixir/OTP 的并发模型和容错机制天然适合SaaS后端,每个租户的请求可以被视为独立的、可监督的进程。JWT则作为无状态认证的载体。前端,我们团队使用Dart(Flutter),需要一个清晰的集成方案。

最终技术选型决策如下:

  • 数据库: ArangoDB。它是一个多模型数据库,我们可以将用户、租户等核心实体存储为文档,同时将它们之间的权限关系构建成图。其AQL(ArangoDB Query Language)对图遍历提供了强大的原生支持。
  • 后端: Elixir + Phoenix Framework。BEAM虚拟机无可匹敌的并发处理能力和轻量级进程,能够轻松应对大量租户的并发连接。其强大的模式匹配和管道操作符让复杂的业务逻辑变得清晰。
  • 认证: JWT。将用户ID、租户ID、会话版本等关键信息编码进token,实现服务端无状态校验,易于水平扩展。
  • 客户端: Dart。我们将展示如何在Dart应用中管理JWT生命周期并与后端API进行安全交互。

本文将记录从数据模型设计到核心代码实现的完整构建过程,重点是那套位于系统核心的、动态且高性能的权限校验层。

1. ArangoDB中的图模型设计

权限的本质是图。一个用户(User)属于一个租户(Tenant),在一个项目中(Project)被赋予一个角色(Role),这个角色包含若干权限(Permission)。我们将这些实体定义为“顶点”(Vertex),将它们之间的关系定义为“边”(Edge)。

在ArangoDB中,我们创建以下集合:

  • 顶点集合 (Vertex Collections):

    • users: 存储用户信息(_key 可以是用户UUID)。
    • tenants: 存储租户信息。
    • roles: 存储角色定义,如“管理员”、“编辑”。
    • permissions: 存储原子权限,如 projects:create, documents:delete
    • resources: 代表被保护的实体,如项目、文档等。
  • 边集合 (Edge Collections):

    • member_of: 连接 userstenants,表示用户是租户成员。
    • has_role: 连接 usersroles,但通常会带上上下文,比如在哪个resources上拥有该角色。
    • has_permission: 连接 rolespermissions
    • belongs_to: 连接 resourcestenants,表示资源归属于某个租户。
    • parent_of: 连接 resources 自身,形成资源层级结构(如组织->部门->项目)。

这是一个更具体的实现图:

graph TD
    subgraph Vertices
        U(User: user_A)
        T(Tenant: tenant_1)
        R(Role: project_admin)
        P1(Permission: projects:edit)
        P2(Permission: documents:create)
        RES(Resource: project_X)
    end

    subgraph Edges
        U -- member_of --> T
        RES -- belongs_to --> T
        U -- "has_role @project_X" --> R
        R -- has_permission --> P1
        R -- has_permission --> P2
    end

这种模型的好处是极高的灵活性。想要实现权限继承?只需要在资源图上沿着 parent_of 边向上遍历即可。想要临时授权?直接在用户和权限之间创建一条临时的 has_permission 边,并设置TTL。

2. Elixir后端:构建校验核心

首先,我们需要一个Elixir项目并配置ArangoDB的驱动。这里我们使用 arangox

config/config.exs

# config/config.exs
import Config

config :my_app, MyApp.Repo,
  hostname: "localhost",
  port: 8529,
  database: "rbac_system",
  username: "root",
  password: System.get_env("ARANGO_ROOT_PASSWORD"),
  pool_size: 10,
  # 在真实项目中,这里应该使用更安全的配置管理方式
  json_library: Jason

2.1 JWT的生成与声明设计

用户的认证成功后,我们需要颁发一个JWT。这里的关键在于设计好claims,它必须包含执行权限检查所需的所有上下文。一个常见的错误是只在JWT中放入user_id,导致每次请求都需要额外查询数据库来获取租户等信息。

# lib/my_app_web/auth/jwt_service.ex
defmodule MyAppWeb.Auth.JWTService do
  @moduledoc """
  处理JWT的签发与校验
  """
  alias Joken

  # 使用应用配置,不要硬编码
  @secret Application.get_env(:my_app, :joken_secret)
  @joken_config Joken.Config.default(%{"alg" => "HS256"}, @secret)

  @doc """
  为用户生成访问令牌 (Access Token)
  """
  def generate_access_token(user, tenant) do
    claims = %{
      sub: user.id,            # Subject, 用户ID
      tid: tenant.id,          # Tenant ID
      aud: "user_access",      # Audience
      iss: "MyApp",            # Issuer
      # session_v: user.session_version # 用于实现登出所有设备
    }

    # access_token 有效期较短,例如15分钟
    Joken.generate_and_sign(claims, %{"exp" => token_exp(minutes: 15)}, @joken_config)
  end

  @doc """
  校验令牌
  """
  def verify(token) do
    case Joken.verify_and_validate(token, @joken_config) do
      {:ok, claims} -> {:ok, claims}
      {:error, reason} ->
        # 实际项目中应记录日志
        Logger.warn("JWT verification failed: #{inspect(reason)}")
        {:error, reason}
    end
  end

  defp token_exp(minutes: min), do: DateTime.utc_now() |> DateTime.add(min * 60, :second) |> DateTime.to_unix()
end

关键考量:

  • tid (Tenant ID) 被直接放入了claims。这至关重要,它确保了所有后续操作都在正确的租户隔离下进行。
  • sub (Subject) 是用户的唯一标识。
  • 我们没有把用户的角色或权限直接放进JWT。因为权限是动态的,随时可能被撤销。如果放在JWT里,用户下线前权限无法实时更新。每次请求都进行一次实时图查询才是最安全可靠的。

2.2 Phoenix Plug:权限校验的守门人

我们需要一个自定义的Plug来拦截需要权限保护的API请求,执行校验逻辑。

# lib/my_app_web/plugs/check_permission.ex
defmodule MyAppWeb.Plugs.CheckPermission do
  import Plug.Conn
  alias MyApp.Permissions.Checker
  alias MyAppWeb.Auth.JWTService

  def init(opts), do: opts

  def call(conn, required_permission) do
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
         {:ok, claims} <- JWTService.verify(token),
         # 这里的实现是关键
         :ok <- check_graph_permission(claims, required_permission) do
      # 权限校验通过,将claims注入conn,方便后续controller使用
      assign(conn, :current_claims, claims)
    else
      # 任何一步失败,都返回403 Forbidden
      _error ->
        conn
        |> put_status(:forbidden)
        |> Phoenix.Controller.json(%{error: %{code: "permission_denied", message: "You don't have permission to perform this action."}})
        |> halt()
    end
  end

  # 实际的权限检查逻辑委托给一个专门的模块
  defp check_graph_permission(claims, required_permission) do
    # 从claims中获取上下文
    user_id = claims["sub"]
    tenant_id = claims["tid"]
    
    # 这里的 Checker.has_permission?/3 是核心
    case Checker.has_permission?(user_id, tenant_id, required_permission) do
      true -> :ok
      false -> {:error, :permission_denied}
    end
  end
end

在Router中这样使用它:

# lib/my_app_web/router.ex
pipeline :api_protected do
  plug :accepts, ["json"]
  # 先校验JWT,再检查具体权限
  plug MyAppWeb.Plugs.CheckPermission, "projects:create"
end

scope "/api", MyAppWeb do
  pipe_through [:api_protected]
  post "/projects", ProjectController, :create
end

2.3 核心:AQL图遍历查询

Checker.has_permission?/3 函数是整个系统的核心。它负责构建并执行AQL查询,以确定用户在当前租户上下文中是否拥有目标权限。

# lib/my_app/permissions/checker.ex
defmodule MyApp.Permissions.Checker do
  alias MyApp.Repo

  @doc """
  检查用户在特定租户下是否拥有指定权限。
  返回 true 或 false.
  """
  def has_permission?(user_id, tenant_id, required_permission) do
    user_doc_id = "users/#{user_id}"
    tenant_doc_id = "tenants/#{tenant_id}"
    perm_doc_id = "permissions/#{required_permission}" # 假设permission的_key就是它的名字

    # 这个AQL查询是整个系统的引擎
    query = """
    WITH users, tenants, roles, permissions
    
    // 1. 定义遍历的起点,即当前用户
    LET startNode = DOCUMENT(@user_doc_id)
    
    // 2. 检查用户是否属于目标租户,这是安全的第一道防线
    LET isMember = (
      FOR v, e IN 1..1 OUTBOUND startNode member_of
        FILTER v._id == @tenant_doc_id
        LIMIT 1
        RETURN 1
    )
    
    // 3. 如果不是租户成员,直接短路返回
    FILTER LENGTH(isMember) > 0
    
    // 4. 从用户开始,在图中寻找一条通往目标权限的路径
    //    这里的路径定义为:User -> Role -> Permission
    FOR v, e, p IN 2..2 OUTBOUND startNode has_role, has_permission
      // p.vertices[1] 是中间节点,即角色
      // v 是最终节点,即权限
      FILTER v._id == @perm_doc_id
      
      // 5. [核心] 校验这条路径的上下文是否正确
      //    例如,has_role这条边可能附带了一个resource_id属性
      //    这里简化为检查角色是否在当前租户的全局角色中
      //    更复杂的场景会在这里加入更多FILTER条件
      
      LIMIT 1 // 只要找到一条路径即可
      RETURN true
    """

    bind_vars = %{
      "user_doc_id" => user_doc_id,
      "tenant_doc_id" => tenant_doc_id,
      "perm_doc_id" => perm_doc_id
    }
    
    # 执行查询
    case Repo.aql(query, bind_vars) do
      {:ok, [true]} ->
        true
      # 查询没有返回结果,意味着没有权限
      {:ok, []} ->
        false
      {:error, reason} ->
        # 生产环境中必须有详细的日志和监控
        Logger.error("AQL permission check failed: #{inspect(reason)}")
        false
    end
  end
end

AQL查询解析:

  1. WITH ...: 显式声明查询涉及的集合,有助于ArangoDB优化。
  2. LET startNode = ...: 定义查询起点为当前用户文档。
  3. LET isMember = ...: 这是一个子查询,用于验证用户确实是tid所指定租户的成员。这是至关重要的多租户数据隔离检查。如果一个用户试图用一个租户的JWT去访问另一个租户的数据,这一步就会失败。
  4. FILTER LENGTH(isMember) > 0: 如果上一步子查询没有返回结果,整个查询终止,权限检查失败。
  5. FOR v, e, p IN 2..2 OUTBOUND ...: 这是核心的图遍历。它从startNode(用户)出发,沿着has_rolehas_permission两条边,向外探索2层深度。
  6. FILTER v._id == @perm_doc_id: 检查遍历的终点是否是我们正在寻找的那个权限节点。
  7. LIMIT 1: 这是一个性能优化。一旦找到任何一条有效的权限路径,我们就可以立即停止搜索并返回。
  8. RETURN true: 如果找到了路径,返回true

这个查询模型远比关系型数据库的递归查询要高效和直观。

3. Dart客户端的集成

在Dart(通常是Flutter)应用中,我们需要处理JWT的存储、在请求中附加以及处理认证失败的情况。

3.1 安全存储JWT

绝不能将JWT存储在localStorageSharedPreferences中,因为它们容易受到XSS攻击。应该使用操作系统提供的安全存储,如iOS的Keychain和Android的Keystore。flutter_secure_storage这个库封装了这些功能。

3.2 使用Interceptor自动附加Header

为了避免在每个API调用中手动添加Authorization头,我们可以使用网络库(如dio)的拦截器。

// lib/api/dio_client.dart
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class DioClient {
  final Dio _dio = Dio();
  final _storage = const FlutterSecureStorage();

  DioClient() {
    _dio.options.baseUrl = "https://api.myapp.com/api";
    _dio.interceptors.add(
      InterceptorsWrapper(
        onRequest: (options, handler) async {
          // 1. 从安全存储中读取token
          final token = await _storage.read(key: 'access_token');
          if (token != null) {
            // 2. 如果token存在,添加到header
            options.headers['Authorization'] = 'Bearer $token';
          }
          // 继续请求
          return handler.next(options);
        },
        onError: (DioError e, handler) async {
          // 3. 处理错误
          if (e.response?.statusCode == 401 || e.response?.statusCode == 403) {
            // Token无效或过期,或者权限不足
            // 在这里可以实现token刷新逻辑,或者直接导航到登录页面
            print('Auth error: ${e.response?.statusCode}');
            // 例如:
            // await _storage.deleteAll();
            // navigatorKey.currentState?.pushReplacementNamed('/login');
          }
          return handler.next(e);
        },
      ),
    );
  }

  Dio get dio => _dio;
}

单元测试思路:

  • AQL查询单元测试: 针对Checker.has_permission?函数,可以Mock MyApp.Repo.aql的返回值,测试各种路径(权限存在、权限不存在、用户不属于租户、数据库错误)下的逻辑是否正确。
  • Plug集成测试: 使用Plug.Test来测试CheckPermission plug。发送带有有效JWT、无效JWT、过期JWT和无JWT的模拟请求,断言响应状态码和内容是否符合预期。
  • 端到端测试: 在测试环境中,真实地创建用户、租户、角色、权限数据,然后通过模拟API请求来验证整个流程是否畅通。

局限性与未来优化路径

这个方案虽然强大,但并非没有权衡。

  1. 查询延迟: 尽管图遍历比复杂的JOIN快,但每次API请求都至少有一次数据库查询。对于性能极度敏感的端点,需要引入缓存。可以在Elixir进程中(使用ETS)或外部(如Redis)缓存用户的权限集,缓存的TTL要很短(例如1-5秒),以在性能和数据一致性之间取得平衡。当用户的角色被修改时,需要有机制主动使缓存失效。

  2. AQL的复杂性: 随着权限规则变得更加复杂(例如,基于资源属性的ABAC,或结合时间的临时权限),AQL查询可能会变得非常复杂,难以维护和调试。这时需要将查询逻辑封装得更好,并建立完善的测试用例集。

  3. JWT吊销: JWT是无状态的,一旦签发,在过期前都有效。如果需要立即强制用户下线(例如,用户修改密码或被管理员禁用),标准的JWT方案无法做到。一种常见的弥补方式是引入一个短期的黑名单(如存储在Redis中),在校验JWT时,先检查其jti(JWT ID)是否在黑名单内。这实际上又引入了状态,是对纯无状态模型的一种妥协。

  4. 数据一致性: 权限系统的写操作(赋予/撤销角色)需要保证原子性。ArangoDB支持在单个文档操作上的原子性,对于跨多个集合的事务,可以使用其Stream Transactions或JavaScript Transactions来确保所有更改要么全部成功,要么全部失败。


  目录