两套权限目录,三重闸门:Alioth 的多租户 RBAC 为什么这么拧
Alioth 技术笔记 · 第 02 篇 · 权限设计 / 多租户 仓库:github.com/aide-family… | 在线演示:web.alioth.asia/
导语
多租户权限系统的难点从来不是"建不建权限表",而是几个更别扭的问题:平台管理员和租户管理员是不是一回事?租户的"负责人"要不要在角色表里占一行?租户没开通的功能,持有权限的成员为什么还会被拒?Alioth 在 Moon 的基础上重做了一遍 RBAC,最后长成了"两套权限目录 + 三重检查闸门"的形状。这篇把这个形状拆开讲,包括那些看起来过于谨慎、但都是踩过坑才写下的判定顺序。
一、起点:Moon 只有角色枚举
Moon 的 goddess 服务里,成员身份只是一个枚举:MEMBER / OWNER / ADMIN / VIEW。没有权限表,业务接口也不按角色拦截——前端藏了按钮,后端就当操作合法。对单租户或小团队够用,一旦平台要托管多个租户、要把 Kubernetes 和 SSH 这类高危能力按租户开通,就完全不够了。
Alioth 保留 Namespace 作为租户,但把身份拆成两层:用户(User)是平台级身份,不隶属于任何租户;成员(Member)才是用户在某个租户里的身份。这个拆分决定了后面所有设计。
二、第一层:两套目录不能混
// app/alioth/internal/access/access.go
// RequireSystem 校验平台权限。它不读取 X-Namespace,也不依赖租户成员身份。
func (c Checker) RequireSystem(ctx context.Context, code string) (*bo.Account, error) {
uid, ok := actor.UserID(ctx)
...
for _, name := range perm.SystemPermissions(account.SystemRole) {
if name == code { return account, nil }
}
return nil, merr.Forbidden("permission denied")
}
平台侧目录是 system:user:read、system:namespace:write、system:package:write 这样的一组,只由平台角色持有:SUPER_ADMIN、SYSTEM_ADMIN、SYSTEM_READONLY、USER。租户侧目录是 resource:action,由租户自建角色持有。两套目录互不相通:租户角色永远拿不到 system:*,平台角色也不会因为"是管理员"就自动获得租户内的写权限。
这条边界有一个关键产物:平台管理员可以只读切入任意租户,但不能写。实现藏在 require 里:
if _, ok := perms[code]; !ok {
// 只有租户目录里的 :read 放行,写、删除、介入和执行动作仍然要求成员角色
if !perm.IsReadOnly(code) { return merr.Forbidden("permission denied") }
admin, err := c.SystemAdmin(ctx)
if !admin { return merr.Forbidden("permission denied") }
readOnly = true
}
这是一个有意的取舍。很多平台的做法是"超级管理员能看到一切、也能改一切",方便,但爆炸半径是整个实例;Alioth 选择让系统管理员能排障、能看配置,但真要动手,必须先成为那个租户的成员。权限边界跟着数据边界走。
三、第二层:负责人改成隐式,预置角色被删了
这一层是 Alioth 与 Moon 差别最大的地方。最初的版本也照搬了 owner / admin / member / viewer 四个预置租户角色,后来全部删掉:
// app/alioth/internal/data/seed.go
// 预置角色改为完全由租户创建、成员添加时显式分配,owner/admin/member/viewer
// 四个系统角色不再使用;负责人权限改由 namespaces.owner_uid 隐式判定。
现在负责人不是"拥有 owner 角色的人",而是 namespace.owner_uid 指向的那个人:
// 租户负责人隐式持有全部租户权限,不依赖任何预置角色数据。
// 套餐能力/模块检查仍然生效,负责人也不能使用租户未开通的功能。
owner := namespace.OwnerUID == uid
为什么值得为这件事做一次数据迁移?三个原因:
- 预置角色可编辑,就等于权限漂移。 只要 owner 角色允许被改权限,就一定会出现"某天 owner 角色没了
member:write,然后没人能把权限改回去"的死锁。负责人隐式持有全部权限,这个死锁在结构上不存在。 - 角色授权需要落到具体的人。 预置角色是"人人可用"的模板,出问题时你查不到"到底是谁把这个角色改成这样的"。现在租户必须自己建角色、在添加成员时显式分配,审计链路从角色名变成了真实授权记录。
- 迁移可以完全幂等。 删除预置角色时先删成员授权和权限授权,再软删角色行,重复执行时系统角色已经查不到,不会二次破坏数据。
代价也是真实的:新租户进来必须先建角色才能分工,产品要在引导上补这一步;老租户的自定义角色要补 k8s:* 的细粒度权限(migrateCustomRoleK8sPermissions 干的活)。但比起"某天发现没人能改权限",这个代价划算。
四、第三层:能力套餐与菜单权限
权限通过了,不代表能操作。Alioth 在角色权限之后还串了两道检查:模块/能力是否给租户开通,以及成员是否持有对应菜单权限。它们的错误消息是三条不同的 403,方便排障:
| 消息 | 含义 | 谁能改 |
|---|---|---|
permission denied | 角色没这个权限 | 租户管理员 |
feature not enabled | 租户没开通这个能力(套餐) | 平台超级管理员 |
menu permission denied | 有权限但没授菜单入口 | 租户管理员 |
判断顺序在 require 的后半段,注释写得比代码还细:
// namespace:* 不属于任何能力,始终可用;菜单权限本身不是接口权限,不在这里判断。
module := perm.FeatureOf(code)
if module == "" || perm.IsMenuPermission(code) { return nil }
// 权限通过后再看租户开通情况:能力没开通,成员持有权限也不能用。
// 平台管理员的只读视角同样受限。
modules, err := c.Features.EffectiveModules(ctx, ns)
if !slices.Contains(modules, module) { return merr.Forbidden("feature not enabled") }
三层各自解决一个不同的问题:角色权限回答"这个人能不能做这件事",套餐回答"这个租户买没买这件事",菜单权限回答"这个入口该不该出现在侧边栏"。它们不能互相替代——把菜单权限当接口权限用,会让 API 调用方在没有 UI 的情况下无故 403;把套餐当角色用,会让租户管理员能自己绕过计费加开功能。
还有一个容易忽略的细节:namespace:* 不属于任何能力,永远可用。否则租户一旦被降级,连"我要续费"的设置页都进不去。
五、授权时先防越权
检查是"用"的时候拦,授权是"给"的时候拦,两道都得有,否则一个租户管理员可以给自己建一个超集角色。UpdateRolePermissions 里有两道:
// 未开通能力/模块的权限不能新授给角色:租户管理员不能绕过套餐自己加开。
if !permissionInTenantScope(name, capabilities, modules) {
if _, ok := held[name]; !ok { return nil, merr.Client("feature not enabled") }
}
...
// 防越权:非负责人只能授予自己当前拥有的权限。
if !owner {
for _, name := range names {
if _, ok := held[name]; !ok {
return nil, merr.Client("cannot grant permissions beyond your own")
}
}
}
两个细节值得说:
- 只拒绝"新增"的未开通权限,保留旧授权。 能力被关闭后,角色上的旧权限如果一律拒绝,前端把当前权限原样回传就会保存失败;静默丢弃又会丢数据。所以判定的单位是"这次请求是否新增了越界权限",而不是"最终集合是否越界"。
- 归一化之后再校验子集。 菜单权限会自动补默认读权限、子权限会自动补所属菜单;如果先校验后归一化,就能借自动补全绕过子集限制。代码里
normalizeRoleGrant放在子集检查之前,就是为了堵这个洞。
另外 namespace:delete 和 namespace:transfer 只属于负责人,任何自定义角色都授不了——租户不能被"角色设计"删掉。
六、几个真实的坑
- 菜单权限不是接口语义。 它只控制入口可见性,
IsReadOnly和ReadOnlyNames都把它当只读处理。曾有实现把它塞进接口校验,结果定时任务和 CLI 调用全部 403。 - 读权限常常要跨菜单共享。 通知模块的渠道、模板、通知组会被其他通知菜单引用,所以读接口允许"任一通知菜单的读权限"放行(
NotificationReadPermissions),写和删除仍按菜单独立授权。 - 平台只读通道跳过菜单检查,但不跳过模块检查。 系统管理员不需要租户给他授菜单权限,可租户没开通 Kubernetes,他进去也只能看到 403——这个行为在测试里被单独钉住(
access_test.go里system admin read-only disabled feature)。 - 能力检查在写路径上还有一次。 例如通知组的创建会再验一遍能力,避免"检查一次、写一次"之间能力被回收的窗口。
七、怎么验证
权限系统最怕"看起来对"。Alioth 用一组表驱动测试把边界钉死:app/alioth/internal/access/access_test.go 覆盖三种 403、负责人隐式权限、只读切入;app/alioth/internal/biz/role/role_test.go 覆盖越权授予与套餐范围。想跑起来看实际效果:
git clone https://github.com/aide-family/moon.git && cd moon
make dev-up
open http://127.0.0.1:8080
用两个浏览器窗口分别登录,一个用超级管理员身份给租户开/关套餐,另一个用租户成员身份刷新页面,就能看到菜单入口和接口 403 是同步变化的。
结语
把多租户 RBAC 的复杂度拆开,其实就是三句话:平台身份与租户身份分目录,负责人靠字段隐式而不是靠角色,能力、菜单、权限各管一件事。 每一条都会让代码更啰嗦一点,但每一条都对应一个真实会出事的场景。权限系统的质量不在于支持多少种角色,而在于"没人能把自己锁死、也没人能越权给自己加权限"这两件事是否在结构上成立。