构建云原生多租户权限中台 Phoenix 与 Ant Design Pro 的架构决策


构建一个SaaS平台的核心挑战之一,在于权限系统的设计。一个初级的权限系统通常采用经典的RBAC模型,将用户、角色、权限三者绑定。但在一个真正的多租户环境中,这种模式很快就会暴露出其刚性。当业务需求演变为“允许每个租户自定义其内部的角色与权限颗粒度”时,传统的硬编码权限标识与静态角色定义便彻底失效。我们面临的问题不是简单的访问控制,而是需要构建一个动态、可配置、且租户间严格隔离的权限中台。

方案A:扩展传统RBAC,以租户ID进行逻辑隔离

这是最容易想到的方案。在所有与权限相关的表(users, roles, permissions, role_permissions)中增加一个tenant_id字段。

# lib/my_app/accounts/schemas/role.ex
defmodule MyApp.Accounts.Role do
  use Ecto.Schema
  import Ecto.Changeset

  schema "roles" do
    field :name, :string
    field :description, :string

    # 核心:通过 tenant_id 实现逻辑隔离
    belongs_to :tenant, MyApp.Accounts.Tenant

    many_to_many :permissions, MyApp.Accounts.Permission,
      join_through: "role_permissions"

    timestamps()
  end

  def changeset(role, attrs) do
    role
    |> cast(attrs, [:name, :description, :tenant_id])
    |> validate_required([:name, :tenant_id])
    |> unique_constraint(:name, name: :roles_tenant_id_name_index)
  end
end

优势分析:

  1. 实现简单: 对现有RBAC模型改动最小,团队成员理解成本低。
  2. 生态成熟: 现有的大多数权限库都可以通过简单的扩展支持这种模式。

劣势分析:

  1. 数据模型僵化: permissions表是全局共享的。如果租户A需要一个manage:financial_reports权限,而租户B不需要,这个权限记录依然会存在于全局表中。更严重的是,如果租户B想定义一个与租户A完全不同的资源/操作体系,此模型无法支持。它假设所有租户共享同一套权限“字典”。
  2. 性能瓶颈: 随着租户和用户数量的增长,所有查询都必须带上WHERE tenant_id = ?。在深度关联查询(例如,查询用户拥有的所有权限)时,数据库的查询优化器可能会面临挑战,尤其是在大型数据表上。
  3. 安全风险: 逻辑隔离是脆弱的。开发过程中的任何一个查询遗漏了tenant_id条件,都将直接导致数据越权泄露。这是SaaS应用中最严重的安全事故之一。

在真实项目中,僵化的数据模型是致命的。一个SaaS平台成功的关键在于其灵活性和可配置性。方案A从根本上违背了这一原则,迫使所有租户在一个统一的框架内行事,这在早期或许可行,但很快会成为业务发展的桎梏。

方案B:基于动态策略与数据沙箱的权限中台架构

这个方案的核心思想是,权限本身不是代码的一部分,而是由租户自己管理的数据。系统提供一个“权限元模型”,租户在此基础上定义自己的具体权限实例。

架构概览:

graph TD
    subgraph "用户浏览器 (Ant Design Pro)"
        A[权限配置界面] -->|1. 定义资源/操作| B(API请求);
    end

    subgraph "云服务商 VPC"
        subgraph "Phoenix 应用"
            B --> C{API网关/Phoenix Router};
            C -->|鉴权/租户识别| D[TenantContext Plug];
            D --> E[PermissionController];
            E -->|写入策略数据| F(PostgreSQL 租户Schema);
            G[用户业务请求] --> C;
            C --> H[业务相关Plug];
            H --> I{PermissionCheck Plug};
            I -->|读取策略| J[PolicyEngine GenServer];
            J -->|Cache Miss| F;
            J -->|Cache Hit| K(ETS缓存);
            I -->|鉴权通过/失败| L[BusinessController];
        end
    end

