权限系统做到第二版时,我彻底放弃在框架里继续堆角色判断的if-else,转用php-casbin统一做RBAC,并且为多租户场景把整套表结构重新设计了一遍。这篇文章就是把这套“用户+租户+角色+权限”的表结构完整拆开:每张表为什么留这些字段、Casbin规则表和业务表怎么咬合、以及上线前我踩出来的几个坑。如果你正在做SaaS化改造,或者想把框架自带的简单角色判断换成独立可扩展的权限引擎,这份设计可以直接给你参考。
1. 多租户隔离方案选型:共用库加tenant_id为什么最省心
1.1 三种隔离方式的账本对比
在动笔画表之前,第一个要敲定的是“租户的数据到底怎么隔”。我见过不少人上来就选独立库,理由是“租户数据必须物理隔离”,结果团队被运维成本拖垮。其实主流方案就三种,各有各的适用范围:
| 方案 | 隔离强度 | 部署与运维成本 | 查询成本 | 典型适用阶段 |
|---|---|---|---|---|
| 独立数据库(一租户一库) | 最高 | 高,每个库都要跑迁移、备份、账号管理 | 连接管理复杂,跨租户汇总几乎无法做 | 大客户定制化、合规要求极高的场景 |
| 独立Schema(一租户一个Schema) | 较高 | 中,迁移脚本要循环执行 | 共享连接池,但仍要动态拼Schema | 中型SaaS,有一定隔离要求 |
| 共用库+tenant_id字段 | 低但够用 | 低,一套表结构、一套迁移 | 最低,所有查询走同一张表 | 绝大多数SaaS起步和增长期 |
我最后选了第三种:共用库+tenant_id。原因不是“偷懒”,而是现实里多数SaaS在初期租户量、单个租户的数据量都不大,单表加索引完全扛得住。共用库还能让你随便写跨租户的统计报表,运营想看“哪些租户活跃度高”时不用绕一大圈。真正需要独立库时再拆也来得及——只要你在业务层把tenant_id一直带着,后面拆库只是数据搬运问题。
1.2 共用库方案下必须定下的三条底线
共用库节省了运维,却把压力转移到了编码自控力上。如果不立规矩,半年后你就会被跨租户数据事故折磨。我在设计表结构之前先定了三条底线:
- 所有业务表必须有tenant_id字段,并且单独建索引。这里的“所有”包括基础数据表,连配置表都不要漏。漏掉一张,将来某条SQL就会被“忘了”加租户条件。
- 所有SQL查询都要显式带租户条件,不依赖所谓“全局过滤器”。全局过滤器看起来省事,但一旦有人用原生SQL、或者在联合查询里绕过了过滤器,越权数据就出来了。显式写在语句里,review代码时一眼能看出来。
- 唯一性约束必须把tenant_id纳入。比如用户名,租户A里叫admin和租户B里叫admin是完全合法的两个人,唯一索引就必须是(tenant_id, username),而不是全局username唯一。
这三条看起来简单,但从根上决定了共用库方案能不能安全落地。权限系统本身是为了防越权,如果数据隔离的桶底漏了,规则再严密也没用。
1.3 主键类型和业务code:规则表里尽量存“人话”
表主键用什么类型,我建议分两层考虑:
- 数据库自增主键:多数表继续用int unsigned或bigint unsigned自增,没必要为了“微服务化焦虑”上来就上雪花ID。
- 但Casbin规则里不要存数据库自增ID,而是给租户、角色、权限各加一个业务code字段,规则文本里存code。
为什么?因为规则表要给人看、要导出、要跨环境迁移。一条规则长这样:
p: role_code_tenant_001, tenant_001, user:create, *
如果全存自增ID,规则会变成:
p: 1287, 332, 8873, *
你完全没法从文本里读懂这条规则代表什么。更麻烦的是,自增ID一旦删除重用,规则语义就会被污染。所以我规定:用户的规则标识用主键ID可以接受(因为用户通常禁用不删除),但租户、角色、权限的规则标识一律用业务code。这条经验在后面每个字段设计里都会反复出现。
2. 七张表的逐字段拆解与完整DDL
2.1 可直接抄作业的整套DDL
下面是这套设计对应的MySQL DDL,包含七张表:租户表、用户表、角色表、权限表、用户角色关系表、角色权限关系表、Casbin规则表。字符集统一utf8mb4,引擎用InnoDB。
-- 租户表 CREATE TABLE `tenants` ( `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '租户ID', `code` varchar(64) NOT NULL COMMENT '租户业务编码,规则表中使用', `name` varchar(128) NOT NULL COMMENT '租户名称', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1启用 0禁用', `expired_at` datetime DEFAULT NULL COMMENT '到期时间', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户表'; -- 用户表 CREATE TABLE `users` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '用户ID', `tenant_id` int unsigned NOT NULL COMMENT '所属租户ID', `username` varchar(64) NOT NULL COMMENT '登录账号', `phone` varchar(32) DEFAULT NULL, `email` varchar(128) DEFAULT NULL, `password_hash` varchar(255) NOT NULL, `nickname` varchar(64) NOT NULL DEFAULT '', `is_super_admin` tinyint NOT NULL DEFAULT '0' COMMENT '全局超管,不走Casbin', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1启用 0禁用', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_tenant_username` (`tenant_id`, `username`), KEY `idx_tenant` (`tenant_id`), KEY `idx_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 角色表 CREATE TABLE `roles` ( `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '角色ID', `tenant_id` int unsigned NOT NULL COMMENT '所属租户ID', `code` varchar(64) NOT NULL COMMENT '角色编码,规则表中使用', `name` varchar(64) NOT NULL COMMENT '角色名称', `description` varchar(255) NOT NULL DEFAULT '', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1启用 0禁用', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_tenant_code` (`tenant_id`, `code`), KEY `idx_tenant` (`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; -- 权限表 CREATE TABLE `permissions` ( `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '权限ID', `parent_id` int unsigned NOT NULL DEFAULT '0' COMMENT '父权限ID,用于组织权限树', `tenant_id` int unsigned NOT NULL DEFAULT '0' COMMENT '0系统公共权限,其他为租户自定义权限', `code` varchar(128) NOT NULL COMMENT '权限点编码,如user:create', `name` varchar(64) NOT NULL COMMENT '权限名称', `type` tinyint NOT NULL DEFAULT '1' COMMENT '1菜单 2按钮 3API', `sort` smallint NOT NULL DEFAULT '0' COMMENT '排序', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1启用 0禁用', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`), KEY `idx_parent` (`parent_id`), KEY `idx_tenant` (`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表'; -- 用户角色关系表 CREATE TABLE `user_roles` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `user_id` bigint unsigned NOT NULL COMMENT '用户ID', `role_id` int unsigned NOT NULL COMMENT '角色ID', `tenant_id` int unsigned NOT NULL COMMENT '冗余租户ID,利于隔离查询与审计', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_role` (`user_id`, `role_id`), KEY `idx_role` (`role_id`), KEY `idx_tenant` (`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表'; -- 角色权限关系表 CREATE TABLE `role_permissions` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `role_id` int unsigned NOT NULL COMMENT '角色ID', `permission_id` int unsigned NOT NULL COMMENT '权限ID', `tenant_id` int unsigned NOT NULL COMMENT '冗余租户ID,利于隔离查询与审计', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_role_permission` (`role_id`, `permission_id`), KEY `idx_permission` (`permission_id`), KEY `idx_tenant` (`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表'; -- Casbin规则表 CREATE TABLE `casbin_rule` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `ptype` varchar(32) NOT NULL COMMENT '规则类型:p策略规则 / g分组规则', `v0` varchar(255) NOT NULL DEFAULT '', `v1` varchar(255) NOT NULL DEFAULT '', `v2` varchar(255) NOT NULL DEFAULT '', `v3` varchar(255) NOT NULL DEFAULT '', `v4` varchar(255) NOT NULL DEFAULT '', `v5` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`), KEY `idx_ptype` (`ptype`), KEY `idx_v0_v1_v2` (`v0`, `v1`, `v2`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Casbin规则表';2.2 租户表与用户表:数据的根和归属
租户表很简单,核心是把code从自增ID里独立出来。这个code会成为Casbin规则里的domain参数,所以要短、要稳定、要可读。我习惯用类似tenant_001这种编码,也见过用UUID的,不建议,规则表里一长串UUID看着就头疼。
用户表要说的事情比较多。
第一,我用tenant_id直接挂在用户表上,等价于“一个用户只属于一个租户”。这是绝大多数SaaS的默认模型:用户注册时选定一个租户(企业/团队),以后就是这家的人。如果你业务里确实有“一个人可以同时加入多个企业,切换身份后进入不同工作台”的需求,那就需要把users.tenant_id抽出去,加一张user_tenant_rel关系表。标题里“用户+租户”就是围绕最常见的一对一关系来设计的,一对多属于变体,设计思路相通。
第二,登录账号的唯一性。用户名在全局不需要唯一,但在一个租户内必须唯一,所以唯一索引是(tenant_id, username)。手机号和邮箱我刻意没加唯一约束,因为真实业务里同一个手机号被两个租户各注册一次实在太常见了,全局唯一反而会误伤。如果业务要求手机号全局唯一,再往users表上补一个uk_phone即可。
第三,用户表的规则标识用主键ID还是单独code?我上文说过规则里尽量存code,但用户是个例外——用户通常只增不删(禁用代替删除),自增ID相对稳定,而且用户体量大,再造一层user_code徒增复杂度。如果你团队确实接受“删用户再重建”的操作,那就给users表加一个user_code字段,规则表里用user_code,别用自增ID。
2.3 角色表与权限表:权限点要全局唯一还是租户内唯一
角色表的逻辑很直白:每个租户里的角色独立管理,所以code只要求在租户内唯一,唯一索引(tenant_id, code)。这样租户A和租户B可以各有一个admin角色,他们在各自租户语境下语义清晰,也不会撞车。
权限表我这里花了较多心思。先解释几个字段:
parent_id:用来组织权限树,给后台管理界面生成勾选树用的。比如“用户管理”下面挂“用户新增”“用户删除”。Casbin规则本身不感知树结构,它只认权限点code。tenant_id:权限表默认允许平台统一维护一套公共权限,tenant_id=0代表系统级公共权限。如果业务允许租户自定义扩展权限点,就再插入一条指定租户ID的权限记录。code:这是权限点的最终形态,格式上我推荐“资源:动作”,比如user:create、order:update。不要用中文,规则表、代码、日志里都要传递同一个字符串。type:菜单、按钮、API三种类型,主要是后台展示区分。菜单权限控制“能不能看到这个入口”,按钮权限控制“能不能点”,API权限控制“能不能调”。到Casbin层,三种类型最后都被翻译成perm code,没有本质区别。
一个值得注意的设计决策:权限code我做了全局唯一(uk_code),而不是租户内唯一。原因是权限点是平台级资源,语义一旦定下来就不该在不同租户之间有歧义。如果允许不同租户定义相同code但不同含义,规则表就乱了:一条p: role_x, tenant_001, user:create, *到底代表哪个租户的user:create?所以我们强制权限code全局唯一,租户自定义权限如果和公共权限语义冲突,直接不允许。
2.4 两张关系表和casbin_rule表:冗余字段不是浪费
user_roles和role_permissions两张关系表,字段结构看起来几乎一样:一对ID,加一个tenant_id。这个tenant_id是冗余的,因为通过role_id反查roles表一定能拿到租户。为什么还要冗余?
两个理由:一是隔离查询。权限配置页面上要展示“当前租户的所有权限”,如果关系表没有tenant_id,就得让role_permissions先join roles再用租户过滤,多一次join;有了tenant_id,一条where就完事,索引也简单。二是审计。某人改坏了数据,排查时直接看关系表里的租户字段就能定位范围,不用顺着ID链去猜。
关系表上我没有加物理外键。不是偷懒,是生产环境不建议加外键:外键会在高频写入时占用额外锁,影响性能和扩展性。关系完整性交给应用层去维护,如果担心脏数据,定时任务扫一遍孤儿记录就够了。
casbin_rule表的字段是php-casbin的标准结构:ptype区分规则类型,v0到v5存储规则参数。这里解释一下这套表在这套设计里的约定:
ptype = 'p'的策略规则,v0=角色code,v1=租户code,v2=权限code,v3=动作(本设计统一为*),这就是role_permissions的投影。ptype = 'g'的分组规则,v0=用户ID,v1=角色code,v2=租户code,这就是user_roles的投影。- 业务管理后台永远操作前五张业务表,casbin_rule表只是运行时投影,通过同步逻辑保持一致,不直接手工改。
3. model.conf怎么配置才能让Casbin认识“租户”
3.1 多域RBAC模型:让domain承载租户ID
表结构定了,关键一步是让Casbin“理解”这套表。Casbin的强项之一是原生支持带域的RBAC模型,domain这个概念恰好可以映射为租户ID。一个最简单可用的model.conf长这样:
[request_definition] r = sub, dom, obj, act [policy_definition] p = sub, dom, obj, act [role_definition] g = _, _, _ [policy_effect] e = some(where (p.eft == allow)) [matchers] m = g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && (r.act == p.act || p.act == "*")逐行拆解:
r = sub, dom, obj, act:请求由四个元素组成,分别代表“谁”“在哪个租户”“操作什么资源”“做什么动作”。p = sub, dom, obj, act:策略规则同样是四个元素,我们在规则表里传入的实参就是(角色code,租户code,权限code,*)。g = _, _, _:分组规则支持三个参数,贴合的语义是(用户,角色,租户域)。m = g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && (r.act == p.act || p.act == "*"):匹配逻辑是,请求者在请求租户域内必须属于某个角色,这个角色在该租户域下拥有对应权限,动作要么精确匹配要么规则里用通配*。
有了这个模型,权限判断就统一了。以“租户tenant_001的管理员admin给用户user_1001授予用户管理权限”为例:
g规则:user_1001, admin, tenant_001p规则:admin, tenant_001, user:create, *- 请求:
enforce("user_1001", "tenant_001", "user:create", "*")
匹配过程是:先查分组,确定user_1001在tenant_001里属于admin角色;再查策略,确定admin角色在tenant_001下对user:create有允许规则;最后动作对得上。整套查询都在casbin_rule表上发生,速度快。
3.2 规则数据与业务数据的同步逻辑
业务表和casbin_rule表不是天然一致的,需要代码去同步。刚接触Casbin的人最容易犯的错是:在管理后台改了角色权限,结果enforce不生效,因为casbin_rule还是旧的。
我的做法是封装一个PermissionSyncService,凡是业务表变更的入口都统一走它:
// 给用户分配角色 $enforcer->addGroupingPolicy($userId, $roleCode, $tenantCode); // 移除用户角色 $enforcer->deleteGroupingPolicy($userId, $roleCode, $tenantCode); // 给角色挂权限点 $enforcer->addPolicy($roleCode, $tenantCode, $permissionCode, '*'); // 移除角色权限点 $enforcer->removePolicy($roleCode, $tenantCode, $permissionCode, '*'); // 整租户权限重建(仅初始化或修复数据时用) foreach ($userRolePairs as $pair) { $enforcer->addGroupingPolicy($pair['user_id'], $pair['role_code'], $pair['tenant_code']); }这里有个工程问题要提前想好:业务表和casbin_rule表不在同一个事务里。如果第一步insert user_roles成功,第二步同步g规则失败,就会出现业务表有角色但鉴权不过的“假掉线”。我的处理是:业务表变更成功后,立刻触发同步,同时把整个租户的权限版本号递增(后面缓存一节会细说),保证即使同步暂时失败也不会让旧缓存一直生效;再拉一条队列任务做兜底重试。这属于最终一致性思路,权限系统可以接受短时间内的规则同步延迟,但绝不能接受“永久不一致”。
3.3 一次完整鉴权的数据链路
实际请求进来时,大致走这么几步:
- 中间件解析Token拿到
$userId,从域名、请求头或path前缀解析出$tenantCode。 - 初始化Enforcer实例。php-casbin用法是:
new Enforcer('/path/to/model.conf', new DatabaseAdapter($pdo))。Laravel环境下建议把Enforcer注册成单例,避免每次请求重新加载模型和策略。 - 从路由映射表拿到当前接口的权限code,例如
user:create。 - 调用
$enforcer->enforce($userId, $tenantCode, 'user:create', '*'),返回true或false。 - 返回false时抛异常,让前端跳403页面或弹出无权限提示。
第3步的路由映射表是本设计中很关键的一环:每个接口都要和权限code建立对应关系。可以做成一张路由权限表,也可以直接写在路由注解里。我最开始只在按钮层面控制,后来发现API层不控制等于白搭——有人绕过前端直接curl接口,按钮藏得再好也没用。权限code最终必须落在服务端路由层做强制校验。
4. 上线前必须想清楚的四个隐患
4.1 超级管理员别硬塞进规则表
多租户系统里通常有两类管理员:平台级超管和租户内管理员。平台超管要能进任意租户操作,常见错误做法是给它配一条通配规则,比如p: super_admin, *, *, *。
极其不建议这么做。原因有三:
- 通配规则会让所有租户的enforce都先命中超管,规则表里这条“万能钥匙”一旦导出、打印、泄露,排查时必须全局找业务去核对。
- 租户管理后台如果需要展示“哪些角色拥有哪些权限”,通配规则会和租户自己的角色配置混在一起,引起各种误展示。
- 超管的鉴权路径本应是“绝对信任”,不需要经过规则匹配器跑一遍。
正确做法是users表里直接放is_super_admin字段,在权限中间件里优先判断:
if (!empty($user['is_super_admin'])) { return $next($request); } if (!$enforcer->enforce($userId, $tenantCode, $permissionCode, '*')) { throw new AccessDeniedException('没有操作权限'); }这样超管的放行逻辑零规则依赖,租户内的管理员则通过给角色挂权限点的正常方式管理。两者的边界非常清晰。
4.2 权限变更后的缓存失效怎么设计
Casbin查询规则本身很快,但每次请求都读一次数据库还是会有压力。我在上线前给Enforcer的enforce结果加了缓存,单纯加缓存容易,难的是权限变更后怎么失效。
逐条删除缓存太脆弱:一个租户改了3个角色、每个角色挂了20个权限点,你得精确算出哪些用户影响哪些权限,稍微漏一个就有人“权限没刷新”。我的方案是“租户级版本号”:
$version = cache('tenant:'.$tenantCode.':perm_version', 0); $cacheKey = "perm:{$tenantId}:{$version}:{$userId}:{$permissionCode}:*"; if (cache()->has($cacheKey)) { return cache()->get($cacheKey); } $allowed = $enforcer->enforce($userId, $tenantCode, $permissionCode, '*'); cache()->set($cacheKey, $allowed, 600); return $allowed;权限变更时,除了同步规则,还执行:
cache()->increment('tenant:'.$tenantCode.':perm_version');版本号一变,同一个用户的缓存key就变了,旧缓存自然过期,新请求会重新读取规则。这个方案的关键优势是:无论规则变更多复杂,一条increment解决所有失效问题,不用去扫描哪些key要删。我上线后遇到的最大好处是让“权限改完立刻生效”这件事变成了确定性行为,再也不用靠猜。
4.3 接口数据查询的租户边界无法靠Casbin兜底
这是很多权限系统的通病:Casbin判断“你有没有权限调用订单列表接口”,但它根本不知道“你能看到哪些订单”。数据范围必须由SQL层来保证。
举个例子,查询订单详情:
// 错误示范:只按订单ID查,可能看到其他租户的订单 $order = Order::find($orderId); // 正确做法:租户条件必须写进查询 $order = Order::where('tenant_id', $currentTenantId)->find($orderId);如果你的代码里大量出现“只按主键ID查询”的接口,在多租户模式下就是重大隐患。因为主键ID是全局自增的,租户A的订单ID未必在租户B范围里,但拦不住有人遍历ID一个个试。我在code review时定了硬规矩:带数据返回的查询,SQL里必须出现tenant_id条件。少数确实不需要租户隔离的数据(比如国家码表),要在代码注释里特别说明,并走白名单流程。
4.4 软删除、唯一索引与账号复用
很多团队习惯用软删除(deleted_at字段)来管理用户和角色,结果和唯一索引撞车。
举个典型场景:租户A有用户名admin,后来这个人离职被“删除”(软删除),过段时间新员工入职想注册admin,如果唯一索引是(tenant_id, username),插入会直接冲突;如果唯一索引是(tenant_id, username, deleted_at),由于软删记录deleted_at不是NULL,看上去能插入成功后,但如果两个被删账号恰好同一秒删除,再次插入还会撞索引。
我的处理建议分两种:
- 用户和角色尽量不物理删除,用
status=0禁用。这样唯一索引不会变复杂,权限规则也可以保留——被禁用的人反正登录不了,规则在不在无所谓。 - 确需保留“删除”语义时,删除操作顺带把账号改成
username_del_{timestamp},释放原名,同时保持唯一索引干净。之后再清理掉casbin_rule里对应的g规则和p规则,避免规则表积累垃圾。
这套思路里,“禁用代替删除”是首选。权限系统里历史关系本身有审计价值,删除留痕比物理清空更有意义。
5. 演进过程中的复盘:什么时候不需要Casbin
5.1 从框架自带RBAC迁移的真实感受
迁移之前,我们项目用框架自带的角色判断:一个用户表、一个角色表、一张用户角色表,权限判断靠控制器里手写if ($user->can('xxx'))。初期没问题,但随着权限点增多,出现两个痛点:
- 每次新增一个资源,都要改菜单表、角色表、关系表,代码模板复制粘贴,权限校验逻辑散落各处。
- 没有统一的规则存储格式,无法导出、无法对比差异,更不要说支持复杂的多域模型。
换成php-casbin之后,权限点管理收敛到permissions表,规则管理收敛到casbin_rule表,控制器里全部变成一行enforce()。新增权限点时,我只需要加一条permission记录,再给对应角色挂上,代码层面零改动。这种“配置化权限”带来的长期收益,比一开始多写的那些同步代码大得多。
5.2 不建议上Casbin的场景
说了这么多优点,也得泼盆冷水。如果你的项目满足以下任一条件,我建议再等等:
- 权限结构十几年不变:角色固定、权限点固定、没有动态配置需求。这种情况框架自带的简单RBAC已经够用,引入Casbin属于杀鸡用牛刀。
- 只有菜单显示控制,没有API层校验:权限只控制前端显示不显示某个按钮,后端接口压根没做权限判断。这种情况问题不在权限引擎,先补API层校验再说。
- 团队对这个引擎不熟悉且不打算维护:Casbin的模型配置和规则同步有一定学习成本,接手的人如果没搞懂原理,改一处权限可能埋一个雷。
一个简单的判断标准:如果你没法在运行期动态调整权限而不用发版,那就不需要Casbin。Casbin解决的是动态、复杂、可扩展的权限模型;为了省那一行if-else而上它,性价比不高。
5.3 后续扩展:数据权限、资源属主与审计日志
这套表结构还有一个容易被忽略的好处:为后续从RBAC扩展到ABAC留了路。Casbin的request定义是可以自己加的,如果你想管“某个人只能操作自己创建的订单”,可以在request里加上owner(资源属主),在matcher里判断r.owner == p.sub之类;想加数据范围(只看本部门、只看本租户),可以在策略里增加数据范围标记字段。
权限审计方面,最简单的做法是在中间件里把每次enforce的user_id、tenant_code、permission_code、结果、时间都记一条日志。这不是为了复杂,而是线上出问题时,你能回答“谁在什么时间尝试了什么越权操作”。多租户系统一旦出安全事故,这个日志就是第一手的排查材料。
我自己在实际项目中,最受益的设计决策有两个:一是权限点规范化成user:create这种全局唯一code,让规则表始终可读可审计;二是租户级版本号的缓存失效方案,让权限变更“改了立刻生效,绝不错乱”。如果你打算按这套结构开工,先把7张表建起来,再写个简单的PermissionSyncService跑通一个权限点,后面按这个模式批量迁移即可。