一次没有按钮的越权
《群岛纪元》的客服后台在一次版本里加了“补发礼包”按钮,按钮只对运营角色可见。两周后,一名客服通过抓包直接调用了按钮背后的接口,给同一个账号补发了十七次。事后复盘发现,服务端只检查了“是否登录”,没有检查“这个动作是否被允许”;按钮的可见性被当成了权限。
这类事故的共同点不是某个人恶意,而是系统把“界面约束”当成了“服务端约束”。菜单可以隐藏,接口不会消失;前端可以校验,请求仍然可以伪造。本章讨论的权限、审批、幂等、补偿和数据修复,都是同一个问题的不同侧面:高权限操作怎样在一个人犯错、两个人串通、或者一次重试的情况下仍然不闯祸。这一节只谈操作本身的安全边界。账号安全、登录凭据和风控策略分别属于卷二与第 4 章;审计日志的留存与取证在卷三已经讨论过,这里只引用结论:动作必须留下可关联到人、工单和目标的记录。
角色是职责,动作是边界
角色模型的第一条规则是:角色描述“这个人负责什么”,动作描述“这次请求改变了什么”。二者不能合并成一张菜单表。同一个“玩家详情”页面里可能同时有查看资料、查看敏感字段、发放道具三个动作;同一个“发放道具”动作也可能从客服工具、GM 指令、补偿脚本三个入口进入。
| 角色 | 查看资料 | 敏感字段 | 发放道具 | 扣减道具 | 封禁/解封 | 导出数据 | 查看审计 |
|---|---|---|---|---|---|---|---|
| 客服 | 允许 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 |
| 运营 | 允许 | 允许 | 允许 | 拒绝 | 拒绝 | 拒绝 | 拒绝 |
| GM | 允许 | 允许 | 允许 | 允许 | 允许 | 拒绝 | 拒绝 |
| 管理员 | 允许 | 允许 | 允许 | 允许 | 允许 | 允许 | 允许 |
| 只读审计 | 允许 | 允许 | 拒绝 | 拒绝 | 拒绝 | 允许 | 允许 |
这张表是示例值,实际取值应由工作室自己评审。几个设计取舍值得说明。第一,只读审计可以导出数据,但不能改数据。导出看起来是“读”,实际上会把大量个人信息带出系统,所以它仍然需要双人复核;只读角色拥有导出权,不等于可以随手导出。
第二,客服默认看不到敏感字段。客服需要判断问题,但判断不一定需要完整设备号、支付流水或聊天原文;脱敏视图能满足大多数工单,确实需要原文时再走临时授权。
第三,角色名拼错必须 fail closed。配置中心里出现一个不认识的 super_gm,正确行为是拒绝请求并报警,而不是“找不到角色就当作普通用户放行”。
双人复核:把“两个人”写进策略
不是所有动作都值得双人复核。每多一步,操作延迟就多一截,紧急故障时更容易被绕过。判断标准是“一次误操作的后果能否在当天挽回”,而不是“这个动作听起来重不重要”。
| 动作 | 是否双人 | 原因 | 复核人最低要求 |
|---|---|---|---|
| 发放道具 | 是 | 可直接制造资产 | 持有发放权 |
| 扣减道具 | 是 | 可损害玩家资产 | 持有扣减权 |
| 封禁/解封 | 是 | 影响玩家能否登录 | 持有封禁权 |
| 补偿发放 | 是 | 涉及经济与客诉 | 持有补偿权 |
| 导出数据 | 是 | 数据离开受控环境 | 持有导出权 |
“双人”不是“两个账号”。同一主管下的两名运营,绩效、排班和晋升都绑在同一条线上,复核很容易退化成互相签字。默认策略应当拒绝同一主管的复核;如果业务确实需要,把它写成显式例外,而不是默默允许。紧急通道要留痕,而不是留后门。紧急复核可以放宽“同一主管”限制,但不能放宽身份分离:发起人仍然不能复核自己,紧急理由必须非空,工单号必须存在,事后在约定时限内补一次常规复核(示例值:24 小时)。代码里把 Emergency 和 Reason 一起存进审批记录,就是为了让“当时为什么急”在事后仍然可查。
还要区分“审批流”和“复核”。审批流回答“这张工单该不该做”,通常发生在执行之前;复核回答“这次请求是不是由另一个人确认过”,应当发生在执行入口。只在工单系统里点过“同意”,不代表执行时的请求一定带着那个人的确认。两者都做,才能覆盖“审批之后参数被改”的情况。
临时授权与即时失效
最小权限不是让每个人都缺权限,而是让权限在需要时出现、在不需要时消失。临时授权要满足四个条件:有明确动作、有到期时间、有批准人、有工单号。缺一个条件,授权就无法解释。
时限要有上限。代码里的 MaxGrantWindow 取 24 小时(示例值),超过上限的请求不是“长期授权”,而是“绕过角色变更评审的永久提权”,应当拒绝并转成正式角色申请。
到期回收要发生在检查时,而不是只靠定时任务。定时任务可能延迟、可能失败,授权检查必须在每次请求时比较当前时间;定时清理只负责让记录变小。这样即使清理任务停摆,过期授权也不会继续生效。离职和转岗是更容易被忽略的路径。人走了,角色还在;人转岗了,旧团队的角色还在。正确做法是把身份系统的事件接到权限系统:离职立即禁用主体、撤销会话、让权限缓存失效;转岗先回收旧角色,再按新岗位重新授予。缓存 TTL 只能缩短窗口,不能替代事件驱动(示例值:5 分钟)。
代码:动作级判定与复核策略
internal/gmrbac 把角色表、动作表、临时授权和复核策略放在同一个包里,但分成两个文件。先看 rbac.go:角色只决定基础动作集合,临时授权单独叠加,过期状态返回独立错误,方便界面提示“续期”而不是笼统的“拒绝”。
// file: internal/gmrbac/rbac.go
// tier: L1
// verify: go test ./internal/gmrbac/
package gmrbac
import (
"errors"
"time"
)
// Role is a duty, not a menu. Hiding a button only changes what an honest
// operator can click; the action check is what stops a crafted request.
type Role string
const (
RoleCS Role = "cs"
RoleOps Role = "ops"
RoleGM Role = "gm"
RoleAdmin Role = "admin"
RoleAuditor Role = "auditor"
)
// Action names an effect on game state, never a screen. One screen can issue
// several actions, and one action can be reached from several screens.
type Action string
const (
ActionViewProfile Action = "player.view"
ActionViewSensitive Action = "player.view_sensitive"
ActionGrantItem Action = "inventory.grant"
ActionDeductItem Action = "inventory.deduct"
ActionBanAccount Action = "account.ban"
ActionUnbanAccount Action = "account.unban"
ActionCompensate Action = "compensation.issue"
ActionExportData Action = "data.export"
ActionViewAudit Action = "audit.view"
)
// MaxGrantWindow caps every temporary elevation. A longer window is not a
// grant any more; it is a permanent role change that skipped review.
const MaxGrantWindow = 24 * time.Hour
var (
ErrUnknownRole = errors.New("gmrbac: unknown role")
ErrDenied = errors.New("gmrbac: action denied")
ErrGrantExpired = errors.New("gmrbac: temporary grant expired")
ErrSelfReview = errors.New("gmrbac: initiator cannot review own action")
ErrReviewerDenied = errors.New("gmrbac: reviewer lacks action permission")
ErrSameManager = errors.New("gmrbac: reviewer shares initiator manager")
ErrNoReview = errors.New("gmrbac: dual review required")
ErrEmergencyNote = errors.New("gmrbac: emergency approval needs reason")
ErrBadGrant = errors.New("gmrbac: invalid grant")
ErrGrantTooLong = errors.New("gmrbac: grant exceeds max window")
ErrSameApprover = errors.New("gmrbac: grant approver must differ")
ErrDuplicateReview = errors.New("gmrbac: duplicate reviewer")
)
var roleActions = map[Role]map[Action]bool{
RoleCS: {
ActionViewProfile: true,
},
RoleOps: {
ActionViewProfile: true,
ActionViewSensitive: true,
ActionGrantItem: true,
ActionCompensate: true,
},
RoleGM: {
ActionViewProfile: true,
ActionViewSensitive: true,
ActionGrantItem: true,
ActionDeductItem: true,
ActionBanAccount: true,
ActionUnbanAccount: true,
ActionCompensate: true,
},
RoleAdmin: {
ActionViewProfile: true,
ActionViewSensitive: true,
ActionGrantItem: true,
ActionDeductItem: true,
ActionBanAccount: true,
ActionUnbanAccount: true,
ActionCompensate: true,
ActionExportData: true,
ActionViewAudit: true,
},
RoleAuditor: {
ActionViewProfile: true,
ActionViewSensitive: true,
ActionExportData: true,
ActionViewAudit: true,
},
}
var allActions = map[Action]bool{
ActionViewProfile: true,
ActionViewSensitive: true,
ActionGrantItem: true,
ActionDeductItem: true,
ActionBanAccount: true,
ActionUnbanAccount: true,
ActionCompensate: true,
ActionExportData: true,
ActionViewAudit: true,
}
// Grant is a time-boxed elevation. ApprovedBy and Ticket make the grant
// explainable after it expires, when the operator has already moved on.
type Grant struct {
Action Action
ExpiresAt time.Time
ApprovedBy string
Ticket string
}
// Principal is one authenticated person. Roles are long-lived; Grants expire.
type Principal struct {
ID string
Roles []Role
ManagerID string
Grants []Grant
Disabled bool
}
// Known reports whether the action is part of the policy vocabulary.
func (a Action) Known() bool { return allActions[a] }
// Known reports whether the role is part of the policy vocabulary.
func (r Role) Known() bool { _, ok := roleActions[r]; return ok }
// Authorize evaluates long-lived roles first and temporary grants second.
// An expired grant is reported distinctly so the UI can say "renew" instead
// of a misleading "denied".
func (p Principal) Authorize(action Action, now time.Time) error {
if p.Disabled {
return ErrDenied
}
if !action.Known() {
return ErrDenied
}
expired := false
for _, r := range p.Roles {
if !r.Known() {
return ErrUnknownRole
}
if roleActions[r][action] {
return nil
}
}
for _, g := range p.Grants {
if g.Action != action {
continue
}
if now.Before(g.ExpiresAt) {
return nil
}
expired = true
}
if expired {
return ErrGrantExpired
}
return ErrDenied
}
// AddGrant attaches a temporary elevation. The approver must already hold the
// same action, must be a different person, and the window is capped. A grant
// without a ticket is rejected because nobody can reconstruct why it existed.
func (p *Principal) AddGrant(g Grant, approver Principal, now time.Time) error {
if p.Disabled || p.ID == "" || approver.Disabled || approver.ID == "" {
return ErrBadGrant
}
if !g.Action.Known() || g.Ticket == "" || g.ApprovedBy == "" {
return ErrBadGrant
}
if approver.ID == p.ID {
return ErrSameApprover
}
window := g.ExpiresAt.Sub(now)
if window <= 0 {
return ErrBadGrant
}
if window > MaxGrantWindow {
return ErrGrantTooLong
}
if err := approver.Authorize(g.Action, now); err != nil {
return ErrReviewerDenied
}
p.Grants = append(p.Grants, g)
return nil
}
// PruneGrants removes expired entries and returns how many were dropped.
// Expiry is checked at authorization time anyway; pruning only keeps the
// principal record small and the review screen honest.
func (p *Principal) PruneGrants(now time.Time) int {
kept := p.Grants[:0]
for _, g := range p.Grants {
if now.Before(g.ExpiresAt) {
kept = append(kept, g)
}
}
dropped := len(p.Grants) - len(kept)
p.Grants = kept
return dropped
}
复核策略在 review.go。它不关心工单怎么流转,只回答一个问题:这组审批人是否能为一个动作提供合格的第二双眼睛。自审、重复复核人、无权限复核人、同主管和紧急无理由都在这里被拒绝。
// file: internal/gmrbac/review.go
// tier: L1
// verify: go test ./internal/gmrbac/
package gmrbac
import (
"sort"
"time"
)
// dualReview lists actions whose blast radius is too large for one person.
// A temporary grant is deliberately absent: elevation itself needs an
// approver, and the granted action still follows this table.
var dualReview = map[Action]bool{
ActionGrantItem: true,
ActionDeductItem: true,
ActionBanAccount: true,
ActionUnbanAccount: true,
ActionCompensate: true,
ActionExportData: true,
}
// Approval is one reviewer's decision. Emergency is not a free pass: it still
// needs a reason and it is stored on the command record.
type Approval struct {
Reviewer Principal
At time.Time
Emergency bool
Reason string
}
// ReviewPolicy holds the exceptions a studio is willing to write down.
type ReviewPolicy struct {
AllowSameManager bool
}
// RequiresDualReview reports whether the action needs a second person.
func RequiresDualReview(action Action) bool { return dualReview[action] }
// CheckReview validates the approval set for one action. It rejects self
// review, reviewers without the action, duplicate reviewers, same-manager
// pairs by default, and emergency approvals without a reason. Emergency mode
// relaxes only the same-manager rule; it never relaxes identity separation.
func CheckReview(initiator Principal, action Action, approvals []Approval, policy ReviewPolicy, now time.Time) error {
if !RequiresDualReview(action) {
return nil
}
seen := map[string]bool{}
valid := 0
for _, a := range approvals {
if a.Reviewer.ID == "" || a.Reviewer.ID == initiator.ID {
return ErrSelfReview
}
if seen[a.Reviewer.ID] {
return ErrDuplicateReview
}
if err := a.Reviewer.Authorize(action, now); err != nil {
return ErrReviewerDenied
}
sameManager := initiator.ManagerID != "" && a.Reviewer.ManagerID == initiator.ManagerID
if sameManager && !policy.AllowSameManager && !a.Emergency {
return ErrSameManager
}
if a.Emergency && a.Reason == "" {
return ErrEmergencyNote
}
seen[a.Reviewer.ID] = true
valid++
}
if valid == 0 {
return ErrNoReview
}
return nil
}
// Actors returns every principal ID referenced by a review set, sorted, so an
// audit record does not depend on the order the UI sent approvals in.
func Actors(approvals []Approval) []string {
ids := make([]string, 0, len(approvals))
for _, a := range approvals {
ids = append(ids, a.Reviewer.ID)
}
sort.Strings(ids)
return ids
}
失败路径与检查表
权限系统的失败通常不是“所有人都进不来”,而是“某个入口忘了检查”。下面几条路径值得在设计和演练时逐条对照。
| 失败路径 | 典型表现 | 处理 |
|---|---|---|
| 绕过菜单直接调接口 | 按钮不可见但接口可用 | 所有入口调用同一动作检查 |
| 角色名拼写错误 | 新角色被当成未知或默认放行 | 未知角色 fail closed 并告警 |
| 复核人与发起人同主管 | 两人互相签字 | 默认拒绝,例外需显式策略 |
| 紧急通道没有理由 | 事后无法解释 | 理由与工单号必填 |
| 临时授权到期仍可用 | 清理任务延迟 | 每次检查比较当前时间 |
| 离职账号仍有角色 | 旧会话继续操作 | 身份事件驱动撤销与缓存失效 |
上线前至少确认六件事:
- 每个写动作都有独立的动作名,没有“万能管理接口”。
- 角色到动作的映射有唯一来源,代码和配置不会各写一份。
- 双人复核的动作清单经过业务、财务和安全共同评审。
- 紧急通道有理由、工单号、事后补审时限和报表。
- 临时授权有上限、有批准人、有到期时间,且检查时生效。
- 离职、转岗、角色变更都能触发会话撤销和权限缓存失效。
小结
权限设计最容易犯的错,是把“谁能看到按钮”当成“谁能执行动作”。菜单是体验,动作才是边界;角色是职责,授权才是时效。把动作判定收到一个入口,把双人复核写成可执行的策略,把临时授权做成会过期的记录,高权限后台才有可能在一个人犯错时仍然安全。下一节讨论另一个维度:即使权限和复核都正确,同一条命令仍可能因为重复点击、超时重试或跨服转发而执行多次。幂等键和执行账本要解决的问题,是“同一个意图只能生效一次”。
阅读导航:上一节:2.3 活动状态、回滚与审计 · 下一节:3.2 GM 命令的幂等与补偿 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。