做AI SaaS平台这两年,我最大的体会就是权限体系这种东西,看着不起眼,真到了模型一多、用户一多、租户一多的时候,它会变成整个系统里最容易出事故、也最难改的一块。之前我们平台刚起步时,权限就两个角色,管理员和普通用户,菜单里藏一下按钮就完事了。后来接入的AI能力越来越重,有的租户要调GPT-4o,有的只需要轻量摘要,还有人要按调用次数计费,这时候才发现,权限不是“谁能登录后台”的问题,而是“谁能调用什么模型、能用多少额度、能看哪些数据”的立体问题。
这篇文章就围绕如何用RBAC(基于角色的访问控制)实战落地一套AI SaaS平台的权限体系展开。我尽量讲清楚整个设计链路:从数据表怎么建、权限点怎么命名、到模型调用怎么鉴权、配额怎么扣、审计日志怎么做,再到实际开发中哪些坑最容易踩。适合正在做SaaS平台、AI应用后端、或者准备重构权限模块的团队参考,也适合刚接触权限设计的后端工程师,至少能帮你少走几条弯路。
1. 为什么AI SaaS平台的权限体系不能只靠“登录 + 角色”
1.1 传统后台权限和AI SaaS平台的差异
传统的后台权限管理,通常解决的是“谁能进入哪个页面、谁能点哪个按钮”的问题。用户登录后拿到一个角色,角色关联一批菜单和操作权限,后端接口再做一下拦截,基本就完事了。这种模型在纯信息管理类系统里非常成熟,也是RBAC最经典的应用场景。
但放到AI SaaS平台上,事情变复杂了,因为你要控制的资源不只是页面和按钮,还包括AI模型本身的调用、Token额度、并发数、数据隔离范围、甚至不同模型的不同版本。举个例子,我们平台上有一个客户成功团队和一个开发者团队,前者只能白屏操作,调用摘要模型,后者需要直接调API,并且可以配置prompt模板。如果只按“管理员/普通用户”分,要么开发者拿到过多权限,要么客户成功团队什么都干不了。更麻烦的是,AI模型调用是有成本的和合规要求的,你不光要管“能不能调”,还要管“能调几次”“能调哪个模型”“调完之后日志留没留下”,这些已经不是传统RBAC能直接覆盖的范围了。
所以说,AI SaaS平台的权限体系,本质上是在传统RBAC之上叠加了资源权限、配额权限、租户数据权限和审计要求。RBAC依然是最稳的底座,但必须在底座上做扩展。
1.2 RBAC模型怎么选:从RBAC0到RBAC2再到RBAC+ABAC
很多同学知道RBAC,但不一定清楚RBAC本身也分几个级别。做设计之前,先把模型选对,能省掉后面大量返工。
- RBAC0:用户直接关联权限,没有角色这层抽象。小项目能用,但一旦权限一多,用户和权限之间直接爆炸,基本不推荐。
- RBAC1:引入角色继承,角色可以嵌套。比如“运营”继承“基础用户”的所有权限,再额外加一些导出权限。这种模型很适合权限有天然层级关系的平台。
- RBAC2:引入角色约束,包括角色互斥(用户不能同时拥有两个互斥角色)、角色基数(每个角色的人数上限)、先决角色(要拥有某角色必须先有另一个角色)。这在金融、企业服务里很常见。
- RBAC3:RBAC1+RBAC2,既支持继承又带约束,能力最全,但复杂度也最高。
我实际做AI SaaS平台时,建议主体用RBAC1加部分RBAC2的约束,比如“模型管理员”和“财务管理员”做成互斥角色,避免权限过度集中。至于那种需要根据上下文动态判断的场景,比如“只允许在工作时间调用敏感模型”或者“同一个角色在不同租户下看到不同数据”,纯RBAC处理不了,我会额外引入ABAC策略来补充,而不是把RBAC硬拗成万能模型。
这里说一个很实用的判断标准:如果权限判断的依据是“用户身份是什么”,用RBAC;如果依据是“请求时的环境、资源属性、上下文”,用ABAC。AI SaaS平台里,前者解决90%的问题,后者解决最后那些动态规则。
1.3 纯RBAC和ABAC结合的落地思路
纯ABAC的问题在于规则太难维护。你让业务去写几十条策略表达式,他们根本看不懂。但RBAC的好处是直观——给某个角色勾选权限点,产品经理和业务都看得明白。
所以我的落地思路是:先把系统里所有可控制的动作抽象成权限点,关联到角色上,这部分全部走RBAC。在此基础上,再留一个规则引擎扩展点,专门承接“限时可用”“限资源可用”这类临时策略。比如某天要上线一个灰度模型,只允许白名单租户调用,我就在规则引擎里加一条“当租户ID在whiteList时,允许访问model:invoke:new-model”,而底层角色权限完全不用动。
这样既保住了RBAC的简单性,又不至于在动态需求面前束手无策。后面章节里的表结构设计,也完全兼容这种“RBAC为主、ABAC为补充”的方案。
2. 权限模型设计:核心表结构与关键字段解析
2.1 五张核心表的SQL设计
权限系统的地基是表结构,尤其要注意不要设计成“用户表里塞一个role字段”这种省事方案。后期你要查“哪些用户拥有某个权限”“某个角色都关联了什么权限”的时候,一张冗余字段表会让你想骂人。
我推荐至少五张表:用户表、角色表、权限表、用户-角色关联表、角色-权限关联表。字段上,不一定要完全照抄我的设计,但以下这些核心点值得保留。
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(128) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0禁用', tenant_id BIGINT NOT NULL COMMENT '所属租户', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(64) NOT NULL UNIQUE COMMENT '角色编码,如 MODEL_ADMIN', role_name VARCHAR(64) NOT NULL, parent_id BIGINT DEFAULT NULL COMMENT '父角色ID,支持角色继承', status TINYINT NOT NULL DEFAULT 1, tenant_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, perm_code VARCHAR(128) NOT NULL UNIQUE COMMENT '权限点编码,如 model:invoke:gpt-4o', perm_name VARCHAR(128) NOT NULL, category VARCHAR(64) DEFAULT NULL COMMENT '分组,如 MODEL_ACCESS / QUOTA / AUDIT', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_user_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, UNIQUE KEY uk_user_role (user_id, role_id) ); CREATE TABLE sys_role_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_id BIGINT NOT NULL, permission_id BIGINT NOT NULL, UNIQUE KEY uk_role_perm (role_id, permission_id) );这里有一个重要细节:所有业务表都加了tenant_id。原因很简单,SaaS平台天然多租户,如果权限表本身不按租户隔离,后面做数据权限会非常痛苦。虽然sys_permission可以做成全局共享,但角色和用户的关系必须绑定租户。比如A租户的“模型管理员”和B租户的“模型管理员”,同名但数据完全不能互通。
2.2 中间表为什么要存在,而不是用户表直接带角色ID
写单表快,但你会立即撞上“一人多角色”这种需求。产品经理大概率会说:“这个用户既是模型管理员,又是审计员,他的权限是两者叠加。”如果你在用户表里只放一个role_id,这需求直接做不了。
用中间表可以支持多对多,多角色时权限取并集,这个其实很简单,但要注意权限叠加可能导致越权。比如一个角色有“导出用户数据”权限,另一个角色刚好有“查看所有租户”的数据范围权限,两者叠加就可能把全平台数据导出。遇到这种情况,就要在RBAC2里加约束,或者靠权限审批流程来控制,后面我会在“常见问题”里展开讲。
2.3 把权限点设计成“资源标识”:统一命名与路由映射
权限点编码是整个体系中容易被低估的设计。很多团队直接写“用户管理”“模型管理”这种中文命名,短期能用,但接口一多、模型一多,维护起来就是灾难。
我建议用三段式命名:模块:动作:资源。模块通常对应系统域,动作是动词,资源是被操作的对象。例如:
- model:invoke:gpt-4o 表示允许调用GPT-4o模型
- model:invoke:gpt-4o-mini 表示允许调用轻量模型
- quota:update:user 表示允许调整用户配额
- audit:view:log 表示允许查看审计日志
- prompt:write:template 表示允许编写提示词模板
后端做校验时,直接在接口代码里声明需要的权限点,例如“调用模型接口需要model:invoke:gpt-4o”。前端拉取权限点列表后,控制菜单显隐、按钮状态。这样前后端用同一套权限点编码,理解成本低,排查问题也方便。
权限点不建议用数据库自增ID直接传给前端,因为ID在不同环境可能不一致,容易出现测试环境正常、生产环境错乱的灵异问题。用字符串编码做唯一标识,天然可读、可迁移,环境切换几乎没有成本。
2.4 数据权限与租户隔离:容易被忽略的维度
角色权限解决的是“能不能操作”,数据权限解决的是“能操作哪些数据”。在AI SaaS平台里,这两个维度必须分开设计。
举个例子,两个租户都开通了某个模型服务,如果接口鉴权只校验角色,不校验租户ID,用户用A租户的token调用接口,传入的却是B租户的模型配置ID,就可能读取到其他租户的数据。这种越权在AI应用里非常隐蔽。
我通常这么处理:先按RBAC判断“能不能做”,再按数据权限过滤“能做哪些”。数据权限分三级就够了,全部数据、本部门/本租户数据、仅本人数据。具体的隔离方式,SaaS平台一般有三种选择:
| 隔离方式 | 说明 | 适合场景 |
|---|---|---|
| 独立数据库 | 每个租户一个库,隔离最彻底 | 大型客户、合规要求高 |
| 独立Schema | 同库不同Schema | 中型SaaS |
| 共享表 + tenant_id | 所有租户共用同一张表,行级隔离 | 中小型SaaS起步期 |
我建议绝大多数AI SaaS平台起步阶段用“共享表 + tenant_id”,成本最低,也最灵活。关键是要有一个全局的TenantContext,比如基于ThreadLocal或请求上下文,把当前租户ID塞进去,然后所有的数据访问层都强制带上tenant_id条件,不允许靠开发人员自觉。
3. 结合AI能力扩展:模型调用权限、配额与审计
3.1 把AI模型当作受控资源:模型网关设计
AI SaaS平台和普通SaaS最不一样的地方,就是底层API全是模型调用。模型不是免费资源,也不是随便哪个角色都能碰,所以要把模型当成一类“资源”来管理。
我的做法是加一层模型网关,所有模型调用统一走网关,不在业务代码里直接拼OpenAI或者其他厂商的SDK调用。模型网关的核心职责有三个:鉴权、配额校验、转发。所谓的“模型权限”,本质上就是网关里的一组白名单配置,判断当前用户所在的角色,是否拥有目标模型的调用权限。
{ "model": "gpt-4o", "allowed_roles": ["ROLE_MODEL_ADMIN", "ROLE_ENTERPRISE_USER"], "allowed_plans": ["enterprise", "pro"], "rate_limit": { "rpm": 60, "tpm": 100000 }, "quota_cost": 20 }上面这个配置的意思是:只有模型管理员和企业用户角色能调用gpt-4o,且套餐必须是enterprise或pro,每分钟最多60次请求,每次调用消耗20个credits。网关拿到请求后,先解析用户角色,再和配置比对,不满足直接返回403,满足就进入配额扣减流程。
这个设计的优点很明显:新增一个模型,只需在网关里加配置,不需要改一堆业务代码。比如平台接入了新的图像生成模型,要开放给运营角色,只需要在网关配置里把运营角色加进白名单,再设置好配额成本,前后端代码零改动。
3.2 Credits配额设计:权限不只是“能不能用”,还要管“用多少”
AI模型按调用量计费,所以权限系统必须对接配额系统。很多团队一开始不做配额,结果月底账单出来老板傻眼。配额本质上是一张“余额表”,记录每个用户或角色在某个周期内可用多少次模型调用。
Credits在AI产品里通常指计量配额。我的方案是:
- 用户账户表里存总credits余额
- 模型配置表里存每次调用消耗多少credits
- 每次调用前做余额预校验,不足直接拒绝
- 调用成功后异步扣费,失败则返还
这里有一个非常容易出问题的点:并发扣费。用户连续点了几次“生成图片”,如果业务代码是“先查余额,再判断,再扣减”,并发请求可能同时通过校验,导致余额变成负数。
解决方式是用Redis的Lua脚本做原子扣减,或者用数据库乐观锁。我习惯在网关里直接做预扣,这样效率高一点:
-- 简单演示:原子扣减 credits local current = tonumber(redis.call('GET', KEYS[1]) or '0') local cost = tonumber(ARGV[1]) if current < cost then return -1 else redis.call('DECRBY', KEYS[1], cost) return current - cost end扣减完成后再去调模型API,万一模型调用失败,再执行“返还”逻辑,给用户的credits加回去。这个“预扣-回调-返还”的流程,比调用成功后再扣费可靠得多,因为模型调用是网络IO,超时、报错太常见了,调用成功后再扣费很容易漏扣。
3.3 审计与内容安全:AI场景更容易出问题
传统后台的审计日志记“谁删了什么数据”就够了,AI SaaS平台的审计更复杂。你得知道谁在什么时间调用了哪个模型、传了什么输入、模型返回了什么摘要。一方面是为了排查滥用,另一方面是为了满足合规要求。
我的建议是至少维护一张审计表:
CREATE TABLE ai_audit_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, model_code VARCHAR(64) NOT NULL, action VARCHAR(32) NOT NULL COMMENT 'invoke / retry / fallback', prompt_hash VARCHAR(64) DEFAULT NULL COMMENT '输入内容哈希,用于溯源', prompt_text TEXT DEFAULT NULL COMMENT '输入内容,需要脱敏', output_text TEXT DEFAULT NULL COMMENT '输出内容摘要', cost_credits INT NOT NULL DEFAULT 0, status TINYINT NOT NULL COMMENT '0失败 1成功', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, created_at), KEY idx_tenant_time (tenant_id, created_at) );注意,prompt_text和output_text必须脱敏,不能直接原样存,尤其是涉及用户隐私或企业机密的内容。实际操作里我会先跑一遍脱敏规则,把手机号、邮箱、身份证号替换掉,再落库。
内容安全层面,AI生成内容需要接入合规检测,检测不通过要拦截返回。这个可以放在模型网关里做,模型调用前先对输入做审查,输出回来后对内容做审查,两边都不放过。我们之前就踩过输出侧漏审的坑,用户输入本身合规,但模型生成的文案里带了违规内容,好在有输出检测兜底,否则问题就大了。
4. 鉴权实现:从登录态到权限校验的完整链路
4.1 登录态与JWT:权限信息放哪里
权限设计得再好,最终都要落到鉴权执行链路上。最常见的方案是用JWT做登录态,用户在登录后拿到一个token,后续请求带token访问。
问题来了:JWT里到底放不放权限信息?我见过有人把用户所有权限点塞进JWT,省得每次查库。但这样做有两个坑:一是JWT是无状态的,权限变更后旧的token依然有效,导致权限更新不及时;二是JWT体积膨胀,每次请求都带着一大串权限列表,浪费带宽。
我的做法是:JWT里只放用户ID、租户ID、角色编码列表这些轻量信息,不放全量权限点。每次请求进来后,用用户ID去缓存里拿权限点集合。权限变更时,通过版本号机制让缓存失效,这样既保证了实时性,又不会把token撑得很大。
// 伪代码:JWT payload 示例 { "sub": "u_12345", "tenant": "tenant_678", "roles": ["ROLE_MODEL_ADMIN"], "perm_version": 12, "exp": 1710000000 }perm_version是一个自增版本号,每次该用户的角色或权限变更,版本号加1,缓存里的权限数据也跟着刷新。网关里只用判断JWT的perm_version和缓存里的版本是否一致,不一致就重新加载权限,不用重启服务。
4.2 后端权限拦截:基于注解加自定义校验器
后端接口权限校验,我推荐直接用Spring Security加方法级注解,或者用AOP自定义一个权限注解。Spring Security生态成熟,但自定义注解更轻量,适合不想被框架绑得太死的团队。
我通常定义一个@RequirePermission的注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequirePermission { String value(); }然后在需要权限的接口上标注:
@PostMapping("/v1/model/invoke") @RequirePermission("model:invoke:gpt-4o") public Result invokeModel(@RequestBody InvokeRequest request) { // 业务逻辑 }再写一个切面,在方法执行前拦截,校验当前用户是否拥有指定权限点:
@Aspect @Component public class PermissionAspect { @Around("@annotation(requirePermission)") public Object checkPermission(ProceedingJoinPoint joinPoint, RequirePermission requirePermission) throws Throwable { PermissionContext ctx = PermissionContextHolder.get(); if (!ctx.hasPermission(requirePermission.value())) { throw new ForbiddenException(requirePermission.value()); } return joinPoint.proceed(); } }这样做的好处是,权限判断和业务逻辑完全解耦,代码里能看到每个接口明确的权限要求,后面接新的AI模型,也只需要标注对应的权限点。缺点也有,如果一个接口有多个权限点,注解只能写一个,这时可以把注解改成支持字符串数组,校验时满足任一即可,或者改成必须全部满足,看业务需要。
4.3 前端按钮级权限控制与菜单动态化
后端校验做完了,前端如果不配合,体验会很糟。用户看到一堆点不进去的菜单,肯定会困惑。所以前端也要根据权限点动态控制界面元素。
登录成功后,后端返回当前用户拥有的权限点列表,前端存起来。然后写一个v-permission指令,控制按钮显隐:
// Vue 3 指令示例 app.directive('permission', { mounted(el, binding) { const required = binding.value; const userPerms = store.state.userPerms; if (!userPerms.includes(required)) { el.parentNode && el.parentNode.removeChild(el); } } });模板里这样用:
<el-button v-permission="'model:invoke:gpt-4o'">调用GPT-4o</el-button>菜单动态化同理,后端返回菜单树的时候,每个菜单项都绑定权限点编码,前端渲染前先过滤一遍,没有权限的菜单直接不渲染。这里强烈建议前后端权限点编码完全一致,不要前端一套、后端一套,否则排查起来要命。
4.4 缓存权限:Redis加速与一致性
权限校验走数据库是能跑,但高并发下数据库压力太大。我建议把用户权限点列表缓存到Redis里,key类似:perm:user:{userId},value是权限点集合的JSON数组。
缓存之后要考虑失效问题。权限变更时,除了更新数据库,还要主动删除Redis缓存。由于JWT里有perm_version,也可以约定每次请求带着版本号,网关发现版本落后就主动刷新,这样即使Redis被误删,也能自动重建。
不过缓存只是加速手段,不能作为唯一数据源。高安全场景下,写操作确认权限前,最好回源数据库校验一次权限,避免缓存数据被篡改导致越权。大多数平台没那么高要求,Redis缓存就够了。
5. 常见问题与排查技巧实录
5.1 改了角色权限,用户还是能访问旧功能
这是最常遇到的问题,十有八九是缓存或token导致的。JWT里塞了权限列表的,token没过期之前权限当然不变;Redis缓存没删的,同样会读到旧权限。
排查思路三步走:第一步看JWT里有没有权限数据,第二步看Redis缓存里有没有旧数据,第三步看权限变更接口有没有主动删缓存。我见过一个团队的问题,是权限变更接口改了数据库,但缓存删除代码在一个新加的事务里,事务回滚了,缓存却被删了,于是权限刷新和数据库状态不一致,排查了整整一下午。所以缓存和数据库的操作顺序一定要设计好,我的习惯是先更新数据库,再删缓存,缓存删除失败要做重试。
5.2 并发扣费导致配额超扣
上文提到的并发问题,我再展开讲一个真实案例。我们上线初期,某个客户用脚本并发调用了20次接口,结果余额从1000被扣到负数,虽然模型调用全成功了,但对账就是不对。
根因还是“先查余额再扣费”不是原子操作。用数据库乐观锁能修,但性能差一点。我后来改用了Redis的Lua脚本,把“检查余额、扣减、返回剩余”放在一个脚本里执行,Redis保证脚本原子性,彻底解决了并发问题。如果你们没有Redis,也可以用数据库原子更新,比如UPDATE account SET credits = credits - 20 WHERE credits >= 20,受影响的记录数为0就说明余额不足,这个SQL本身是原子的。
5.3 角色多、权限矩阵乱,怎么治理
正常来说,权限点控制在100个以内,角色控制在20个以内,维护起来问题不大。一旦角色超过30个,权限矩阵基本就会失控。
我的建议是:定期做角色收敛。把权限点高度重叠的角色合并,用用户组而不是多角色来解决“一批人总是拥有相同角色”的需求。另外,每次给角色加权限点时,都要反问一句“这个权限真的需要放到角色上吗?能不能用ABAC规则做临时开通?”权限点不是越多越好,每多一个权限点,就多一分越权的可能。
5.4 多租户数据越权访问:全局拦截器兜底
多租户SaaS最危险的场景,就是A租户的用户传入B租户的资源ID,接口如果没有做租户隔离,数据就泄露了。光靠开发人员在SQL里写where tenant_id = ?往往不够,因为人都会忘。
我建议做一个全局MyBatis拦截器,或者在ORM层统一注入租户条件。比如MyBatis-Plus就有租户插件,配置好tenant_id字段后,所有自动拼接的SQL都会带上租户条件。这样即便开发者漏写了,框架也会兜底。前提是你能接受所有表都有tenant_id字段,这个设计需要前期规划好,后期临时加非常痛苦。
5.5 权限点编码错误排查
还有一个细节:权限点编码是字符串,一旦写错,比如前端要求model:invoke:gpt-4o,后端接口标的是model:invoke:gpt4o,用户就会莫名看到按钮被隐藏,或者接口403。这种问题没有好办法,只能靠规范。
我建议权限点编码统一用常量类或枚举管理,前后端从接口文档自动生成类型定义,避免手敲出错。审核代码的时候,重点看权限点字符串是否全部来自常量引用,而不是随手写的字面量。
如果让我重新把权限体系再做一遍,我会把租户隔离和审计日志放到最高优先级,因为这两个东西一旦上线后想补,成本比角色模型大多了。RBAC的核心思路不复杂,复杂的是把“谁能在什么条件下做什么事”这个一句话需求,拆成用户、角色、权限点、数据范围、配额、审计这六个维度,并且让它们各司其职又彼此协作。希望这篇实战记录能给你一些参考。