《MMO 服务器开发》7.1 权限与审计

GM 后台是运营手里最锋利也最危险的一把刀:一次误操作就能给全服发错奖励,或者抹掉一个玩家的付费道具。这一节把控制这把刀的三道锁讲透——用 RBAC 决定谁能发什么命令、谁能改什么配置,用双人审批拦住高危操作,再用 append-only 加哈希链的审计日志把谁在何时对谁做了什么、结果如何永久留痕,并给出可跑的权限判定与审计断言。

7.1 权限与审计

前六章把「服务器扛不扛得住」「线上跑得稳不稳」「配置能不能热更」这些工程问题挨个解掉了。但一个已经上线的 MMO,真正的风险往往不是宕机,而是一个手滑的运营。给全服错发了一批绑定元宝、把某个玩家的付费外观当成 bug 回收了、把测试服的爆率配置推到了正式服——这类事故的破坏面不一定比一次宕机小,而且它不可回滚的部分(玩家的信任、外流的道具)比宕机更难补。

所以 GM 后台不是「给运营一个能改数据的网页」,而是一套约束:它要保证「能做这件事的人,恰好是应该有这个权限的人」,保证「高危操作不会由一个人拍板」,保证「做过的每一件事事后都能查得清清楚楚」。这一节把这三道锁——RBAC 权限、双人审批、审计留痕——讲清楚,并落成一套可跑的 Go 代码。命令总线与补偿留到下一节 7.2 命令、补偿与重试 ,客服工单与数据修复留到 7.3 客服工具与数据修复流程 。

分工:这一节与既有 practice 文章的关系

本站「游戏服务端 practice」专题里已经有三篇和后台权限相关的文章,它们讲的是架构与取舍,本节讲的是能跑的实现与断言。分工如下:

既有文章主题与本节的边界
/game-server-admin-permission-architecture/后台权限架构只讲角色模型与权限边界的设计取舍,不给可跑代码
/game-server-data-repair-workflow-architecture/数据修复流程架构讲工单与修复的整体流程,本节只碰审计与审批这一层
/game-server-platform-operations-architecture/运营平台架构讲平台分层,本节聚焦 GM 控制台内部的权限判定

一句话:那三篇回答「该不该这么设计」,本节回答「这么设计能不能跑通、断言锁没锁住」。

谁能发什么命令:角色与权限矩阵

权限模型的第一步是把「角色」和「权限」这两件事分开。权限是一个原子动作——「查角色」「发道具」「调元宝」「踢人」「封号」「改配置」「推配置」「跑修复」;角色是一组权限的集合。运营人员永远拿角色,不直接拿权限,这样入职、转岗、离职都只需要改一处。

aegis 的 GM 后台有五个角色,按「爆炸半径」而不是按「职级」排序:viewer 只能看,cs(客服)能给道具、调元宝、跑修复但碰不到封号和配置,gm 能封号踢人但碰不到配置,ops 只管配置,admin 横跨两边。这张矩阵有一个刻意的设计:没有继承、没有通配符。一旦允许「admin 自动拥有下面所有角色」,权限表就会在某次「临时授权」里长出一颗谁都说不清的树;把每个角色的权限写成一张平表,多写几行,换来的是半年后还能一眼读懂。

权限判定本身只有一行 rolePerms[r][p]——未知角色取到零值 false,也就是默认拒绝,这是权限系统里唯一安全的默认值。

高危操作:双人审批

有些操作改的是「值」或者「线上」——调元宝、封号、推配置、跑修复。它们有一个共同点:一旦生效,代价立即发生且难以撤销。对这类操作,一个人点两次确认是不够的,需要两个不同的人各点一次。这就是双人审批(four-eyes principle)。

实现上,Approval 记录一次高危操作的审批状态。Approve 会拒绝同一个人重复审批——因为「双人审批」的语义是两个人,不是一个人按两遍。只有当审批人达到两个,Ready() 才放行;非高危操作则天然 Ready,不需要为只读查询也走一遍审批。