决策理由:
此方案将权限定义从开发时(compile-time)推迟到了运行时(run-time),赋予了租户极大的自主权。同时,它对隔离性做了更深层次的考量。

  1. 灵活性: 租户可以创建任意颗粒度的资源(Resource)和操作(Action),并将其组合成策略(Policy),再赋予角色(Role)。例如,租户A可以定义资源: "月度财报"操作: ["查看", "审批", "下载"];而租户B可以定义资源: "生产线A"操作: ["启动", "急停"]
  2. 隔离性: 借助云服务商提供的数据库服务(如AWS RDS for PostgreSQL或阿里云PolarDB),我们可以实现Schema-per-Tenant模式。每个租户的数据存在于自己独立的Schema中,从数据库层面提供了强隔离,彻底杜绝了因忘记tenant_id而导致的数据泄露。
  3. 性能: 高频的权限检查操作不应直接访问数据库。我们将利用Elixir/OTP的GenServerETS(Erlang Term Storage)为每个租户构建一个内存中的策略缓存。权限检查的路径是 请求 -> Plug -> GenServer -> ETS,速度极快,只有在策略变更时才需要与数据库交互。

核心实现概览

1. 数据库层:Schema-per-Tenant 与 Ecto 多前缀支持

PostgreSQL的Schema是实现此模式的关键。当一个新租户注册时,我们通过迁移脚本动态创建其专属的Schema。

# lib/my_app/tenant_migrator.ex
defmodule MyApp.TenantMigrator do
  alias MyApp.Repo

  def up(tenant_prefix) do
    # 创建新的 schema
    Repo.query!("CREATE SCHEMA \"#{tenant_prefix}\"")

    # 在新 schema 内运行所有迁移
    Ecto.Migrator.run(Repo, migrator_opts(direction: :up, prefix: tenant_prefix))
  end

  # ... down/1 and private functions
  defp migrator_opts(opts) do
    Keyword.merge([migrations_path: "priv/repo/migrations"], opts)
  end
end

Ecto原生支持prefix选项,可以优雅地处理多Schema操作。我们会创建一个Plug,在请求开始时从JWT或域名中解析出tenant_id,并将其设置为Ecto的prefix

# lib/my_app_web/plugs/tenant_context.ex
defmodule MyAppWeb.Plugs.TenantContext do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    # 假设租户信息已在之前的认证Plug中解析并放入了conn.assigns
    case conn.assigns[:current_tenant] do
      nil ->
        # 对于公共API或未认证用户,使用公共schema
        Repo.put_dynamic_repo(build_repo_opts("public"))
        conn
      tenant ->
        # 为当前请求的Repo操作设置租户特定的schema
        Repo.put_dynamic_repo(build_repo_opts(tenant.schema_prefix))
        conn
    end
  end

  defp build_repo_opts(prefix) do
    [prefix: prefix, otp_app: :my_app]
  end
end

2. 后端:动态权限模型与策略引擎

数据模型需要变得更加抽象。

# lib/my_app/permissions/schemas/resource.ex
defmodule MyApp.Permissions.Resource do
  use Ecto.Schema
  import Ecto.Changeset

  # 注意:此表存在于每个租户的schema中,没有tenant_id字段
  schema "resources" do
    field :name, :string # e.g., "财务报表"
    field :key, :string  # e.g., "financial_report"
    # ... 其他元数据
  end
end

# lib/my_app/permissions/schemas/policy.ex
defmodule MyApp.Permissions.Policy do
  use Ecto.Schema

  # 策略表,核心定义
  schema "policies" do
    field :effect, Ecto.Enum, values: [:allow, :deny], default: :allow
    # 关联到角色
    belongs_to :role, MyApp.Accounts.Role
    # 关联到租户自定义的资源
    belongs_to :resource, MyApp.Permissions.Resource
    # 存储允许的操作列表
    field :actions, {:array, :string} # e.g., ["read", "update", "approve"]
  end
end

策略引擎 PolicyEngine 是性能的关键。它是一个GenServer,负责为每个租户维护一个ETS表,缓存其所有策略。

# lib/my_app/permissions/policy_engine.ex
defmodule MyApp.Permissions.PolicyEngine do
  use GenServer
  alias MyApp.Repo
  alias MyApp.Permissions.Policy

  # --- Client API ---
  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end

  @doc """
  检查权限。这是外部调用的主要接口。
  """
  def can?(user_roles, resource_key, action, tenant_prefix) do
    GenServer.call(__MODULE__, {:check, user_roles, resource_key, action, tenant_prefix})
  end

  @doc """
  使某个租户的策略缓存失效。
  """
  def invalidate(tenant_prefix) do
    GenServer.cast(__MODULE__, {:invalidate, tenant_prefix})
  end

  # --- Server Callbacks ---
  @impl true
  def init(state) do
    # 初始化时,ETS表是空的
    {:ok, state}
  end

  @impl true
  def handle_call({:check, user_roles, resource_key, action, tenant_prefix}, _from, state) do
    case check_from_cache(user_roles, resource_key, action, tenant_prefix) do
      :not_found ->
        # 缓存未命中,从数据库加载并存入缓存
        load_policies_for_tenant(tenant_prefix)
        # 再次检查
        reply = check_from_cache(user_roles, resource_key, action, tenant_prefix)
        {:reply, reply != :not_found && reply, state}
      
      is_allowed ->
        # 缓存命中
        {:reply, is_allowed, state}
    end
  end

  @impl true
  def handle_cast({:invalidate, tenant_prefix}, state) do
    # 删除该租户的ETS表
    ets_table = tenant_ets_table(tenant_prefix)
    :ets.delete(ets_table)
    {:noreply, state}
  end

  # --- Private Helpers ---
  defp tenant_ets_table(tenant_prefix) do
    # 为每个租户创建一个ETS表,避免数据混淆
    :"#{tenant_prefix}_policies"
  end

  defp load_policies_for_tenant(tenant_prefix) do
    table = tenant_ets_table(tenant_prefix)
    :ets.new(table, [:set, :protected, :named_table, read_concurrency: true])

    # 注意这里的 Repo.all 调用,必须在调用前设置好 prefix
    # 实际项目中,这部分逻辑应该在独立的进程中执行,避免阻塞 GenServer
    Repo.all(Policy, prefix: tenant_prefix)
    |> Enum.each(fn policy ->
      # 缓存结构: {{role_id, resource_key}, [actions]}
      key = {policy.role_id, policy.resource.key}
      actions = policy.actions
      :ets.insert(table, {key, actions})
    end)
  end

  defp check_from_cache(user_roles, resource_key, action, tenant_prefix) do
    table = tenant_ets_table(tenant_prefix)
    
    # 检查用户拥有的任何一个角色是否满足权限
    Enum.any?(user_roles, fn role_id ->
      case :ets.lookup(table, {role_id, resource_key}) do
        [{_key, allowed_actions}] -> action in allowed_actions
        [] -> false # 找不到对应的策略记录
      end
    end)
  rescue
    ArgumentError ->
      # 如果 ETS 表不存在,会抛出 ArgumentError,此时视为缓存未命中
      :not_found
  end
end

这个PolicyEngine体现了策略模式 (Strategy Pattern) 的思想:权限检查的具体逻辑(check_from_cache)被封装起来,并且可以被替换或扩展(例如,增加deny策略的处理)。当租户更新权限时,我们只需调用PolicyEngine.invalidate(tenant_prefix),下次检查时就会自动重新加载。

最后,一个Phoenix Plug将这一切串联起来。

# lib/my_app_web/plugs/permission_check.ex
defmodule MyAppWeb.Plugs.PermissionCheck do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, {resource, action}) do
    user = conn.assigns.current_user
    tenant = conn.assigns.current_tenant
    
    # 假设用户的角色ID列表已经加载
    user_roles = user.role_ids

    if MyApp.Permissions.PolicyEngine.can?(user_roles, resource, action, tenant.schema_prefix) do
      conn
    else
      conn
      |> put_status(:forbidden)
      |> Phoenix.Controller.json(%{error: "Permission Denied"})
      |> halt()
    end
  end
end

# 在Router中使用
# lib/my_app_web/router.ex
pipeline :api_protected do
  plug :accepts, ["json"]
  plug MyAppWeb.Plugs.Auth # 认证并加载user和tenant
  plug MyAppWeb.Plugs.TenantContext # 设置Ecto prefix
end

scope "/api", MyAppWeb do
  pipe_through :api_protected

  get "/reports", ReportController, :index, plugs: [{MyAppWeb.Plugs.PermissionCheck, {"financial_report", "read"}}]
end

3. 前端:Ant Design Pro 构建动态配置界面

后端提供了动态模型,前端则需要一个强大的界面让租户管理员能够直观地配置。Ant Design Pro及其组件库是这个任务的理想选择。

我们将使用Tree组件来展示资源层级,TransferTable来为角色分配权限。

// src/pages/Permissions/RoleEditor.jsx
import React, { useState, useEffect } from 'react';
import { Card, Tree, Checkbox, Row, Col, message } from 'antd';
import { useRequest } from 'umi';
import { getResources, getRolePolicies, updateRolePolicies } from '@/services/permissions';

const RoleEditor = ({ roleId }) => {
  const [treeData, setTreeData] = useState([]);
  const [checkedPermissions, setCheckedPermissions] = useState({}); // { resourceKey: ['action1', 'action2'] }

  // 1. 加载所有可用资源和操作
  const { data: resources, loading: loadingResources } = useRequest(getResources);
  
  // 2. 加载当前角色的策略
  const { data: currentPolicies, run: loadPolicies } = useRequest(() => getRolePolicies(roleId), {
    manual: true,
    onSuccess: (data) => {
      const initialChecked = {};
      data.forEach(policy => {
        initialChecked[policy.resource.key] = policy.actions;
      });
      setCheckedPermissions(initialChecked);
    }
  });

  useEffect(() => {
    if (roleId) {
      loadPolicies();
    }
  }, [roleId]);

  // 将后端资源数据转换为 Antd Tree 需要的格式
  useEffect(() => {
    if (resources) {
      const transformedData = resources.map(res => ({
        title: res.name,
        key: res.key,
        children: res.available_actions.map(action => ({
          title: action.name, // "查看", "编辑"
          key: `${res.key}:${action.key}` // "financial_report:read"
        }))
      }));
      setTreeData(transformedData);
    }
  }, [resources]);

  // 3. 处理权限勾选逻辑
  const handleCheck = (checkedKeys) => {
    const newChecked = {};
    checkedKeys.forEach(key => {
      if (key.includes(':')) {
        const [resourceKey, actionKey] = key.split(':');
        if (!newChecked[resourceKey]) {
          newChecked[resourceKey] = [];
        }
        newChecked[resourceKey].push(actionKey);
      }
    });
    setCheckedPermissions(newChecked);
    
    // 调用更新接口
    // 在真实项目中,这里应该有防抖和保存按钮
    updateRolePolicies(roleId, newChecked)
      .then(() => message.success('Permissions updated'))
      .catch(() => message.error('Update failed'));
  };
  
  // 从 checkedPermissions 计算出 Tree 组件需要的 checkedKeys
  const getCheckedKeys = () => {
    const keys = [];
    Object.entries(checkedPermissions).forEach(([resourceKey, actions]) => {
      actions.forEach(actionKey => {
        keys.push(`${resourceKey}:${actionKey}`);
      });
    });
    return keys;
  };

  return (
    <Card title="Edit Role Permissions" loading={loadingResources}>
      <Tree
        checkable
        defaultExpandAll
        treeData={treeData}
        checkedKeys={getCheckedKeys()}
        onCheck={handleCheck}
      />
    </Card>
  );
};

export default RoleEditor;

这段React代码展示了如何利用Ant Design的Tree组件,结合useRequest进行数据管理,构建出一个清晰的权限配置界面。当租户管理员勾选或取消勾选时,前端将格式化的策略数据发送给后端,后端更新数据库后,通过PolicyEngine.invalidate/1使缓存失效。整个流程形成了一个闭环。

架构的扩展性与局限性

此架构并非一劳永逸。它的扩展性体现在:

  1. 策略语言增强: 当前actions只是一个简单的字符串列表。未来可以将其升级为一个小型DSL(领域特定语言),支持更复杂的条件判断,例如{action: "read", condition: "user.department == resource.department"},从而演进为真正的ABAC(Attribute-Based Access Control)。
  2. 性能优化: 对于拥有海量角色的超大型租户,单GenServer可能成为瓶颈。可以利用Horde或类似库,将不同租户的PolicyEngine分布到不同节点上,形成一个分布式的策略缓存集群。
  3. 与云服务集成: 可以将策略变更事件通过云服务商的消息队列(如AWS SQS)广播出去,确保多实例部署时所有节点的缓存都能同步失效。

局限性同样明显:

  1. 实现复杂度: 相比方案A,方案B的初始开发成本更高,对团队成员的技术能力要求也更高,需要对Elixir/OTP和数据库有深入理解。
  2. 运维成本: Schema-per-Tenant模型在租户数量巨大(如数万以上)时,数据库的连接管理、备份恢复、迁移管理都会变得非常复杂,需要强大的DBA和自动化运维能力支持。这时可能需要权衡,切换到共享Schema但使用行级安全策略(Row-Level Security)的折中方案。
  3. 缓存一致性: PolicyEngine的缓存更新依赖于主动失效。如果在某些边缘情况下(例如,直接修改数据库但忘记调用invalidate),可能导致缓存与数据库不一致,直到缓存过期或服务重启。需要建立严格的开发规范来保证数据一致性。

  目录