3.1 权限体系:角色与租户双维度
场景:权限做不好,多租户就是事故合集
数据隔离守住了库,但只要有人能登录,权限就是第二道大门。我们出过一次惊魂事故:客户 A 的管理员注销后,他的会话令牌还挂在一个分享出的看板链接里,客户 B 的员工点开那个链接,居然能看到 A 户的数据看板。根因是权限校验只验证了令牌有效性,没有重新校验会话的租户归属。虽然没看到敏感明细,但那一次让我们彻底明白:多租户的权限是两个维度交叉的,一个是角色(能做什么),一个是租户(能看谁家的),缺了任何一维都会出事。
定制时代权限是单维度的:张三是管理员,他什么都能看。SaaS 时代必须加上第二维:张三是 A 户的管理员,只对 A 户有效,跨到 B 户就是路人。这个维度加起来容易,难的是让它在所有入口都生效——页面、接口、导出、文件下载、甚至队列任务,任何一处漏掉校验就是一次事故现场。这一节把我们在两个维度上花的功夫摊开讲,包括代码怎么落、检测怎么做、以及排过的雷。
另一个常被忽略的点是权限与功能的版本关系:功能在迭代,权限点也要跟着长。某个老功能加了个新的导出按钮,如果没给它挂权限点,它默认对所有角色开放,等于悄悄挖了个洞。我们后来定了死规矩:新功能提测时必须附权限点矩阵,否则不予测试,把治理动作前置到开发流程里。
讲规模感的三个事实:权限检查的代码点是全系统最多的,有将近两百个;权限相关的事故在上线第一年占了工单的四分之一;权限模块也是唯一每次大版本都要重构的模块。听上去枯燥,但它是系统里最被低估的核心资产,投资回报率极高。
方案:角色加租户的双维度模型
模型选择上我们对比过三种经典方案,结论是够用、可审计、团队维护得动最重要,花哨的表达力在小团队手里反而是负担:
| 模型 | 表达力 | 维护成本 | 适用性 |
|---|---|---|---|
| ACL 逐资源授权 | 最强 | 高,规则爆炸 | 资源数小的场景 |
| RBAC 角色继承 | 强 | 低,角色可枚举 | 企业应用主流 |
| ABAC 属性策略 | 灵活 | 高,策略难审计 | 复杂合规场景 |
我们选 RBAC,并强制把租户号作为每次校验的第一参数。角色的设计克制在六个以内,多了必然失控:
| 角色 | 数据范围 | 典型成员 |
|---|---|---|
| 超级管理员(平台方) | 所有租户 | 我们自己的运营 |
| 租户管理员 | 本户全部 | 客户老板或 IT |
| 租户运营 | 本户业务数据 | 市场与运营 |
| 租户财务 | 账单与发票 | 财务 |
| 租户员工 | 被授权项目 | 一线员工 |
| 访客 | 指定公开页 | 外部协作方 |
克制角色数量的好处是审计起来一目了然,坏处是总有客户想自定义。我们的折中:角色固定,但每个角色可以按功能模块勾选开关,等于在 RBAC 上加了一层轻量的数据权限。客户想拧巴的细粒度需求,八成能被这层开关满足,剩下一成我们建议他升级企业版走专属定制,而不是改架构。
角色表里还有一个隐藏设计:同一用户在同一租户只允许一个主角色,避免角色叠加产生权限并集的意外。临时需要的额外权限走审批单独赋权,且带有效期,到期自动回收。授权这件事最大的风险不是不够用,而是给了就忘。
配置:统一校验入口
所有接口走同一个权限中间件,策略类集中管理,避免散落在各控制器里:
class CheckPermission
{
public function handle($request, Closure $next, string $perm)
{
$ctx = app(TenantContext::class);
$user = $request->user();
// 租户维度:令牌所属租户必须与当前请求租户一致
if (!$user || $user->tenant_id !== $ctx->id()) {
return response('无权访问该租户', 403);
}
// 角色维度:会员角色是否拥有该权限
if (!$user->role->can($perm)) {
return response('权限不足', 403);
}
return $next($request);
}
}
注意第一段校验是关键中的关键:令牌的 tenant_id 与当前上下文必须一致,它就是当年看板事故的解药。第二段才是常规的角色权限。两段校验的顺序不能颠倒,先定租户再谈权限,是整个体系最重要的约定。
数据层面的保险丝也没省:所有业务查询自动带上当前用户角色对应的数据范围,员工只能看到被授权项目的行,由模型基类的 second scope 兜底。这样即使有人翻墙进来,也只能在院子里打转,进不了屋。
再补一条验收经验:权限相关的变更,我们要求作者自己跑一遍越权矩阵——用一个测试账号尝试访问其他租户的页面、接口、导出链接,把四十多种组合跑成 checklist 脚本,每次发版自动执行。权限的修改最容易引入回归,因为它散落在两百多个检查点上,人眼 review 根本看不过来,必须靠脚本守。
量化:权限健康度的三个信号
权限模块安静不代表健康,我们用三个信号持续度量:
| 信号 | 采集方式 | 健康标准 |
|---|---|---|
| 越权拦截量 | 四零三日志统计 | 有量但无尖峰 |
| 令牌校验覆盖率 | 路由表抽样审计 | 百分之百 |
| 角色配置投诉 | 客服工单分类 | 趋势下降 |
第一个信号有个有趣解读:如果某天拦截量突然为零,不是天下太平,更可能是校验失效了,这时候先怀疑系统而不是用户。这套看板救过我们一次,某次升级后校验中间件注册顺序错位,拦截量从一个稳定的基线掉到零,监控先于客户发现了问题,那天要是没有这块看板,事故要等客户投诉才能发现。
扩展:跨租户协作与邀请
权限模型的边界有一处例外很值得单独说:跨租户协作。供应商和经销商之间、集团总部和分子公司之间,天然存在跨户查看数据的需求。我们的方案是协作令牌:A 户发出邀请,B 户接受后生成一张映射表,记录谁能以什么角色看 A 户的哪些项目,令牌可随时撤销。
| 协作要素 | 设计 |
|---|---|
| 邀请方式 | 邮件链接带一次性令牌 |
| 授权粒度 | 项目级,而非整户 |
| 有效期 | 默认九十天,可续期 |
| 撤销 | 即时失效并清缓存 |
协作令牌机制上线后成了续费率最高的功能之一,尤其是制造业客户,上下游协作基本都靠它。协作的本质是在不破坏隔离的前提下开一扇受控的门——门上有锁、有期限、有记录。这比给租户管理员开放跨户权限再祈祷他谨慎,要可靠一万倍,客户的安全负责人也更愿意为这种设计签字。
踩坑:权限的四个雷区
第一个是仅前端隐藏菜单。页面不显示不等于接口不存在,早期我们把敏感功能藏在前端菜单里,被客户的技术人员翻接口文档翻出来了,他当时还在群里发了截图,整个团队社死。所有路由必须在服务端声明权限,前端只负责展示,这条规矩之后再没破过。
第二个是令牌跨租户复用。单人管理多个租户账号是常见诉求,我们最初的做法是切换租户时令牌不变,结果有人切错租户操作了另一个户的数据,事后还说不清是有意还是手滑。后来切换必须重新换发令牌,并在令牌里内嵌租户号,让跨户操作在协议层就不可能偷偷发生。
第三个是管理员自己绕过。租户管理员理论上能给自己开任何权限,我们在敏感操作(导出全部数据、删除成员)上加了二次确认与异步审批流,操作记录同步给租户的其他管理员。权力 delegated 出去的同时也必须有制衡,这是做平台的责任,客户出事第一个被骂的终究是我们。
第四个是导出与文件下载的权限。大家容易只记住接口权限,忘了导出链接是可以被复制转发的。导出工单化后,下载链接带一次性令牌且校验当前登录者的租户与角色,直接转发出去十二小时就失效。权限体系与数据导出的配合,可以参见 PHP API 实战 的令牌设计,那里对一次性下载令牌有展开。
还有一个留白:本节没展开审计日志,因为它值得单独成篇,见 4.3 安全边界一节,这两节加起来构成多租户的信任骨架。
小结
权限是多租户系统的第二道大门:角色维度管能做什么,租户维度管能看谁家的,两者交叉校验缺一不可。代码上收敛进一个中间件两段校验,先定租户再谈权限;模型上克制角色数量,用功能开关做微调;度量上持续看拦截信号,零拦截不等于安全。把权限当作产品能力来做,而不是技术债,客户的 IT 部门才会把系统视为可托付的基础设施,而他们往往是续费决策的关键人。
如果团队人手有限,先抓哪一段?我们的经验是先抓令牌的租户校验,它是一道闸门,几行代码挡住一大片事故;角色体系可以慢慢演进,但令牌这关一天都不能晚。还有一条管理上的心得:权限变更我们要求两人复核制,任何角色或租户维度的调整都必须由另一个人确认。执行大半年后,一次产品负责人自己也看花了眼的批量调整被复核人拦下——那天如果不是有这道闸,三个部门的管理员会一觉醒来发现彼此的权限互换了。制度有时候比代码更像基础设施。下一节看模板引擎:怎么让每个租户的站点长得不一样。
