《游戏服务端编程实践》16.1 GM 权限与命令系统

从 RBAC 权限模型出发,说明 GM 命令的注册、参数解析、权限校验、危险等级与二次确认流程,并用哈希链审计回答谁在何时对谁做了什么;附可运行的 gm.go 以及越权、缺确认、参数错误与审计完整性测试,最后讨论上线边界、审计保留与回滚思路。

一、GM 系统的第一道门是权限

GM 命令看起来只是运营工具,实际却直接改写玩家资产、封禁账号、发放道具,一次误操作就可能变成一次线上事故。因此 GM 系统的设计顺序应当是先权限、再命令、最后才是界面,而不是先做一个能点的后台再补权限。

一个常见的反例是权限只在前端判断:按钮灰掉就认为安全,接口却可以被任意构造的请求直接调用。前端权限只是交互提示,真正的授权必须发生在服务端每一次执行之前,并且与命令、目标、参数一起进入审计。

1.1 三条硬约束

  • 最小权限:每个角色只拿到完成工作所需的最小权限集合,查询与写操作分开授权。
  • 默认拒绝:没有显式授予的权限一律拒绝,未知命令、未知角色、空身份都走拒绝分支。
  • 授权可回收:权限变更有明确入口,离职、转岗或临时活动结束后能立即收回。
原则推荐做法省略后的典型后果
最小权限查询、单服操作、全服操作分成不同权限客服账号可以误发全服邮件
默认拒绝先检查权限,再解析参数和执行新命令上线时忘记加校验直接裸奔
授权可回收角色绑定有到期时间,支持一键摘除临时账号长期保留高危权限

二、RBAC:角色、权限、操作

RBAC 把授权拆成三层:角色绑定权限,身份绑定角色,命令声明自己需要的权限。执行时只回答一个问题:当前身份通过角色并集,是否拥有该命令要求的权限。这样新增命令只需要声明权限名,新增人员只需要调整角色,不需要在业务代码里堆 if user == "admin"。

对象示例存储位置注意事项
身份运营账号、客服账号、值班账号账号系统不使用共享账号,离职即禁用
角色客服、活动运营、数值策划、管理员权限服务角色数量保持可读,避免一人一角色
权限player.ban、mail.send、server.notice权限表权限名与命令名解耦,便于迁移

2.1 临时授权与双人复核

临时授权应当带到期时间,并由系统自动回收,而不是靠人工记得删除。双人复核则把高危操作拆成“发起”和“批准”两个动作:发起人提交目标与参数,批准人看到完整上下文后确认,执行时使用一次性的批准令牌。

  • 临时授权记录授权人、被授权人、权限、生效时间与到期时间。
  • 高危操作要求不同账号发起与批准,禁止自批自审。

下面的示例代码聚焦命令注册、权限校验、危险命令确认和审计链,没有实现完整的审批流。生产系统可以在 Execute 之前增加审批状态检查,把批准令牌作为 Confirm 的一部分传入。

三、命令系统的执行流水线

一条 GM 命令从请求到回执应当经过固定阶段,每个阶段都有明确的失败分支。流水线化的好处是审计字段完整、错误可分类、测试可以逐段覆盖,而不是把解析、校验和执行揉在一个大函数里。

阶段检查内容失败处理是否写审计
解析命令名、目标、参数是否可解析返回参数错误是
查找命令是否已注册返回未知命令是
鉴权身份角色是否拥有命令权限返回拒绝是
校验必填参数、范围、跨字段约束返回参数错误是
确认高危命令是否带正确确认串返回需要确认是
执行调用 Handler,处理返回与异常返回执行错误是

参数校验必须在执行前完成。封禁命令如果只检查“有没有 reason”,却不检查时长范围,就可能出现封禁一万年的误操作;发邮件命令如果不限制附件数量,就可能把单次请求放大成一次存储事故。

3.1 危险等级与二次确认

等级典型命令要求
低查询在线人数、查看玩家背包权限校验即可
中单玩家封禁、单玩家补偿权限校验,记录完整参数
高全服邮件、批量封禁、经济参数调整权限校验、二次确认、双人复核

二次确认不能只是一个“确定吗”的前端弹窗。确认串应当与命令和目标绑定,最好由服务端生成、短有效期、一次性使用;否则调用方可以预先拼出确认串,批量调用高危命令。示例用命令名和目标的摘要作为确认串,目的是让测试可重复,生产环境应替换为随机令牌。

四、审计:谁在何时对谁做了什么

审计日志至少要回答五个问题:谁发起、什么时候、对谁、做了什么、结果如何。字段缺失会让事故复盘变成猜谜,尤其是“参数是什么”和“为什么被拒绝”这两类信息,往往比最终成功与否更重要。

