能力套餐:菜单、权限、开通范围怎么拆成三层而不打架
Alioth 技术笔记 · 第 03 篇 · 权限模型 / 商业化设计 仓库:github.com/aide-family... | 在线演示:web.alioth.asia/
导语
一个自建平台要变成能交付给多个租户的产品,绕不开三个问题:侧边栏该显示哪些入口、接口该不该放行、这个租户到底买了什么。最省事的做法是把三者合成一个"权限码",然后到处打补丁;稍微认真一点,会发现它们的变化节奏根本不同------菜单跟着产品迭代走,权限跟着角色走,套餐跟着商务走。Alioth 把它们拆成模块、能力、权限三层,并且只存能力编码,不存计算结果。这篇讲这套拆法,以及为什么"只增不减"和"套餐快照"是让三层不打架的两个关键决定。
一、先看失败的形态
Moon 的做法是:功能写在代码里,要么有,要么没有。想在版本 A 里卖告警、版本 B 里再卖 Kubernetes,只能靠分支或者环境变量。更麻烦的是"租户自己给自己加功能"------只要权限表可编辑,租户管理员建个角色勾上 cluster:access,就等于绕过了整条商务链路。
所以第三层的职责不是"权限的另一种写法",而是回答一个权限系统回答不了的问题:这个租户被允许使用哪些功能类别。它必须由平台侧控制,且必须能随时收回。
二、三层各管一件事
go
// app/alioth/pkg/perm/capability.go
// - 模块(Feature)决定菜单分组,是租户差异与功能检查的语义单位;
// - 能力(Capability)是套餐里勾选的单位,一个模块下可以有多个能力;
// - 权限(Permission)挂在能力上,租户角色按能力持有的权限决定能调哪些接口。
//
// 套餐与租户差异都只存能力编码;模块由能力反推,不单独落表。
举一个具体的例子,Kubernetes 模块下有 k8s_pod_read、k8s_pod_logs、k8s_pod_exec、k8s_pod_delete 四个能力,每个能力对应一个接口权限。商务上"只买只读集群"和"买 Pod 终端"是两笔生意,能力层就能表达;而侧边栏只需要知道"Kubernetes 模块在不在",那是模块层的事。
模块不落表,由能力反推:
go
func ModulesOfCapabilities(capabilities []string) []string {
enabled := map[string]struct{}{}
for _, code := range capabilities {
item, ok := CapabilityOf(code)
if !ok { continue }
enabled[item.Module] = struct{}{}
}
// 按 featureOrder 输出,保证顺序稳定
}
这条规则带来一个很好的性质:模块下只要还有一个能力开通,模块就还在。管理员关掉"Pod 终端",Kubernetes 菜单不会突然消失。
三、不存"有效能力",每次算
很多系统的做法是给租户存一列 features,开通时写入、到期时跑批清理。Alioth 反过来:只存三份原料------套餐快照、租户额外能力、到期时间------有效能力每次现算。
go
// app/alioth/internal/data/feature_repo.go
func (r *featureRepo) EffectiveCapabilities(ctx context.Context, namespaceUID string) ([]string, error) {
ns, err := r.client.Namespace.Query().Where(namespace.UIDEQ(namespaceUID)).Only(ctx)
...
if ns.DeletedAt != nil || ns.Status == namespace.StatusDISABLED {
return nil, nil // 停用即空,配置保留
}
if ns.PackageExpiresAt != nil && !ns.PackageExpiresAt.After(time.Now()) {
return nil, nil // 到期即空,不需要定时任务
}
packageFeatures, err := namespacePackageCapabilities(ctx, r.client, namespaceUID)
extra, err := namespaceExtraCapabilityCodes(ctx, r.client, namespaceUID)
return perm.EffectiveCapabilities(packageFeatures, extra), nil
}
并集的实现只有几行,但顺序有讲究------按能力目录顺序输出,保证同一组能力在任何地方序列化出来都一样,前端做 diff、测试做断言都不会被 map 遍历顺序坑到:
go
func EffectiveCapabilities(packageCapabilities, extraCapabilities []string) []string {
allowed := make(map[string]struct{}, len(packageCapabilities)+len(extraCapabilities))
...
codes := make([]string, 0, len(allowed))
for _, item := range capabilitySpecs { // 目录顺序
if _, ok := allowed[item.Code]; ok { codes = append(codes, item.Code) }
}
return codes
}
代价是每次鉴权都要多查两次 :access.Checker 里模块检查和能力检查各调一次。这是有意的取舍------正确性只依赖一个函数,而不是"数据写入时有没有漏更新"。停用、归档、到期这三种状态全部收敛到"返回空",没有一条异步清理路径需要维护。
四、套餐快照:改套餐不能追溯改租户
这是整套设计里最容易被忽略的一处。套餐是平台级实体,管理员随时会改它的能力明细。如果租户的权益直接读套餐当前明细,那么"给套餐加一个能力"就会静默地把已售出的所有租户升级;"删一个能力"则会让客户的生产环境突然 403。
Alioth 的做法是授权时快照:
go
// 换套餐时按新套餐当前明细授权并重新快照;同一个套餐只调整额外能力时,
// 必须沿用租户已有快照,不能让管理员后来修改套餐内容反向影响租户权益。
var packageFeatures []string
if ns.PackageUID == packageUID {
packageFeatures, err = namespacePackageCapabilities(ctx, tx.Client(), namespaceUID)
} else {
packageFeatures, err = packageSnapshotCapabilities(ctx, tx.Client(), packageUID)
}
快照存在租户上,与"额外能力"分开。套餐升级想推给存量租户,必须显式调用 SyncPackageToNamespaces------一次可审计的批量操作,而不是某次编辑的副作用。这和"改配置即时生效"的直觉相反,但对计费系统来说,可追溯比即时重要。
五、只增不减:租户不能自己降配
设置租户能力时,接口接收的是"目标能力全集",但服务端只接受包含了套餐全部能力的那种:
go
included := make(map[string]struct{}, len(packageFeatures))
for _, code := range packageFeatures {
included[code] = struct{}{}
if _, ok := target[code]; !ok {
return merr.Client("features must include the package features")
}
}
然后在一个事务里:删掉旧的全部额外能力记录,只为"目标里有、套餐里没有"的能力写新记录,最后更新 package_uid、package_features 快照和 package_expires_at。
为什么不允许减?因为"减"在语义上等于租户给自己降配------它绕过了计费,也让平台方无法解释"为什么这个租户付了钱却没有功能"。要降配,正确路径是换一个更小的套餐,那是一次有价格、有到期时间的商务动作。
历史包袱也被收敛在这一个函数里:M24 拆分前的能力编码(legacyCapabilityExpansion)先归一化成当前编码,再参与比较,否则"同义编码"会被误判成缺失的套餐能力。
六、三层叠起来之后的判定
第 02 篇讲过 access.require 的顺序,这里只强调套餐层的位置:
- 角色是否持有该权限(或平台管理员只读通道);
perm.FeatureOf(code)得到的模块是否在EffectiveModules里,否则返回feature not enabled;- 能力是否开通------
perm.PermissionCapabilities(code)可能返回多个能力(通知渠道的七个能力共享同一组notification_channel:*权限),只要其中一个开通即可; - 租户成员还要求持有覆盖该权限的
menu:<能力>。
第 3 步的"任一命中"不是妥协,而是模型决定的:一个权限可以挂在多个能力上,反过来不能要求"全部能力都开通"才放行,否则只买 SMTP 的租户连通知渠道列表都打不开。
七、几个真实的坑
- 别把模块检查删掉。 只查能力也能工作,但错误消息会从"功能未开通"退化成"没权限",值班的人分不清是商务问题还是配置问题。
- 成员上限跟着套餐算,别冗余。
NamespaceMemberLimit实时读套餐的max_members,租户表上不存副本。套餐改上限时不需要数据迁移,也不会出现两份真相。 - 旧授权要能保留。 能力被关闭后,角色上那份权限如果一律拒绝保存,前端把当前权限原样回传就会失败;所以授权侧只拒绝"新增的越界权限",不拒绝已有集合。
- 到期不需要定时任务,但需要提示。 计算层面"到期即空"很干净,可用户看到的是所有菜单突然消失;产品侧必须提前在到期前提醒,这是产品责任,不是权限系统的。
八、怎么验证
app/alioth/internal/data/feature_repo_test.go 覆盖了快照语义(改套餐不影响存量租户、显式同步才生效)、到期与停用的空能力、包外能力拒绝写入;app/alioth/pkg/perm/perm_test.go 钉住了归一化和模块反推的边界。跑起来看效果:
bash
git clone https://github.com/aide-family/moon.git && cd moon
make dev-up
open http://127.0.0.1:8080
用超级管理员账号建一个套餐(只勾 Kubernetes 读能力),再开一个租户选它,用租户成员登录,侧边栏只会出现 Kubernetes,Pod 终端和删除按钮都不可用------三层闸门会同时生效。
结语
套餐设计的核心不是"支持多少个功能",而是把变化节奏不同的三件事分开放 :菜单跟着产品走、权限跟着角色走、开通范围跟着商务走。Alioth 的答案是只存能力编码、有效能力现算、套餐快照授权、租户只能加不能减。四条规则都不复杂,但它们一起保证了同一件事:没有任何一次配置编辑,会静默改变一个已经付过钱的租户的权益。