两套权限目录,三重闸门:Alioth 的多租户 RBAC 为什么这么拧

两套权限目录,三重闸门: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)才是用户在某个租户里的身份。这个拆分决定了后面所有设计。

二、第一层:两套目录不能混

go 复制代码
// 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 里:

go 复制代码
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 四个预置租户角色,后来全部删掉:

go 复制代码
// app/alioth/internal/data/seed.go
// 预置角色改为完全由租户创建、成员添加时显式分配,owner/admin/member/viewer
// 四个系统角色不再使用;负责人权限改由 namespaces.owner_uid 隐式判定。

现在负责人不是"拥有 owner 角色的人",而是 namespace.owner_uid 指向的那个人:

go 复制代码
// 租户负责人隐式持有全部租户权限,不依赖任何预置角色数据。
// 套餐能力/模块检查仍然生效,负责人也不能使用租户未开通的功能。
owner := namespace.OwnerUID == uid

为什么值得为这件事做一次数据迁移?三个原因:

  1. 预置角色可编辑,就等于权限漂移。 只要 owner 角色允许被改权限,就一定会出现"某天 owner 角色没了 member:write,然后没人能把权限改回去"的死锁。负责人隐式持有全部权限,这个死锁在结构上不存在。
  2. 角色授权需要落到具体的人。 预置角色是"人人可用"的模板,出问题时你查不到"到底是谁把这个角色改成这样的"。现在租户必须自己建角色、在添加成员时显式分配,审计链路从角色名变成了真实授权记录。
  3. 迁移可以完全幂等。 删除预置角色时先删成员授权和权限授权,再软删角色行,重复执行时系统角色已经查不到,不会二次破坏数据。

代价也是真实的:新租户进来必须先建角色才能分工,产品要在引导上补这一步;老租户的自定义角色要补 k8s:* 的细粒度权限(migrateCustomRoleK8sPermissions 干的活)。但比起"某天发现没人能改权限",这个代价划算。

四、第三层:能力套餐与菜单权限

权限通过了,不代表能操作。Alioth 在角色权限之后还串了两道检查:模块/能力是否给租户开通 ,以及成员是否持有对应菜单权限。它们的错误消息是三条不同的 403,方便排障:

消息 含义 谁能改
permission denied 角色没这个权限 租户管理员
feature not enabled 租户没开通这个能力(套餐) 平台超级管理员
menu permission denied 有权限但没授菜单入口 租户管理员

判断顺序在 require 的后半段,注释写得比代码还细:

go 复制代码
// 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 里有两道:

go 复制代码
// 未开通能力/模块的权限不能新授给角色:租户管理员不能绕过套餐自己加开。
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 只属于负责人,任何自定义角色都授不了------租户不能被"角色设计"删掉。

六、几个真实的坑

  1. 菜单权限不是接口语义。 它只控制入口可见性,IsReadOnly 和 ReadOnlyNames 都把它当只读处理。曾有实现把它塞进接口校验,结果定时任务和 CLI 调用全部 403。
  2. 读权限常常要跨菜单共享。 通知模块的渠道、模板、通知组会被其他通知菜单引用,所以读接口允许"任一通知菜单的读权限"放行(NotificationReadPermissions),写和删除仍按菜单独立授权。
  3. 平台只读通道跳过菜单检查,但不跳过模块检查。 系统管理员不需要租户给他授菜单权限,可租户没开通 Kubernetes,他进去也只能看到 403------这个行为在测试里被单独钉住(access_test.go 里 system admin read-only disabled feature)。
  4. 能力检查在写路径上还有一次。 例如通知组的创建会再验一遍能力,避免"检查一次、写一次"之间能力被回收的窗口。

七、怎么验证

权限系统最怕"看起来对"。Alioth 用一组表驱动测试把边界钉死:app/alioth/internal/access/access_test.go 覆盖三种 403、负责人隐式权限、只读切入;app/alioth/internal/biz/role/role_test.go 覆盖越权授予与套餐范围。想跑起来看实际效果:

bash 复制代码
git clone https://github.com/aide-family/moon.git && cd moon
make dev-up
open http://127.0.0.1:8080

用两个浏览器窗口分别登录,一个用超级管理员身份给租户开/关套餐,另一个用租户成员身份刷新页面,就能看到菜单入口和接口 403 是同步变化的。

结语

把多租户 RBAC 的复杂度拆开,其实就是三句话:平台身份与租户身份分目录,负责人靠字段隐式而不是靠角色,能力、菜单、权限各管一件事。 每一条都会让代码更啰嗦一点,但每一条都对应一个真实会出事的场景。权限系统的质量不在于支持多少种角色,而在于"没人能把自己锁死、也没人能越权给自己加权限"这两件事是否在结构上成立。

相关推荐
imythu25 分钟前
让 Claude Code 在家跑,我在外面用手机盯着:做一个开源工具踩过的几个坑
人工智能·github
报表开不开发33 分钟前
从"给人看的 Excel"到"能入库的规范行":点检表数据清洗逻辑拆解
程序员
Tokenfree38 分钟前
把多模态 OCR 接进业务系统:四千张运单,三个坑
后端
用户859313061351 小时前
我给 AI Agent 写了个下载器:293KB 的网页,不再吃掉 7 万 token
后端
phil61 小时前
沙箱核心功能系列 06:编排调度与资源管理
后端
小兔子1 小时前
if (system(cmd) != 0) 判不出命令失败:它返回的是等待状态,不是退出码
后端
艾迪的技术之路1 小时前
16k 条慢查询,128 张分表,我这么解决
后端·github
dpharness1 小时前
我踩过的四个记忆坑,从预算到换机
前端·后端
加宝的起点雷达1 小时前
三个定时任务被删那天,系统一声不吭
人工智能·后端