字段含义示例
Seq单调递增序号1024
At服务端接收时间2026-11-04T10:36:40+08:00
Operator操作者身份gm-zhangsan
Command命令名player.ban
Target操作目标player-8821
Args完整参数副本reason=cheat
Confirmed是否完成二次确认true
Outcome成功或失败ok / error
Message输出或错误原因banned player-8821

不可篡改不等于“只写不删”。示例给每条记录计算包含前一条哈希的 SHA-256 链,任何中间记录被修改或删除,后续校验都会失败。真实系统还应把审计同步到独立日志平台或对象存储,并设置只追加权限和保留周期。

4.1 失败场景:权限只在前端校验

某后台把“全服邮件”按钮按角色隐藏,接口却没有做服务端鉴权。一次内部压测脚本误调了接口,使用普通客服账号向全服发送了带附件的邮件,等运营发现时邮件已经进入玩家邮箱。

时间动作后果
T+0s脚本携带客服账号调用发送接口服务端未校验权限,请求进入业务层
T+2s邮件任务开始批量投递全服玩家收到错误附件
T+30s运营发现异常并停任务已投递部分无法撤回
T+1h复盘发现接口无鉴权、无审计无法定位调用来源

正确的做法是服务端对每个命令做权限校验,高危命令要求二次确认,批量投递进入审批与限额流程,并把请求来源、参数和结果写入审计。前端隐藏按钮只能减少误点,不能承担安全职责。

五、可运行实现:gm.go

示例把命令注册、角色授权、参数校验、二次确认和审计链放在一个包里。Execute 的所有返回路径都会调用 finish,因此成功、越权、缺确认、参数错误和未知命令都会留下记录;审计记录返回副本,调用方修改不会破坏内部链。

// file: ch16/gm/gm.go
// tier: L1
// verify: go test ./ch16/gm/

package gm

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"sync"
	"time"
)

var (
	ErrUnknownCommand       = errors.New("gm: unknown command")
	ErrPermissionDenied     = errors.New("gm: permission denied")
	ErrConfirmationRequired = errors.New("gm: confirmation required")
	ErrInvalidArgument      = errors.New("gm: invalid argument")
)

type RiskLevel int

const (
	RiskLow RiskLevel = iota
	RiskMedium
	RiskHigh
)

type Principal struct {
	ID    string
	Roles []string
}

type Request struct {
	Operator *Principal
	Command  string
	Target   string
	Args     map[string]string
	Confirm  string
}

type Command struct {
	Name       string
	Permission string
	Risk       RiskLevel
	Required   []string
	Handler    func(Request) (string, error)
}

type AuditRecord struct {
	Seq       uint64
	At        time.Time
	Operator  string
	Command   string
	Target    string
	Args      map[string]string
	Confirmed bool
	Outcome   string
	Message   string
	PrevHash  string
	Hash      string
}

type Registry struct {
	mu       sync.RWMutex
	commands map[string]*Command
	grants   map[string]map[string]struct{}
	audit    []AuditRecord
	now      func() time.Time
}

func NewRegistry() *Registry { return NewRegistryWithClock(time.Now) }

func NewRegistryWithClock(now func() time.Time) *Registry {
	if now == nil {
		now = time.Now
	}
	return &Registry{
		commands: map[string]*Command{},
		grants:   map[string]map[string]struct{}{},
		now:      now,
	}
}

func (r *Registry) Register(cmd Command) error {
	if cmd.Name == "" || cmd.Permission == "" || cmd.Handler == nil {
		return fmt.Errorf("gm: invalid command %q", cmd.Name)
	}
	cmd.Required = append([]string(nil), cmd.Required...)
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.commands[cmd.Name]; ok {
		return fmt.Errorf("gm: command %q already registered", cmd.Name)
	}
	r.commands[cmd.Name] = &cmd
	return nil
}

func (r *Registry) Grant(role string, perms ...string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	set := r.grants[role]
	if set == nil {
		set = map[string]struct{}{}
		r.grants[role] = set
	}
	for _, perm := range perms {
		set[perm] = struct{}{}
	}
}

func ConfirmationToken(command, target string) string {
	sum := sha256.Sum256([]byte(command + "\x00" + target))
	return "confirm-" + hex.EncodeToString(sum[:8])
}

