1. 多租户权限系统的核心挑战
在SaaS系统开发中,多租户权限管理一直是个让人头疼的问题。我最近用PHP-Casbin重构了一个老项目的权限系统,深刻体会到表结构设计对整个系统的可维护性和性能有多重要。传统的RBAC模型在单租户环境下还能应付,一旦加入多租户维度,复杂度直接指数级上升。
最典型的痛点就是"权限泄露"问题——租户A的员工能看到租户B的数据。去年我们系统就因为这个漏洞被客户投诉过,排查时发现是角色表缺少tenant_id字段导致的。这次重构我特别注重租户隔离的完整性,所有核心表都强制关联租户ID。
2. PHP-Casbin的适配方案选择
2.1 为什么选择Casbin
相比传统RBAC库,Casbin最大的优势在于支持ABAC(属性基访问控制)。在多租户场景下,权限判断往往需要结合用户属性(如部门)、资源属性(如所属租户)和环境属性(如访问时间)。用纯SQL实现这种复杂规则,存储过程和触发器会写得非常痛苦。
我们测试过三种方案:
- 纯数据库存储过程:维护成本高,跨数据库兼容性差
- Laravel Gate/Policies:灵活性不足,多租户支持弱
- Casbin+数据库:策略存储与执行解耦,支持动态规则
最终选择Casbin的核心原因是它的g(r.sub, p.sub)语法糖,可以优雅实现租户-用户-角色的三级继承关系。
2.2 策略存储方式对比
| 存储方式 | 性能 | 动态更新 | 分布式支持 | 适用场景 |
|---|---|---|---|---|
| 数据库 | 中 | 实时 | 需缓存 | 策略频繁变更 |
| 配置文件 | 高 | 需重启 | 困难 | 静态规则 |
| Redis | 高 | 实时 | 支持 | 高并发读取 |
| 文件+数据库混合 | 中高 | 准实时 | 部分支持 | 平衡型方案 |
我们采用MySQL作为主存储,Redis做二级缓存。关键配置项:
php复制$config = [
'model' => '/path/to/model.conf',
'adapter' => DatabaseAdapter::class,
'cache' => [
'enabled' => true,
'store' => 'redis',
'key' => 'casbin_rules',
'ttl' => 3600
]
];
3. 核心表结构设计详解
3.1 用户-租户关联表
sql复制CREATE TABLE `tenant_users` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`tenant_id` bigint unsigned NOT NULL COMMENT '租户ID',
`user_id` bigint unsigned NOT NULL COMMENT '用户ID',
`is_admin` tinyint(1) NOT NULL DEFAULT '0' COMMENT '租户管理员',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '0-禁用 1-启用',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_tenant_user` (`tenant_id`,`user_id`),
KEY `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
这个表解决了一个关键问题:同一个用户在不同租户下可能有不同权限。字段设计注意点:
- 唯一索引防止重复关联
- is_admin标记租户级超级管理员
- 软删除通过status字段控制
3.2 角色继承表
sql复制CREATE TABLE `tenant_roles` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`tenant_id` bigint unsigned NOT NULL,
`name` varchar(100) NOT NULL COMMENT '角色名称',
`description` varchar(255) DEFAULT NULL,
`parent_id` bigint unsigned DEFAULT NULL COMMENT '父角色ID',
`is_system` tinyint(1) NOT NULL DEFAULT '0' COMMENT '系统内置角色',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_tenant_role` (`tenant_id`,`name`),
KEY `idx_parent` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
角色继承是权限系统的核心难点之一。我们采用邻接表模式存储层级关系,配合Casbin的g(r.sub, p.sub)实现继承。例如:
code复制g, role:developer, role:employee
g, alice, role:developer
3.3 策略规则表
Casbin默认策略表结构:
sql复制CREATE TABLE `casbin_rules` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`ptype` varchar(10) NOT NULL COMMENT '策略类型(p,g)',
`v0` varchar(256) DEFAULT NULL,
`v1` varchar(256) DEFAULT NULL,
`v2` varchar(256) DEFAULT NULL,
`v3` varchar(256) DEFAULT NULL,
`v4` varchar(256) DEFAULT NULL,
`v5` varchar(256) DEFAULT NULL,
`tenant_id` bigint unsigned NOT NULL COMMENT '租户隔离字段',
PRIMARY KEY (`id`),
KEY `idx_ptype` (`ptype`),
KEY `idx_tenant` (`tenant_id`),
KEY `idx_v0` (`v0`(20)),
KEY `idx_v1` (`v1`(20))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
关键改进点:
- 增加tenant_id实现租户隔离
- 对v0-v5字段添加前缀索引
- 使用utf8mb4支持emoji等特殊字符
4. 权限校验的实战实现
4.1 中间件封装
php复制class CasbinMiddleware
{
public function handle($request, Closure $next)
{
$tenantId = $request->header('X-Tenant-Id');
$user = auth()->user();
if (!$this->checkPermission($tenantId, $user->id, $request)) {
abort(403, '无权访问');
}
return $next($request);
}
protected function checkPermission($tenantId, $userId, $request)
{
$enforcer = app('casbin');
$uri = $request->route()->uri();
$method = $request->method();
// 格式化为Casbin需要的格式
$obj = str_replace('/', ':', $uri);
$act = strtolower($method);
return $enforcer->enforce("user:$tenantId:$userId", $obj, $act);
}
}
4.2 策略加载优化
直接查询数据库性能较差,我们采用两层缓存:
- Redis缓存热数据
- 本地内存缓存高频策略
php复制class CachedAdapter extends DatabaseAdapter
{
public function loadPolicy(Model $model)
{
$cacheKey = 'casbin_policy_'.$this->tenantId;
if ($policies = Cache::get($cacheKey)) {
$this->loadPolicyFromCache($model, $policies);
return;
}
parent::loadPolicy($model);
$this->cachePolicy($model);
}
}
5. 性能调优经验
5.1 索引优化方案
经过压测发现,当策略规则超过10万条时,查询性能明显下降。我们最终采用的索引方案:
sql复制ALTER TABLE casbin_rules
ADD INDEX idx_composite (ptype, tenant_id, v0(20), v1(20)),
ADD INDEX idx_tenant_ptype (tenant_id, ptype);
优化效果对比:
| 数据量 | 无索引(ms) | 优化后(ms) |
|---|---|---|
| 1万 | 120 | 15 |
| 10万 | 2300 | 45 |
| 100万 | 超时 | 120 |
5.2 批量操作技巧
Casbin默认每次修改都会全量重载策略,我们通过批量接口提升性能:
php复制// 错误做法:循环插入
foreach ($rules as $rule) {
$enforcer->addPolicy($rule);
}
// 正确做法:批量处理
$enforcer->addPolicies($rules);
// 然后手动刷新一次缓存
$enforcer->loadPolicy();
实测10万条数据插入时间从6分钟降到12秒。
6. 常见问题排查
6.1 权限缓存不一致
现象:修改角色后,部分用户权限未及时更新
解决方案:
- 实现标签化缓存版本控制
php复制$version = TenantVersion::get($tenantId);
$cacheKey = "casbin_{$tenantId}_v{$version}";
- 权限变更时递增版本号
6.2 跨租户越权访问
关键防御措施:
- 所有SQL查询自动注入tenant_id条件
php复制Model::addGlobalScope('tenant', function ($builder) {
$builder->where('tenant_id', request()->header('X-Tenant-Id'));
});
- 在Casbin模型中加入租户匹配规则
code复制[matchers]
m = g(r.tenant, p.tenant) && r.obj == p.obj && r.act == p.act
7. 扩展设计建议
7.1 数据隔离方案选择
根据业务需求选择隔离级别:
| 方案 | 共享程度 | 成本 | 适用场景 |
|---|---|---|---|
| 独立数据库 | 完全隔离 | 高 | 金融、医疗等高安全 |
| Schema隔离 | 部分共享 | 中 | 中型SaaS |
| 字段隔离 | 完全共享 | 低 | 初创项目 |
7.2 权限变更审计
添加审计日志表记录关键操作:
sql复制CREATE TABLE `permission_logs` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL,
`operator_id` bigint NOT NULL COMMENT '操作人',
`action` enum('ADD','DELETE','UPDATE') NOT NULL,
`object_type` varchar(50) NOT NULL COMMENT 'user/role/permission',
`object_id` bigint NOT NULL,
`old_value` json DEFAULT NULL,
`new_value` json DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tenant` (`tenant_id`),
KEY `idx_operator` (`operator_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
在项目上线半年后,这套架构成功支撑了200+租户和5万+用户的权限管理。最大的收获是认识到:好的权限系统设计必须提前考虑扩展性,特别是在租户数量增长时,N+1查询问题会特别明显。我们后来引入的批量策略加载和二级缓存机制,让系统在百万级策略规则下仍能保持毫秒级响应。