审计留痕:append-only 与哈希链

第三道锁是审计日志。它必须回答五个问题:谁(Operator / Role)、何时(At)、对谁(Target)、做了什么(Action / Detail)、结果如何(Result)。少了任何一条,事后追责都会卡壳。

更关键的是「append-only」这四个字。一个能被悄悄修改的日志等于没有日志。所以 AuditLog 在 API 上只暴露 Append 和只读的 Records,没有任何 update 或 delete;在数据上,每条记录都带上前一条记录的哈希,形成一条哈希链。谁改动了中间任何一行,从那一行往后所有记录的哈希都对不上,Verify() 立刻报错。这把「我们承诺不删日志」从一句口号,变成了一个能被测试证明的属性。

下面这段是 L1,go test 可以直接跑。它把 RBAC 矩阵、双人审批与哈希链审计装进同一个文件 internal/gm/rbac.go:

// file: internal/gm/rbac.go
// tier: L1
// verify: go test ./internal/gm/
// Package gm is the operations domain of aegis: the permission model behind the
// GM console, the command bus that carries an operator action to a live shard,
// and the repair workflow customer service runs to fix a broken character.
//
// This file holds section 7.1: role-based access control and the append-only
// audit log. A console user acts through a role, a role grants a fixed set of
// permissions, a few permissions are high-risk and need two approvers, and
// every action lands in a tamper-evident log.
package gm

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"time"
)

// Permission names one action a GM console user may perform.
type Permission string

const (
	PermPlayerQuery    Permission = "player:query"
	PermItemGrant      Permission = "item:grant"
	PermCurrencyAdjust Permission = "currency:adjust"
	PermPlayerKick     Permission = "player:kick"
	PermPlayerBan      Permission = "player:ban"
	PermConfigEdit     Permission = "config:edit"
	PermConfigPush     Permission = "config:push"
	PermDataRepair     Permission = "data:repair"
)

// Role names a GM console role, ordered by blast radius: a viewer only looks,
// ops never touches a character, and only admin spans both halves.
type Role string

const (
	RoleViewer Role = "viewer"
	RoleCS     Role = "cs"
	RoleGM     Role = "gm"
	RoleOps    Role = "ops"
	RoleAdmin  Role = "admin"
)

// rolePerms is the permission matrix. A permission absent from a role's set is
// denied; there is no inheritance and no wildcard, because a wildcard is how a
// "temporary" grant becomes permanent.
var rolePerms = map[Role]map[Permission]bool{
	RoleViewer: {PermPlayerQuery: true},
	RoleCS:     {PermPlayerQuery: true, PermItemGrant: true, PermCurrencyAdjust: true, PermDataRepair: true},
	RoleGM:     {PermPlayerQuery: true, PermItemGrant: true, PermCurrencyAdjust: true, PermPlayerKick: true, PermPlayerBan: true, PermDataRepair: true},
	RoleOps:    {PermPlayerQuery: true, PermConfigEdit: true, PermConfigPush: true},
	RoleAdmin:  {PermPlayerQuery: true, PermItemGrant: true, PermCurrencyAdjust: true, PermPlayerKick: true, PermPlayerBan: true, PermConfigEdit: true, PermConfigPush: true, PermDataRepair: true},
}

// Can reports whether a role may perform a permission. An unknown role is
// denied, never defaulted to allow.
func Can(r Role, p Permission) bool { return rolePerms[r][p] }

// highRisk lists the permissions that change value or reach a live shard
// irreversibly; they need a second pair of eyes.
var highRisk = map[Permission]bool{
	PermCurrencyAdjust: true, PermPlayerBan: true, PermConfigPush: true, PermDataRepair: true,
}

// HighRisk reports whether a permission needs two-person approval.
func HighRisk(p Permission) bool { return highRisk[p] }

// Approval tracks the approvers of one high-risk action. A non-high-risk action
// is always Ready; a high-risk one needs two distinct approvers.
type Approval struct {
	Permission Permission
	Target     string
	approvers  []string
}

