客服主管的一次临时授权
大促前一周,客服主管接到一笔投诉:用户买错规格,要求按活动价改价。正常流程要走工单、运营审批、财务确认,但当时排队太久,主管找研发“临时”给客服角色加了一个 price:write 权限。研发在后台点了几下,十分钟后问题解决,大家继续忙大促。
三个月后,一次批量改价脚本用客服账号跑了一遍。脚本本身是运营写的,参数填错了一位小数,几千个 SKU 的价格被改成原价的十分之一。等财务发现时,订单已经出库,损失按案例假设值估算在几十万元量级。事后追查,这个 price:write 权限从来没有被回收,客服角色里甚至还有 campaign:* 和 order:*。
复盘会上暴露出三个问题。第一,改价权限没有有效期,临时授权变成了永久授权;第二,客服角色是从一个旧模板复制来的,模板里的权限没人逐条确认;第三,审计日志只写了“操作成功”,没有记录改前价格、改后价格、工单号和操作原因,无法还原是谁、为什么改。
这三个问题都不是“代码 bug”,而是后台权限模型缺失。后台权限和面向用户的权限看起来都是“能不能做某件事”,但它们的生命周期、影响面和审计要求完全不同。
后台权限与用户权限是两套体系
用户权限回答的是“这个用户能不能看自己的订单、能不能用这张券”。它通常跟着账号、会员等级或订单归属走,变更频繁但影响面局限在一个人。后台权限回答的是“这个员工能不能改全站价格、能不能下线一个活动”,一次误操作可能影响所有用户。两者共用一张角色表,迟早会出事。
| 维度 | 用户权限 | 后台权限 | 设计后果 |
|---|---|---|---|
| 授权主体 | C 端账号 | 内部员工与岗位 | 不能共用账号体系 |
| 授权来源 | 购买、领取、等级 | 角色、审批、工单 | 后台授权必须可追溯 |
| 变更频率 | 高频、跟随业务 | 低频、跟随组织 | 后台需要定期复核 |
| 影响面 | 单个用户 | 全量用户或大批商品 | 后台需要更严格的审批 |
| 审计要求 | 一般行为日志 | 改前改后、原因、工单 | 审计字段不能只记结果 |
| 回收方式 | 过期、退款、注销 | 转岗、离职、项目结束 | 必须有到期时间 |
最小权限不是一句口号,而是三个具体约束。第一,角色按岗位拆细,客服、运营、财务各自只拿完成工作所需的权限点,不能因为“以后可能用得上”就给通配。第二,通配只给明确需要跨模块操作的角色,例如平台运维的 order:*,并且要经过审批。第三,所有后台写操作都要记录操作者、时间、对象、改前值、改后值、原因和工单号。读操作可以采样,写操作不能省。
后台权限通常采用允许列表:没有明确授予就是拒绝。不要一开始就引入复杂的 deny 规则,因为 deny 与 allow 的优先级、角色叠加顺序和冲突解释会迅速变得难以推理。先把 allow 做对,再考虑少量显式的禁止场景。
定期复核同样重要。权限不是一次性配置,而是会随着组织变化漂移:项目结束、人员转岗、供应商合同到期,都可能留下不再需要的权限。复核要回答三个问题:这个角色现在还有谁在用、最近一次使用是什么时候、对应岗位是否还存在。超过一定时间没有使用的写权限,应该进入待回收列表,而不是继续默认保留。
高风险操作还需要双人复核。改价、下线活动、批量发券这类动作,单人点击就能影响大量用户,应该有第二个人确认,或者至少在执行前展示影响范围。双人复核不是不信任员工,而是让误操作在生效前多一道人类的检查。
失败场景:只有“操作成功”的审计日志
回到改价事故。审计表里有这样一条记录:操作人 cs-07,动作 update_price,结果 success。没有 SKU,没有改前改后,没有工单号。这条日志能证明“有人改过价格”,但不能回答“改了哪些商品、从多少改成多少、依据是什么”。
更麻烦的是 cs-07 是一个共享账号。三个人轮班用同一个账号处理客服工单,日志里的操作人无法对应到具体员工。权限回收时,研发看到账号还在用,也不敢停。共享账号加上无明细审计,等于把责任链彻底切断。
第二个变种是“权限校验只在前端”。后台页面根据角色隐藏了改价按钮,但接口本身没有校验。有人用旧版页面、脚本或直接调接口,依然能改价。前端隐藏只是体验优化,不是安全边界;真正的判断必须在服务端,而且要在写入之前。
第三个变种是“角色名重复”。两个团队各自建了一个叫 ops 的角色,权限集合不同。合并角色列表时,后一个覆盖前一个,或者两个都被加载,实际权限比任何一个人以为的都大。角色名是授权的主体标识,必须唯一;重复应该直接报错,而不是静默取并集。
第四个变种是“审计写入失败不阻塞业务”。研发担心审计表慢,把写审计做成异步、失败只打日志。结果审计表主键冲突、队列积压或字段超长时,业务操作成功,审计却丢了。写操作和审计应该尽量在同一个事务边界内完成,或者至少用可靠的 outbox 补偿,不能把“记录失败”当成可忽略的噪音。
第五个变种是“权限变更没有版本”。角色定义直接覆盖,历史请求只能看到当前权限,无法回答“事故发生那一刻这个角色有哪些权限”。角色定义应该保留版本或变更记录,审计里记录命中的角色版本,才能在事后复现判断过程。
用 Role、Validate 与 Allow 表达最小权限
Role 只描述“这个角色有哪些权限点”,不负责存储和审批。Validate 检查角色定义本身是否合法,适合在角色保存、发布和启动加载时调用。Allow 判断一次请求是否放行,任意角色命中即通过,并支持 order:* 这样的冒号通配前缀。
// file: internal/admin/permission.go
// tier: L1
// verify: go test ./internal/admin/
package admin
import (
"errors"
"fmt"
"strings"
)
var (
// ErrEmptyRoleName 表示角色名为空,无法在审计日志里定位授权主体。
ErrEmptyRoleName = errors.New("admin: empty role name")
// ErrDuplicateRole 表示同一个权限集合里出现了重复角色。
ErrDuplicateRole = errors.New("admin: duplicate role name")
// ErrEmptyPerm 表示权限字符串为空,空权限不能授予任何能力。
ErrEmptyPerm = errors.New("admin: empty permission")
)
// Role 是后台的一个角色。Name 是角色标识,Perms 是该角色拥有的权限点。
// 后台权限与面向用户的权限是两套体系:用户权限决定“能看什么”,
// 后台权限决定“能改什么”,两者不能共用同一个角色表。
type Role struct {
Name string
Perms []string
}
// Validate 检查角色定义本身是否合法。角色名不能为空白且不能重复;
// 每个权限字符串也不能为空白。它只校验定义,不判断某次请求是否放行。
func Validate(roles []Role) error {
seen := make(map[string]struct{}, len(roles))
for i, role := range roles {
name := strings.TrimSpace(role.Name)
if name == "" {
return fmt.Errorf("%w: index %d", ErrEmptyRoleName, i)
}
if _, ok := seen[name]; ok {
return fmt.Errorf("%w: %q", ErrDuplicateRole, name)
}
seen[name] = struct{}{}
for j, perm := range role.Perms {
if strings.TrimSpace(perm) == "" {
return fmt.Errorf("%w: role %q index %d", ErrEmptyPerm, name, j)
}
}
}
return nil
}
// Allow 判断这组角色是否拥有 perm。任意一个角色命中即通过。
// 权限点支持冒号结尾的通配前缀,例如 order:* 命中 order:read 和
// order:refund,但不命中 order 本身。空 perm 一律拒绝。
func Allow(roles []Role, perm string) bool {
if strings.TrimSpace(perm) == "" {
return false
}
for _, role := range roles {
for _, granted := range role.Perms {
if granted == perm {
return true
}
if strings.HasSuffix(granted, ":*") {
prefix := strings.TrimSuffix(granted, "*")
if strings.HasPrefix(perm, prefix) {
return true
}
}
}
}
return false
}
实现里有几个取舍:
- 角色名先
TrimSpace再判空和查重,避免只由空格组成的名字绕过校验。查重使用规范化后的名字,ops和ops被视为同一个角色。 - 权限字符串同样判空。空权限没有语义,放进去只会让审计表出现无法解释的授权记录。
Allow对空perm直接返回false。空权限不能被任何通配命中,否则order:*会意外放行一个没有名字的操作。- 通配只认
:*后缀。order:*的前缀是order:,因此命中order:read,但不命中order本身。这样order和order:*的语义不会混淆。 - 任意角色命中即通过,符合“多个岗位叠加”的现实。如果以后需要“必须同时具备两个权限”,应该新增显式的组合权限点,而不是让
Allow猜测。 Allow不负责审计。它只回答 yes/no;审计要在调用方记录决策输入、命中的角色和改前改后,两者职责分开。
这套实现假设角色列表已经在可信边界内完成校验。生产环境还应该在服务启动时调用一次 Validate,把脏数据挡在流量之外,而不是等某次请求才发现角色重复。
还有一个常见误区是把审计塞进 Allow:放行时顺手写一条日志。这样做的问题在于,Allow 会被读接口、健康检查、批量任务反复调用,审计表里会充满无关记录;真正需要审计的写操作,反而可能因为日志采样被丢掉。正确的分层是 Allow 只做判断,业务处理层在事务里记录改前改后,两者通过请求上下文传递操作者与工单号。
测试:重复角色、通配与空权限
测试覆盖四类问题:合法定义能否通过、空白与重复角色是否被挡住、通配边界是否正确、空权限是否绝对拒绝。
// file: internal/admin/permission_test.go
// tier: L1
// verify: go test ./internal/admin/
package admin
import (
"errors"
"testing"
)
func TestValidate(t *testing.T) {
ok := []Role{
{Name: "cs", Perms: []string{"order:read"}},
{Name: "ops", Perms: []string{"order:*", "campaign:write"}},
}
if err := Validate(ok); err != nil {
t.Fatalf("valid roles = %v, want nil", err)
}
if err := Validate([]Role{{Name: " ", Perms: []string{"order:read"}}}); !errors.Is(err, ErrEmptyRoleName) {
t.Fatalf("blank name = %v, want ErrEmptyRoleName", err)
}
if err := Validate([]Role{{Name: "cs"}, {Name: "cs"}}); !errors.Is(err, ErrDuplicateRole) {
t.Fatalf("duplicate role = %v, want ErrDuplicateRole", err)
}
if err := Validate([]Role{{Name: "cs", Perms: []string{"order:read", " "}}}); !errors.Is(err, ErrEmptyPerm) {
t.Fatalf("blank perm = %v, want ErrEmptyPerm", err)
}
}
func TestAllowExact(t *testing.T) {
roles := []Role{{Name: "cs", Perms: []string{"order:read", "refund:create"}}}
if !Allow(roles, "order:read") {
t.Fatal("order:read should be allowed")
}
if !Allow(roles, "refund:create") {
t.Fatal("refund:create should be allowed")
}
if Allow(roles, "order:refund") {
t.Fatal("order:refund should not be allowed")
}
}
func TestAllowWildcard(t *testing.T) {
roles := []Role{{Name: "ops", Perms: []string{"order:*"}}}
for _, perm := range []string{"order:read", "order:refund", "order:export"} {
if !Allow(roles, perm) {
t.Fatalf("%s should match order:*", perm)
}
}
if Allow(roles, "order") {
t.Fatal("order should not match order:*")
}
if Allow(roles, "campaign:write") {
t.Fatal("campaign:write should not match order:*")
}
}
func TestAllowAnyRole(t *testing.T) {
roles := []Role{
{Name: "cs", Perms: []string{"order:read"}},
{Name: "ops", Perms: []string{"order:*"}},
}
if !Allow(roles, "order:refund") {
t.Fatal("second role should grant order:refund")
}
if Allow(nil, "order:read") {
t.Fatal("nil roles should deny")
}
}
func TestAllowEmptyPerm(t *testing.T) {
roles := []Role{{Name: "ops", Perms: []string{"order:*"}}}
for _, perm := range []string{"", " "} {
if Allow(roles, perm) {
t.Fatalf("empty perm %q should be denied", perm)
}
}
}
TestValidate 覆盖合法角色、空白角色名、重复角色和空权限。TestAllowExact 验证精确匹配,同时确认没有授予的权限不会被放行。TestAllowWildcard 验证 order:* 命中三个具体权限,但不命中 order 和 campaign:write。TestAllowAnyRole 验证第二个角色可以授权,以及空角色列表默认拒绝。TestAllowEmptyPerm 验证空字符串和纯空格都被拒绝。
这些用例固定了三条语义:角色名必须唯一且非空、通配必须以冒号结尾、空权限永远拒绝。以后如果有人把 Allow 改成“空权限表示全部”,TestAllowEmptyPerm 会立即失败。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/admin/ -run 'TestValidate|TestAllow' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidate
--- PASS: TestValidate (0.00s)
=== RUN TestAllowExact
--- PASS: TestAllowExact (0.00s)
=== RUN TestAllowWildcard
--- PASS: TestAllowWildcard (0.00s)
=== RUN TestAllowAnyRole
--- PASS: TestAllowAnyRole (0.00s)
=== RUN TestAllowEmptyPerm
--- PASS: TestAllowEmptyPerm (0.00s)
PASS
ok webapp/internal/admin 0.243s
PASS 表示 5 个权限用例全部通过。它们保证的是单次授权判断的正确性;角色审批、定期复核、离职回收和审计留存属于后台治理流程,代码测试覆盖不到,但同样决定权限会不会变成永久后门。
阅读导航:上一节:目录与学习路径 · 下一节:12.2 运营操作的状态机 。
小结
后台权限与用户权限是两套体系,不能共用角色表。最小权限要求按岗位拆细、通配经过审批、写操作记录改前改后与原因。Role、Validate 和 Allow 把定义校验与请求判断分开,角色名唯一、权限非空、通配边界明确、空权限默认拒绝。
常见错误与边界
- 把后台角色和 C 端用户角色混用。两者的授权来源、生命周期和影响面不同,共用一张表会让权限回收失去依据。
- 用
order:*当默认权限。通配应该只给明确需要跨模块操作的角色,并且经过审批,否则一个模板复制就会放大权限。 - 只在前端隐藏按钮。接口没有服务端校验时,旧页面、脚本和直接调用都能绕过,前端隐藏不是安全边界。
- 审计只记录“操作成功”。没有对象、改前值、改后值、原因和工单号,故障后无法还原,也无法定责。
- 角色名重复或为空白。同名角色权限不同会让实际权限取决于加载顺序,空白名字让审计日志无法定位授权主体。
- 通配写成
order*或*。本实现只认:*后缀,order*会被当成普通权限字符串,*不会命中任何带冒号的权限。 - 临时权限没有到期时间。大促结束、项目结束、人员转岗时都没有触发回收,临时授权会永久化。
- 共享后台账号。多人共用一个账号时,审计日志只能定位到账号,不能定位到人,责任链在第一步就断了。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。