《互联网产品的服务端架构·交易、增长与平台化》12.1 后台的权限与审计

客服主管为处理一笔投诉,给客服角色临时开了改价权限;三个月后权限仍在,一次批量改价把毛利打穿。文章区分后台权限与用户权限,说明最小权限、角色定义校验和写操作审计该怎么落地,并给出 Role、Allow、Validate 的 Go 实现与真实测试输出。

客服主管的一次临时授权

大促前一周,客服主管接到一笔投诉:用户买错规格,要求按活动价改价。正常流程要走工单、运营审批、财务确认,但当时排队太久,主管找研发“临时”给客服角色加了一个 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* 会被当成普通权限字符串,* 不会命中任何带冒号的权限。
  • 临时权限没有到期时间。大促结束、项目结束、人员转岗时都没有触发回收,临时授权会永久化。
  • 共享后台账号。多人共用一个账号时,审计日志只能定位到账号,不能定位到人,责任链在第一步就断了。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查