// NewApproval starts an approval record for one action.
func NewApproval(p Permission, target string) *Approval {
	return &Approval{Permission: p, Target: target}
}

// Approve records one operator's approval. The same operator approving twice is
// rejected: two-person approval means two people, not one person twice.
func (a *Approval) Approve(operator string) error {
	if !HighRisk(a.Permission) {
		return fmt.Errorf("gm: %s is not high-risk", a.Permission)
	}
	for _, x := range a.approvers {
		if x == operator {
			return fmt.Errorf("gm: %s already approved", operator)
		}
	}
	a.approvers = append(a.approvers, operator)
	return nil
}

// Approvers returns a copy of the approver list.
func (a *Approval) Approvers() []string { return append([]string(nil), a.approvers...) }

// Ready reports whether the action may proceed.
func (a *Approval) Ready() bool { return !HighRisk(a.Permission) || len(a.approvers) >= 2 }

// AuditRecord answers the five questions every GM action must answer: who did
// it, when, to whom, what was done, and how it ended.
type AuditRecord struct {
	Seq      uint64
	Operator string
	Role     Role
	Action   Permission
	Target   string
	Detail   string
	Result   string
	At       time.Time
	PrevHash string
	Hash     string
}

// AuditLog is append-only by API: Append and a read-only Records view, no
// update or delete. Each record carries its predecessor's hash, so removing or
// editing a past line breaks Verify.
type AuditLog struct {
	recs []AuditRecord
	head string
}

// NewAuditLog returns an empty log rooted at a genesis hash.
func NewAuditLog() *AuditLog { return &AuditLog{head: "genesis"} }

// Append fills in Seq, PrevHash and Hash; the caller supplies the rest.
func (l *AuditLog) Append(r AuditRecord) AuditRecord {
	r.Seq = uint64(len(l.recs)) + 1
	r.PrevHash = l.head
	r.Hash = hashRecord(r)
	l.head = r.Hash
	l.recs = append(l.recs, r)
	return r
}

func hashRecord(r AuditRecord) string {
	h := sha256.New()
	fmt.Fprintf(h, "%d|%s|%s|%s|%s|%s|%s|%s|%s",
		r.Seq, r.Operator, r.Role, r.Action, r.Target, r.Detail, r.Result,
		r.At.UTC().Format(time.RFC3339Nano), r.PrevHash)
	return hex.EncodeToString(h.Sum(nil))
}

// Records returns a copy, so a caller cannot mutate the stored slice.
func (l *AuditLog) Records() []AuditRecord {
	out := make([]AuditRecord, len(l.recs))
	copy(out, l.recs)
	return out
}

// Verify recomputes the chain and reports the first record that does not match.
func (l *AuditLog) Verify() error {
	prev := "genesis"
	for i, r := range l.recs {
		if r.PrevHash != prev {
			return fmt.Errorf("gm: audit record %d has a broken chain link", i+1)
		}
		if hashRecord(r) != r.Hash {
			return fmt.Errorf("gm: audit record %d content does not match its hash", i+1)
		}
		prev = r.Hash
	}
	return nil
}

再配一个测试 internal/gm/rbac_test.go,断言三件事:权限矩阵逐条符合预期、高危操作要两个不同的人、改过的审计日志必须验不过:

// file: internal/gm/rbac_test.go
// tier: L1
// verify: go test ./internal/gm/
package gm

import (
	"testing"
	"time"
)

func TestRolePermissionMatrix(t *testing.T) {
	// The matrix is the contract of section 7.1: a viewer looks, cs grants and
	// repairs but never bans or touches config, ops owns config only.
	cases := []struct {
		role Role
		perm Permission
		want bool
	}{
		{RoleViewer, PermPlayerQuery, true},
		{RoleViewer, PermItemGrant, false},
		{RoleCS, PermItemGrant, true},
		{RoleCS, PermDataRepair, true},
		{RoleCS, PermPlayerBan, false},
		{RoleGM, PermPlayerBan, true},
		{RoleGM, PermConfigPush, false},
		{RoleOps, PermConfigPush, true},
		{RoleOps, PermItemGrant, false},
		{RoleAdmin, PermConfigPush, true},
		{Role("unknown"), PermPlayerQuery, false},
	}
	for _, c := range cases {
		if got := Can(c.role, c.perm); got != c.want {
			t.Errorf("Can(%s, %s) = %v, want %v", c.role, c.perm, got, c.want)
		}
	}
}

