《Go 语言编程实战》5.2 RBAC 与多租户隔离

令牌解决了「你是谁」,但 viewer 和 owner 能做的事完全不同,而且不同租户的数据绝不能互相看见。本节给 TaskHub 建 owner/admin/member/viewer 四级角色与权限矩阵,把认证、租户与权限校验串成中间件链,再用一个真实的反面仓库演示「忘了带 tenant_id」如何导致跨租户泄漏,给出参数、类型与行级安全三种隔离实现,并用表驱动测试锁住权限边界。

5.2 RBAC 与多租户隔离

5.1 让每个请求都能可靠地拿到「usr_1、属于 tnt_1、角色是 member」。但拿到身份只是第一步,接下来的问题是两个:这个身份能做什么操作(授权),以及它能看到哪些数据(隔离)。这两件事经常被混在一起写成一个巨大的 if,然后在某次需求变更里悄悄漏掉一条。

本节把 TaskHub 推进到:四级角色与权限矩阵落地,认证 → 租户校验 → 权限校验的中间件链跑通,数据访问层强制注入租户条件,并用实测证明漏掉租户条件的后果。

5.2.1 认证不等于授权

一句话区分:认证(authentication)回答「你是谁」,授权(authorization)回答「你能干什么」。HTTP 上的表现就是 401 和 403:

  • 令牌缺失、过期、签名错 → 401 unauthenticated,并带 WWW-Authenticate 头。
  • 令牌有效但角色不够 → 403 forbidden。

把它们混起来是最常见的错误之一:权限不足回 401,客户端会以为「令牌坏了」,于是不停地刷新令牌重试,把认证服务打爆。客户端按状态码分支的逻辑在 4.1 节已经定过,服务端必须配合。

5.2.2 角色模型:四个角色够用吗

TaskHub 第一版只用四个角色:

角色定位典型使用者
owner租户的所有者付费账号持有人,唯一能转移所有权
admin管理员团队负责人,能管项目但改不了计费
member普通成员干活的人,能读写任务
viewer只读外部合作方、审计员

为什么不做「自定义角色 + 权限点自由组合」?因为那是产品成熟之后的事。角色数量应该由实际授权需求驱动,而不是由「未来可能」驱动。四个角色能覆盖绝大多数团队,个别例外用「一个用户多个角色」的组合表达(["viewer","member"] 取并集)就够了。等真的出现「这个角色只能看某个项目的任务」这种需求,再引入资源级权限——那时你才知道真实的维度是什么。

权限点用「资源 + 动作」命名,避免出现 canEdit 这种含糊的名字:

type Permission string

const (
	PermTaskRead     Permission = "task:read"
	PermTaskWrite    Permission = "task:write"
	PermTaskDelete   Permission = "task:delete"
	PermProjectAdmin Permission = "project:admin"
	PermMemberManage Permission = "member:manage"
)

权限矩阵用 map 表达,一处定义、处处引用:

var rolePerms = map[Role]map[Permission]bool{
	RoleOwner:  {PermTaskRead: true, PermTaskWrite: true, PermTaskDelete: true, PermProjectAdmin: true, PermMemberManage: true},
	RoleAdmin:  {PermTaskRead: true, PermTaskWrite: true, PermTaskDelete: true, PermProjectAdmin: true, PermMemberManage: false},
	RoleMember: {PermTaskRead: true, PermTaskWrite: true, PermTaskDelete: false, PermProjectAdmin: false, PermMemberManage: false},
	RoleViewer: {PermTaskRead: true, PermTaskWrite: false, PermTaskDelete: false, PermProjectAdmin: false, PermMemberManage: false},
}

实测打印出来的矩阵:

        task:read       task:write      task:delete     project:admin   member:manage
owner     Y               Y               Y               Y               Y
admin     Y               Y               Y               Y               .
member    Y               Y               .               .               .
viewer    Y               .               .               .               .

两个刻意的设计:admin 不能 member:manage——加人踢人属于租户级操作,只有 owner 能做,这样即使管理员账号被盗,攻击者也扩不了权。viewer 只有 task:read——只读角色最容易被忽略,然后有人顺手给了它写权限「方便测试」。

判断函数取并集,多角色用户只要任一角色有权限即通过:

func can(roles []string, p Permission) bool {
	for _, r := range roles {
		if rolePerms[Role(r)][p] {
			return true
		}
	}
	return false
}

注意 rolePerms[Role(r)][p] 在 r 是未知角色时返回零值 false——默认拒绝。这一点很重要:令牌里的角色名如果因为版本不一致而对不上,行为是「拒绝」而不是「放行」。

5.2.3 表驱动测试锁住权限边界

权限矩阵最容易出的 bug 是「改了一行,别处行为变了」。用表驱动测试把边界钉死:

cases := []struct {
	roles []string
	perm  Permission
	want  bool
}{
	{[]string{"viewer"}, PermTaskRead, true},
	{[]string{"viewer"}, PermTaskWrite, false},
	{[]string{"member"}, PermTaskWrite, true},
	{[]string{"member"}, PermTaskDelete, false},
	{[]string{"admin"}, PermTaskDelete, true},
	{[]string{"admin"}, PermMemberManage, false},
	{[]string{"owner"}, PermMemberManage, true},
	{[]string{"viewer", "member"}, PermTaskWrite, true},
	{nil, PermTaskRead, false},
}

实测 9 条断言全部通过:

权限断言 9 条, 失败 0 条

特别要保留的是最后两条:多角色取并集、空角色全拒绝。前者防止「只有单角色才生效」的实现错误,后者防止「没角色等于放行」的严重漏洞。

5.2.4 中间件链:认证 → 租户 → 权限

三层关注点拆成两个中间件(认证与租户校验合并成一个),顺序不能乱(chain 按书写顺序把中间件套上):

handler := chain(final,
	withAuth(tokens),                 // 1. 解析令牌,写 principal 进 context
	requirePermission(PermTaskWrite), // 2. 检查权限
)

withAuth 做三件事,其中第二件是多租户的关键:

func withAuth(tokens map[string]principal) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
			p, ok := tokens[tok]
			if !ok {
				w.Header().Set("WWW-Authenticate", `Bearer realm="taskhub"`)
				w.WriteHeader(http.StatusUnauthorized)
				fmt.Fprint(w, `{"code":"unauthenticated"}`)
				return
			}
			// 路径里的租户必须与令牌一致,否则 404(不泄露存在性)
			if tid := tenantFromPath(r.URL.Path); tid != "" && tid != p.TenantID {
				w.WriteHeader(http.StatusNotFound)
				fmt.Fprint(w, `{"code":"not_found"}`)
				return
			}
			next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalKey, p)))
		})
	}
}

三个要点:

  1. 路径租户与令牌租户不一致时回 404,不是 403。这正是 4.1 节定的规则:回 403 等于确认「tnt_2 存在」,攻击者可以据此枚举租户。
  2. 租户来源是令牌,路径只做校验。哪怕路径里没有租户(比如 /api/v1/projects/{id}/tasks),下游也一律用 principal.TenantID,永远不用路径里的值去查库。
  3. context key 用自定义类型,避免和其他包撞车(卷一的中间件章节讲过这个坑)。

5.2.5 实测:五种请求的结果

把上面这条链挂上,逐个场景打一遍:

tok_member_t1    /api/v1/tenants/tnt_1/projects/prj_7/tasks   -> 200 {"created_for_tenant":"tnt_1"}
tok_viewer_t1    /api/v1/tenants/tnt_1/projects/prj_7/tasks   -> 403 {"code":"forbidden"}
tok_member_t2    /api/v1/tenants/tnt_1/projects/prj_7/tasks   -> 404 {"code":"not_found"}
                 /api/v1/tenants/tnt_1/projects/prj_7/tasks   -> 401 {"code":"unauthenticated"}
tok_member_t1    /api/v1/tenants/tnt_9/projects/prj_7/tasks   -> 404 {"code":"not_found"}

逐条对应:

场景状态码说明
member 在自己的租户里写任务200正常放行,handler 拿到 tnt_1
viewer 尝试写任务403认证通过、权限不足
tnt_2 的 member 访问 tnt_1 的路径404跨租户,不泄露存在性
完全不带令牌401带 WWW-Authenticate
自己的令牌但路径里写 tnt_9404路径与令牌不符

注意最后一条和第 3 条的区别:前者是「令牌租户与路径租户不符」,后者也是。两条都回 404,因为从外部看它们的区别本身就是要保护的信息。

5.2.6 隔离的真相:漏掉 WHERE tenant_id

中间件只解决了「入口」。真正的隔离漏洞发生在数据访问层——某个查询忘了带 tenant_id。用一个反面例子说明,两个仓库读同一份数据:

// naiveRepo 是反面教材:只按 id 查,不带租户条件
func (r *naiveRepo) Get(ctx context.Context, id string) (*Task, bool) {
	for i := range r.all {
		if r.all[i].ID == id {
			return &r.all[i], true
		}
	}
	return nil, false
}

// scopedRepo 强制租户注入:租户是必填参数,无法「忘记」
func (r *scopedRepo) Get(ctx context.Context, tid TenantID, id string) (*Task, bool) {
	for i := range r.all {
		if r.all[i].ID == id && TenantID(r.all[i].TenantID) == tid {
			return &r.all[i], true
		}
	}
	return nil, false
}

以 tnt_1 的身份去读属于 tnt_2 的任务 tsk_2,实测:

naiveRepo 跨租户泄漏: 拿到 "tnt_2 的机密任务"(属于 tnt_2)
scopedRepo 跨租户读取: 返回 not found(正确)

naiveRepo 把别的租户的机密任务原样返回了。这不是假想——真实事故里绝大多数跨租户泄漏都是这么来的:handler 校验了权限,但 SQL 少了一个条件。

关键在于:naiveRepo 的签名里根本没有租户这个参数,所以调用方「想传也传不了」。这就是为什么修复方式不该是「在 SQL 里补一句 where tenant_id = ?」,而是改签名,让漏掉成为不可能:

// 不好:租户可选,能忘
func (r *Repo) Get(ctx context.Context, id string) (*Task, error)

// 好:租户必填,忘不了
func (r *Repo) Get(ctx context.Context, tid TenantID, id string) (*Task, error)

5.2.7 三种强制注入的实现

把「必须带租户」从约定变成机制,有三条路:

方案做法强度代价
参数强制仓库方法首参 tid TenantID弱(能传错值)零
类型强制ScopedDB 只能由 ForTenant(tid) 构造,内部自动拼条件中一层封装
行级安全Postgres RLS + SET LOCAL app.tenant_id强(数据库兜底)需改连接上下文

参数强制成本最低,立刻能做,且能拦住「完全忘记」这类错误——这也是 TaskHub 第一版的选择。

类型强制更进一步,让 ScopedDB 成为唯一能执行查询的入口:

type ScopedDB struct {
	db       *sql.DB
	tenantID TenantID
}

func (s *ScopedDB) ForTenant(tid TenantID) *ScopedDB {
	return &ScopedDB{db: s.db, tenantID: tid}
}

// Query 自动把租户条件拼在最前面,调用方无法绕过
func (s *ScopedDB) Query(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
	return s.db.QueryContext(ctx, "select * from ("+q+") t where t.tenant_id = $1", append([]any{s.tenantID}, args...)...)
}

**行级安全(RLS)**是最后的兜底,第 17 章安全加固会展开。它的价值在于:即使应用层代码写错了,数据库也会把不属于当前租户的行过滤掉。代价是每次事务开始要 SET LOCAL app.tenant_id,且要小心连接池复用导致租户串味——必须用 SET LOCAL(事务级)而不是 SET(会话级)。

TaskHub 的演进路径是:第一版参数强制 → 上线后加类型强制 → 有合规要求时上 RLS。一步到位上 RLS 会让早期迭代变慢,收益也不明显。

5.2.8 小结

  • 认证答「你是谁」(401),授权答「你能干什么」(403),两者不能混。
  • 四个角色起步,权限点用 资源:动作 命名;多角色取并集,未知角色默认拒绝。
  • 表驱动测试锁住权限边界,重点覆盖「多角色并集」与「空角色拒绝」。
  • 中间件链顺序是认证 → 租户校验 → 权限校验;路径租户与令牌租户不符一律 404。
  • 租户 ID 永远来自令牌,路径只做校验;下游查询绝不用路径里的租户值。
  • 实测 naiveRepo 会原样返回别的租户的机密任务——隔离靠签名强制,不靠自觉。
  • 隔离强度分三档:参数强制 → 类型强制 → 行级安全,按需演进。

到这里,TaskHub 自己的账号体系能用了。但企业客户会问:「能不能用我们自己的 SSO 登录?」——下一节把第三方身份接进来。

阅读导航:上一节:5.1 JWT 与会话管理 · 下一节:5.3 OAuth2/OIDC 与第三方登录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练