func (r *Registry) Execute(req Request) (string, error) {
	started := r.now()
	r.mu.RLock()
	cmd, known := r.commands[req.Command]
	allowed := known && req.Operator != nil
	if allowed {
		allowed = false
		for _, role := range req.Operator.Roles {
			if _, ok := r.grants[role][cmd.Permission]; ok {
				allowed = true
				break
			}
		}
	}
	r.mu.RUnlock()
	if !known {
		return r.finish(req, nil, started, "", ErrUnknownCommand)
	}
	if !allowed {
		return r.finish(req, cmd, started, "", ErrPermissionDenied)
	}
	for _, name := range cmd.Required {
		if strings.TrimSpace(req.Args[name]) == "" {
			return r.finish(req, cmd, started, "",
				fmt.Errorf("%w: missing %q", ErrInvalidArgument, name))
		}
	}
	if cmd.Risk >= RiskHigh && req.Confirm != ConfirmationToken(cmd.Name, req.Target) {
		return r.finish(req, cmd, started, "", ErrConfirmationRequired)
	}
	out, err := cmd.Handler(req)
	return r.finish(req, cmd, started, out, err)
}

func (r *Registry) finish(req Request, cmd *Command, at time.Time, output string, err error) (string, error) {
	rec := AuditRecord{
		At:      at,
		Command: req.Command,
		Target:  req.Target,
		Args:    cloneArgs(req.Args),
		Outcome: "ok",
		Message: output,
	}
	if req.Operator != nil {
		rec.Operator = req.Operator.ID
		rec.Confirmed = cmd != nil && cmd.Risk >= RiskHigh &&
			req.Confirm == ConfirmationToken(cmd.Name, req.Target)
	}
	if err != nil {
		rec.Outcome = "error"
		rec.Message = err.Error()
	}

	r.mu.Lock()
	rec.Seq = uint64(len(r.audit) + 1)
	if n := len(r.audit); n > 0 {
		rec.PrevHash = r.audit[n-1].Hash
	}
	rec.Hash = hashRecord(rec)
	r.audit = append(r.audit, rec)
	r.mu.Unlock()
	return output, err
}

func hashRecord(rec AuditRecord) string {
	rec.Hash = ""
	body, _ := json.Marshal(rec)
	sum := sha256.Sum256(body)
	return hex.EncodeToString(sum[:])
}

func (r *Registry) AuditLog() []AuditRecord {
	r.mu.RLock()
	defer r.mu.RUnlock()
	out := make([]AuditRecord, len(r.audit))
	for i, rec := range r.audit {
		rec.Args = cloneArgs(rec.Args)
		out[i] = rec
	}
	return out
}

func (r *Registry) AuditByOperator(id string) []AuditRecord {
	out := make([]AuditRecord, 0)
	for _, rec := range r.AuditLog() {
		if rec.Operator == id {
			out = append(out, rec)
		}
	}
	return out
}

func (r *Registry) VerifyAudit() error {
	r.mu.RLock()
	defer r.mu.RUnlock()
	prev := ""
	for i, rec := range r.audit {
		if rec.Seq != uint64(i+1) || rec.PrevHash != prev || rec.Hash != hashRecord(rec) {
			return fmt.Errorf("gm: audit chain broken at record %d", rec.Seq)
		}
		prev = rec.Hash
	}
	return nil
}

func cloneArgs(in map[string]string) map[string]string {
	out := make(map[string]string, len(in))
	for k, v := range in {
		out[k] = v
	}
	return out
}

测试覆盖越权拒绝、缺确认拒绝、参数缺失、未知命令、审计字段完整性、哈希链篡改检测以及审计副本隔离,共 8 个测试。运行方式:

cd .local/checks/gs-practice-lab
gofmt -l ch16/gm
go vet ./ch16/gm/
go test ./ch16/gm/ -count=1

5.1 实现边界

示例的确认串是确定性摘要,只适合演示;生产环境应换成服务端生成的一次性随机令牌,并校验有效期、审批单号和使用次数。审计链只能发现单进程内记录的静默修改,如果整份日志可被重写,仍需要独立只追加存储或签名服务兜底。内存审计也不适合长期保留,应异步写入独立存储,并监控写入失败、链校验失败和积压;高危命令在审计写失败时应默认拒绝执行。

六、和 16.2 的衔接

权限和审计解决“谁能改、改了什么”,但命令背后的参数仍然散落在配置文件和代码常量里。下一篇 /game-server-in-action-16-2/ 会把配置抽成带版本号的快照,讨论热更、回滚、订阅通知和上线前校验。

小结

GM 系统的安全边界在服务端:RBAC 决定谁能执行,命令流水线决定按什么顺序检查,二次确认拦住高危误操作,审计链回答事后追责。把这些能力做成统一入口,而不是散落在每个命令里,才能让新增命令默认继承安全约束。

常见错误与边界

  • 只在后台页面隐藏按钮,接口没有服务端鉴权。
  • 审计只记录成功操作,越权和拒绝反而没有留下证据。
  • 审计记录返回内部切片或 map,调用方修改后污染哈希链。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist