《游戏服务端架构·平台化与架构治理》3.1 权限、审批与双人复核

《群岛纪元》的客服后台加了一个补发按钮,两周后有人绕过按钮直接调用接口。本文把权限从菜单拆到动作,讨论双人复核的资格、同主管例外与紧急通道留痕,拆解临时授权的时限、到期回收和离职转岗时的即时失效,并附 internal/gmrbac 的动作级 RBAC 与复核策略实现。

一次没有按钮的越权

《群岛纪元》的客服后台在一次版本里加了“补发礼包”按钮,按钮只对运营角色可见。两周后,一名客服通过抓包直接调用了按钮背后的接口,给同一个账号补发了十七次。事后复盘发现,服务端只检查了“是否登录”,没有检查“这个动作是否被允许”;按钮的可见性被当成了权限。

这类事故的共同点不是某个人恶意,而是系统把“界面约束”当成了“服务端约束”。菜单可以隐藏,接口不会消失;前端可以校验,请求仍然可以伪造。本章讨论的权限、审批、幂等、补偿和数据修复,都是同一个问题的不同侧面:高权限操作怎样在一个人犯错、两个人串通、或者一次重试的情况下仍然不闯祸。这一节只谈操作本身的安全边界。账号安全、登录凭据和风控策略分别属于卷二与第 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 并告警
复核人与发起人同主管两人互相签字默认拒绝,例外需显式策略
紧急通道没有理由事后无法解释理由与工单号必填
临时授权到期仍可用清理任务延迟每次检查比较当前时间
离职账号仍有角色旧会话继续操作身份事件驱动撤销与缓存失效

上线前至少确认六件事:

  1. 每个写动作都有独立的动作名,没有“万能管理接口”。
  2. 角色到动作的映射有唯一来源,代码和配置不会各写一份。
  3. 双人复核的动作清单经过业务、财务和安全共同评审。
  4. 紧急通道有理由、工单号、事后补审时限和报表。
  5. 临时授权有上限、有批准人、有到期时间,且检查时生效。
  6. 离职、转岗、角色变更都能触发会话撤销和权限缓存失效。

小结

权限设计最容易犯的错,是把“谁能看到按钮”当成“谁能执行动作”。菜单是体验,动作才是边界;角色是职责,授权才是时效。把动作判定收到一个入口,把双人复核写成可执行的策略,把临时授权做成会过期的记录,高权限后台才有可能在一个人犯错时仍然安全。下一节讨论另一个维度:即使权限和复核都正确,同一条命令仍可能因为重复点击、超时重试或跨服转发而执行多次。幂等键和执行账本要解决的问题,是“同一个意图只能生效一次”。

阅读导航:上一节:2.3 活动状态、回滚与审计 · 下一节:3.2 GM 命令的幂等与补偿 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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