func TestHighRiskNeedsTwoDistinctApprovers(t *testing.T) {
	a := NewApproval(PermCurrencyAdjust, "player:1001")
	if a.Ready() || a.Approve("alice") != nil || a.Ready() {
		t.Fatal("a high-risk action needs two approvers, not one")
	}
	if a.Approve("alice") == nil {
		t.Fatal("the same operator must not approve twice")
	}
	if a.Approve("bob") != nil || !a.Ready() {
		t.Fatal("two distinct approvers must make the action ready")
	}
	if !NewApproval(PermPlayerQuery, "p").Ready() {
		t.Fatal("a read-only action is ready without approval")
	}
}

func TestAuditLogIsTamperEvident(t *testing.T) {
	at := time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC)
	log := NewAuditLog()
	rec := log.Append(AuditRecord{Operator: "alice", Role: RoleCS, Action: PermItemGrant, Target: "player:1001", Detail: "item=1001 x5", Result: "ok", At: at})
	if rec.Seq != 1 || rec.Operator != "alice" || rec.Action != PermItemGrant || rec.Target != "player:1001" || rec.Result != "ok" || rec.PrevHash != "genesis" {
		t.Fatal("a record must carry who / what / whom / result and link to genesis")
	}
	log.Append(AuditRecord{Operator: "bob", Role: RoleGM, Action: PermPlayerBan, Target: "p:2", Result: "ok", At: at})
	if err := log.Verify(); err != nil {
		t.Fatalf("a fresh log must verify: %v", err)
	}
	log.recs[1].Result = "covered up" // simulate an edit past the append-only API
	if log.Verify() == nil {
		t.Fatal("editing a record must break the hash chain")
	}
}

本机实跑(go test ./internal/gm/ -v)三条断言全绿:

=== RUN   TestRolePermissionMatrix
--- PASS: TestRolePermissionMatrix (0.00s)
=== RUN   TestHighRiskNeedsTwoDistinctApprovers
--- PASS: TestHighRiskNeedsTwoDistinctApprovers (0.00s)
=== RUN   TestAuditLogIsTamperEvident
--- PASS: TestAuditLogIsTamperEvident (0.00s)
PASS
ok  	aegis/internal/gm	0.527s

TestAuditLogIsTamperEvident 那条断言值得单独说一句:它先让日志正常写入并验证通过,再绕过 append-only 的 API 直接改内存里的第二条记录,然后断言 Verify() 必须失败。也就是说,「日志不可篡改」不是靠代码评审时的一句承诺,而是靠一条会变红的测试守着。将来谁想给 AuditLog 加一个 Update 方法「方便修数据」,Verify 的哈希链会第一时间拦住他。

本节小结

这一节把 GM 后台的三道锁落成了可执行代码:

  1. 权限是原子的、角色是权限的集合,五个角色按爆炸半径切分,权限矩阵没有继承也没有通配符,未知角色默认拒绝。
  2. 高危操作走双人审批,同一个操作人不能重复审批,两个不同的人到齐才放行;非高危操作不受此约束。
  3. 审计日志 append-only 且带哈希链,五个要素(谁、何时、对谁、做什么、结果)齐全,任何篡改都会让 Verify 失败。

权限决定了「谁能下令」,但一条命令从下发到落地还有一长串失败点:网络重发、进程崩溃、发奖半途而废。下一节就来处理命令的幂等、补偿与重试——让「同一条命令重复执行只生效一次」,让「发奖失败能补回来」,并守住「账目守恒」这条底线。

阅读导航:上一节:目录 · 下一节:7.2 命令、补偿与重试 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标