能力套餐:菜单、权限、开通范围怎么拆成三层而不打架

5 阅读8分钟

能力套餐:菜单、权限、开通范围怎么拆成三层而不打架

Alioth 技术笔记 · 第 03 篇 · 权限模型 / 商业化设计 仓库:github.com/aide-family… | 在线演示:web.alioth.asia/

导语

一个自建平台要变成能交付给多个租户的产品,绕不开三个问题:侧边栏该显示哪些入口、接口该不该放行、这个租户到底买了什么。最省事的做法是把三者合成一个"权限码",然后到处打补丁;稍微认真一点,会发现它们的变化节奏根本不同——菜单跟着产品迭代走,权限跟着角色走,套餐跟着商务走。Alioth 把它们拆成模块、能力、权限三层,并且只存能力编码,不存计算结果。这篇讲这套拆法,以及为什么"只增不减"和"套餐快照"是让三层不打架的两个关键决定。

一、先看失败的形态

Moon 的做法是:功能写在代码里,要么有,要么没有。想在版本 A 里卖告警、版本 B 里再卖 Kubernetes,只能靠分支或者环境变量。更麻烦的是"租户自己给自己加功能"——只要权限表可编辑,租户管理员建个角色勾上 cluster:access,就等于绕过了整条商务链路。

所以第三层的职责不是"权限的另一种写法",而是回答一个权限系统回答不了的问题:这个租户被允许使用哪些功能类别。它必须由平台侧控制,且必须能随时收回。

二、三层各管一件事

// app/alioth/pkg/perm/capability.go
//   - 模块(Feature)决定菜单分组,是租户差异与功能检查的语义单位;
//   - 能力(Capability)是套餐里勾选的单位,一个模块下可以有多个能力;
//   - 权限(Permission)挂在能力上,租户角色按能力持有的权限决定能调哪些接口。
//
// 套餐与租户差异都只存能力编码;模块由能力反推,不单独落表。

举一个具体的例子,Kubernetes 模块下有 k8s_pod_read、k8s_pod_logs、k8s_pod_exec、k8s_pod_delete 四个能力,每个能力对应一个接口权限。商务上"只买只读集群"和"买 Pod 终端"是两笔生意,能力层就能表达;而侧边栏只需要知道"Kubernetes 模块在不在",那是模块层的事。

模块不落表,由能力反推:

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 反过来:只存三份原料——套餐快照、租户额外能力、到期时间——有效能力每次现算。

// 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 遍历顺序坑到:

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 的做法是授权时快照:

// 换套餐时按新套餐当前明细授权并重新快照;同一个套餐只调整额外能力时,
// 必须沿用租户已有快照,不能让管理员后来修改套餐内容反向影响租户权益。
var packageFeatures []string
if ns.PackageUID == packageUID {
    packageFeatures, err = namespacePackageCapabilities(ctx, tx.Client(), namespaceUID)
} else {
    packageFeatures, err = packageSnapshotCapabilities(ctx, tx.Client(), packageUID)
}

快照存在租户上,与"额外能力"分开。套餐升级想推给存量租户,必须显式调用 SyncPackageToNamespaces——一次可审计的批量操作,而不是某次编辑的副作用。这和"改配置即时生效"的直觉相反,但对计费系统来说,可追溯比即时重要。

五、只增不减:租户不能自己降配

设置租户能力时,接口接收的是"目标能力全集",但服务端只接受包含了套餐全部能力的那种:

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 的顺序,这里只强调套餐层的位置:

  1. 角色是否持有该权限(或平台管理员只读通道);
  2. perm.FeatureOf(code) 得到的模块是否在 EffectiveModules 里,否则返回 feature not enabled;
  3. 能力是否开通——perm.PermissionCapabilities(code) 可能返回多个能力(通知渠道的七个能力共享同一组 notification_channel:* 权限),只要其中一个开通即可;
  4. 租户成员还要求持有覆盖该权限的 menu:<能力>。

第 3 步的"任一命中"不是妥协,而是模型决定的:一个权限可以挂在多个能力上,反过来不能要求"全部能力都开通"才放行,否则只买 SMTP 的租户连通知渠道列表都打不开。

七、几个真实的坑

  1. 别把模块检查删掉。 只查能力也能工作,但错误消息会从"功能未开通"退化成"没权限",值班的人分不清是商务问题还是配置问题。
  2. 成员上限跟着套餐算,别冗余。 NamespaceMemberLimit 实时读套餐的 max_members,租户表上不存副本。套餐改上限时不需要数据迁移,也不会出现两份真相。
  3. 旧授权要能保留。 能力被关闭后,角色上那份权限如果一律拒绝保存,前端把当前权限原样回传就会失败;所以授权侧只拒绝"新增的越界权限",不拒绝已有集合。
  4. 到期不需要定时任务,但需要提示。 计算层面"到期即空"很干净,可用户看到的是所有菜单突然消失;产品侧必须提前在到期前提醒,这是产品责任,不是权限系统的。

八、怎么验证

app/alioth/internal/data/feature_repo_test.go 覆盖了快照语义(改套餐不影响存量租户、显式同步才生效)、到期与停用的空能力、包外能力拒绝写入;app/alioth/pkg/perm/perm_test.go 钉住了归一化和模块反推的边界。跑起来看效果:

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 的答案是只存能力编码、有效能力现算、套餐快照授权、租户只能加不能减。四条规则都不复杂,但它们一起保证了同一件事:没有任何一次配置编辑,会静默改变一个已经付过钱的租户的权益。