构建一个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
优势分析:
- 实现简单: 对现有RBAC模型改动最小,团队成员理解成本低。
- 生态成熟: 现有的大多数权限库都可以通过简单的扩展支持这种模式。
劣势分析:
- 数据模型僵化:
permissions
表是全局共享的。如果租户A需要一个manage:financial_reports
权限,而租户B不需要,这个权限记录依然会存在于全局表中。更严重的是,如果租户B想定义一个与租户A完全不同的资源/操作体系,此模型无法支持。它假设所有租户共享同一套权限“字典”。 - 性能瓶颈: 随着租户和用户数量的增长,所有查询都必须带上
WHERE tenant_id = ?
。在深度关联查询(例如,查询用户拥有的所有权限)时,数据库的查询优化器可能会面临挑战,尤其是在大型数据表上。 - 安全风险: 逻辑隔离是脆弱的。开发过程中的任何一个查询遗漏了
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),赋予了租户极大的自主权。同时,它对隔离性做了更深层次的考量。
- 灵活性: 租户可以创建任意颗粒度的资源(Resource)和操作(Action),并将其组合成策略(Policy),再赋予角色(Role)。例如,租户A可以定义
资源: "月度财报"
,操作: ["查看", "审批", "下载"]
;而租户B可以定义资源: "生产线A"
,操作: ["启动", "急停"]
。 - 隔离性: 借助云服务商提供的数据库服务(如AWS RDS for PostgreSQL或阿里云PolarDB),我们可以实现Schema-per-Tenant模式。每个租户的数据存在于自己独立的Schema中,从数据库层面提供了强隔离,彻底杜绝了因忘记
tenant_id
而导致的数据泄露。 - 性能: 高频的权限检查操作不应直接访问数据库。我们将利用Elixir/OTP的
GenServer
和ETS
(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
组件来展示资源层级,Transfer
或Table
来为角色分配权限。
// 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
使缓存失效。整个流程形成了一个闭环。
架构的扩展性与局限性
此架构并非一劳永逸。它的扩展性体现在:
- 策略语言增强: 当前
actions
只是一个简单的字符串列表。未来可以将其升级为一个小型DSL(领域特定语言),支持更复杂的条件判断,例如{action: "read", condition: "user.department == resource.department"}
,从而演进为真正的ABAC(Attribute-Based Access Control)。 - 性能优化: 对于拥有海量角色的超大型租户,单
GenServer
可能成为瓶颈。可以利用Horde
或类似库,将不同租户的PolicyEngine
分布到不同节点上,形成一个分布式的策略缓存集群。 - 与云服务集成: 可以将策略变更事件通过云服务商的消息队列(如AWS SQS)广播出去,确保多实例部署时所有节点的缓存都能同步失效。
局限性同样明显:
- 实现复杂度: 相比方案A,方案B的初始开发成本更高,对团队成员的技术能力要求也更高,需要对Elixir/OTP和数据库有深入理解。
- 运维成本: Schema-per-Tenant模型在租户数量巨大(如数万以上)时,数据库的连接管理、备份恢复、迁移管理都会变得非常复杂,需要强大的DBA和自动化运维能力支持。这时可能需要权衡,切换到共享Schema但使用行级安全策略(Row-Level Security)的折中方案。
- 缓存一致性:
PolicyEngine
的缓存更新依赖于主动失效。如果在某些边缘情况下(例如,直接修改数据库但忘记调用invalidate
),可能导致缓存与数据库不一致,直到缓存过期或服务重启。需要建立严格的开发规范来保证数据一致性。