主题
授权:把“谁能对哪份数据做什么”写成可检查的规则
登录成功后,服务端知道请求来自 Alice。接下来读取 doc-1 时,还必须回答:这份文档属于哪个租户?Alice 是所有者、共享成员还是管理员?这次动作是查看还是删除?文档是否已锁定?
这些问题属于授权。本文用同一份文档策略说明 RBAC、ABAC、ReBAC 的差别,再把水平越权、垂直越权、缓存失效和检查时序放回请求链里。目标是让权限规则可以解释、可以测试,而不是把所有判断塞进一个不断变长的 if admin。
一个授权判断至少有四个输入
| 输入 | 要表达什么 | 可信来源 | 常见错误 |
|---|---|---|---|
| 主体 Subject | 谁在操作,包括用户、服务身份 | 已验证认证上下文及服务端记录 | 从请求体的 user_id 直接认人 |
| 动作 Action | 具体要做什么 | 服务端路由和业务操作定义 | 只区分“已登录”,不区分读、改、删 |
| 资源 Resource | 对哪个对象执行 | 服务端加载的对象及租户、所有权 | 相信客户端传来的 owner_id |
| 环境 Context | 此刻是否满足条件 | 可信时间、认证强度、对象状态等 | 相信客户端自报“已 MFA”或所在网络 |
表达成函数就是 allow(subject, action, resource, context)。ABAC 文献也使用主体、对象、操作和环境属性描述决策输入。NIST SP 800-162
输入完整还不够,必须来自正确的信任边界。若浏览器能随意填写 tenant=team-b,授权函数再精细也只是在认真处理假资料。租户 ID 可以作为请求想访问的目标,但必须与已验证主体的有效租户成员关系比对,不能直接把它升级成主体身份。
正在绘制图示…
查看图源代码
flowchart LR
R["请求:目标 ID 与操作参数"] --> E["服务端执行点 PEP"]
A["认证结果:主体与会话"] --> E
E --> L["按租户加载对象和可信属性"]
L --> P["策略决策 PDP"]
G["角色、关系、策略版本"] --> P
P --> D{"允许?"}
D -- "否或无法确定" --> N["拒绝并记录原因"]
D -- "是" --> W["在一致性边界内读写对象"]
PEP 是执行点,决定是否真正放行;PDP 是计算策略结果的地方。可以在同一个进程,也可以拆成服务。拆出去方便统一策略,却会增加延迟、故障模式和策略同步问题。无论如何拆,业务代码仍必须走到执行点。库里存在一个 Enforce 函数,并不意味着每个导出、下载或后台任务都调用了它。OWASP 授权检查位置
三种模型,用同一个问题比较
假设一份设计文档属于 team-a。所有者可以编辑;design 组成员可以读;同租户管理员可以删除;锁定后的文档不能编辑。
| 比较维度 | RBAC:基于角色 | ABAC:基于属性 | ReBAC:基于关系 |
|---|---|---|---|
| 决策的主要材料 | 用户有哪些角色,角色有哪些权限 | 主体、资源与环境属性满足什么条件 | 主体与资源之间是否存在允许的关系路径 |
| 同一场景的表达 | admin 可执行 delete | 主体租户等于资源租户,且文档未锁定 | Alice 是文档 owner,或 design 组 member |
| 最擅长的问题 | 稳定的岗位、职责和功能权限 | 租户、状态、时间、认证强度等组合条件 | 分享、团队、文件夹继承与组织关系 |
| 主要维护成本 | 角色设计与授予,角色膨胀 | 属性来源、规则解释与组合测试 | 关系写入、继承、撤销与一致性 |
| 容易漏掉的边界 | 有“编辑者”角色不等于能编辑所有对象 | 条件正确但属性过期或来自客户端 | 图上有路径不等于允许跨租户或跨动作 |
模型可以组合。这里的删除权限用角色,租户和锁定状态用属性,所有者与共享组用关系;没有必要为了“纯 ABAC”而把简单职责写成复杂表达式。选择标准是你的权限变化主要来自哪里。
RBAC:角色让岗位权限不必逐人复制
RBAC 把用户分配给角色,再把权限分配给角色。员工调岗时改角色关系,比逐条修改每份权限更容易审查。角色层级还能表达“主管继承某些普通职责”,但继承规则必须是明确策略,不是名字里带 admin 就自动放行。NIST RBAC 模型
当系统开始出现 team-a-read-own-unlocked-editor 这类角色,通常说明你把资源属性和对象关系也编码进了角色。租户、项目、文档状态相乘后,角色数量会很难管理。这时保留少量职责角色,把归属和状态交给对象级规则更自然。
职责分离也要显式表达。例如付款发起者不能审批自己的付款,这不是“给财务一个角色”就能完成的;要在审批动作中检查发起者与审批者是否相同。高危权限可以短时授予并审计,减少长期持有的权限范围。
ABAC:条件更细,代价是必须解释属性
ABAC 可以表达“同租户、在允许工作时段、且最近完成强认证的审核员,才能批准未冻结的申请”。好处是不用为每种组合建角色。问题是每个属性都变成安全输入:时区从哪来?“最近认证”由谁签发?对象状态读取后会不会变化?策略冲突时是拒绝优先还是允许优先?
因此上线一个 ABAC 规则,至少应带一张真值表和一条可理解的拒绝原因。只返回 false 可以保护接口细节,但内部审计需要区分“租户不匹配”“对象锁定”“策略服务不可用”,否则排障时容易有人把失败临时改成放行。NIST ABAC 指南
ReBAC:共享权限来自关系,撤销也必须跟着关系变化
ReBAC 可以把权限表示为关系:document:1#owner@user:alice、group:design#member@user:alice,以及“该文档允许 design 组查看”。查询时沿已定义的关系规则找 Alice 是否具有 read 权限。嵌套组和父文件夹继承会让关系形成图,必须限制或正确处理循环、深度和查询成本。
Zanzibar 论文讨论了大规模关系授权中的一致性:如果用户已被移出共享组,之后创建或读取内容时仍使用旧授权视图,就可能出现本不该继续有的访问。论文中的因果顺序与一致性机制说明,关系引擎不只是图查询,还要约定授权数据与业务数据的新旧关系。Zanzibar,USENIX ATC 2019
本文实验只有一级组成员关系,没有实现递归图、分布式关系存储或 Zanzibar 的一致性协议。这种小模型足以说明边界,不能用于推断大型图授权系统的性能或撤销延迟。
水平越权与垂直越权,在代码里分别长什么样
水平越权是访问同等级其他主体的对象,例如 Alice 把文档 ID 换成 Bob 的。垂直越权是执行自己没有资格执行的高权限动作,例如普通成员直接请求管理员删除接口。跨租户访问是多租户系统里必须单独强调的隔离边界,两类越权都可能跨过它。OWASP IDOR 指南
UUID、隐藏按钮和前端路由守卫都不能完成服务端授权。对象 ID 难猜只会减少枚举机会;对象链接可能从邮件、日志或共享页面被获得,拿到 ID 后仍要检查权限。批量接口也必须逐对象检查,不能只检查第一项后放行整批。
下面是实验的共同前置条件。所有角色,包括管理员,都必须经过租户检查。
go
if !s.Active || s.ID == "" || s.Tenant == "" ||
d.ID == "" || s.Tenant != d.Tenant {
return false
}动作的语义随后分开定义:
go
switch action {
case "read":
return s.Role == "admin" || s.ID == d.Owner ||
(d.ViewerGroup != "" && s.Groups[d.ViewerGroup])
case "edit":
return !d.Locked && (s.Role == "admin" || s.ID == d.Owner)
case "delete":
return s.Role == "admin"
default:
return false
}在 Go 1.27.2、Linux/amd64 中运行完整程序,得到:
text
owner-read: true
other-user: false
group-share: true
cross-tenant: false
member-delete: false
unknown-action: false
admin-other-tenant: false这七行表达了七个不同边界。共享关系只允许读,不自动授予编辑;成为管理员也不会跨过租户边界;新加一个 publish 动作在策略未更新前会被拒绝。默认拒绝让“忘记写规则”的失败方向更安全,但也要求部署新功能时一起部署策略与测试。默认拒绝
删除与锁定之间的关系是本例的业务选择:锁定只限制编辑,同租户管理员仍可删除。若真实业务把锁定理解为法律保留,删除也必须拒绝。授权函数不能替业务决定“锁定”的含义;必须让名称、规则和测试一致。
最小权限,不止是给用户少发几个角色
最小权限要同时约束动作、对象范围、租户范围、时间和使用环境。应用访问数据库时也不应默认使用超级用户;导出服务不需要修改用户角色;后台任务的工作负载身份应有自己的权限,不应借用某个管理员的人类账号。
| 场景 | 权限应缩到哪里 | 额外成本 |
|---|---|---|
| 用户查看订单 | 当前租户中被允许的订单 | 查询必须携带授权条件 |
| 客服临时排障 | 指定租户、特定动作、短有效期 | 审批、到期回收与审计 |
| 批量导出 | 被授权的数据与字段 | 分页、异步任务和下载阶段都要维持边界 |
| 后台结算任务 | 必要表、动作与资源 | 任务身份、轮换、失败处理 |
| 服务间调用 | 指定服务受众和操作范围 | 身份验证后仍需资源授权 |
对多租户查询,一种常见结构是在服务端确认租户身份后,将租户条件放进查询:WHERE tenant_id = ? AND id = ?,必要时再附所有者或共享关系条件。参数化只能解决 SQL 数据与语法分离,不能替你补遗漏的租户条件。Row-Level Security 等数据库机制可作为额外边界,但仍需按目标数据库验证配置和绕过角色。多租户数据隔离
权限检查与业务操作之间,不能留下随意变化的空隙
假设 Alice 先通过所有者检查,另一个请求随后把文档转给 Bob,Alice 的原请求最后才写入内容。这叫检查时和使用时之间的竞态,常写作 TOCTOU。处理方法取决于业务一致性:可以在事务里锁定或读取版本后条件更新,也可以把关键授权条件放进单条更新语句并检查影响行数。
本地实验用同一把锁保护“加载对象、授权检查、修改对象”,用于说明这个原子边界:
go
st.mu.Lock()
defer st.mu.Unlock()
d, ok := st.documents[id]
if !ok || next == "" || !Permit(s, "edit", d) {
return false
}
d.Owner = next
st.documents[id] = d程序让 Alice 把文档所有者改为 Bob,再让 Alice 尝试改回来,实际输出:
text
owner-change: true
old-owner-write: false并发测试对同一对象发起两次转让,断言恰好一次成功。go test -race 通过表示这次执行没有检测到数据竞争;它不是数据库事务测试,也不证明其他访问路径都走同一把锁。示例还省略了“新所有者存在且属于同租户”的生产校验,因此这段方法只展示原子检查,不是完整的所有权转移 API。
缓存授权,必须同时设计撤销
缓存键只写 userID 不够。同一个用户对不同动作、对象、租户可能有不同结果;策略版本、组成员关系、对象状态、认证强度也可能改变答案。缓存越久,撤销越慢。一个设计应能回答:移除角色后最迟多久失效?进程没有收到失效消息会怎样?策略服务不可用时拒绝,还是允许使用某个有明确时限的旧结果?
对高风险写操作,采用短缓存、强版本校验或在线决策通常更容易解释;低风险公开数据可以有不同策略。这是风险取舍,不存在适用于全部请求的统一 TTL。JWT 内的角色也属于签发时快照,用户被移出组后,旧令牌中的角色不会自行改变。
审计至少记录主体、租户、动作、资源标识、允许/拒绝、策略版本、请求关联 ID 与时间。不要把整张令牌、完整隐私字段或原始请求体顺手写进去。对外是否返回 403 还是隐藏对象存在性的 404,应保持一致,避免错误码、响应长度和计时差异泄漏额外信息。RFC 9110 §15.5.4
如何知道规则没有少写一条
测试应先从允许矩阵的邻居开始:允许 Alice 读自己的文档,就紧接着验证 Bob 的文档、另一个租户的同名文档、禁用用户、未知动作和已撤销共享。再覆盖列表、详情、搜索、导出、下载、批量接口与后台任务,检查不同入口有没有绕过相同对象规则。
本例已有所有者、共享只读、锁定、禁用、管理员跨租户、未知动作、关系撤销和并发转让测试。接入真实服务后仍需 HTTP 身份边界、数据库条件查询、缓存失效与任务执行身份的集成测试。授权策略越精细,越需要能说出“为什么允许这次操作”,而不是只积累一堆通过测试的布尔表达式。
依据与验证边界
- 模型:NIST RBAC 项目、NIST SP 800-162 ABAC 定义与指南;上文将其应用到同一文档例子,规则本身是本文的假设业务策略。
- 关系一致性:Pang 等,Zanzibar: Google’s Consistent, Global Authorization System,USENIX ATC 2019。本文没有实现该系统或复现论文性能。
- 实施边界:OWASP Authorization、IDOR Prevention、Multi Tenant Security;HTTP 隐藏禁止资源存在性的语义见 RFC 9110 §15.5.4。
- 2026-10-13,官方 Go 1.27.2、Linux/amd64,实际通过
go run .、go test -race -count=1 ./...、go vet ./...。源码只用标准库,无真实数据。 - 未运行 HTTP 登录、数据库事务、分布式缓存、递归关系图或真实租户隔离测试。内存锁不应被解释为跨进程一致性保证。
完整复现代码
示例的用户、租户与文档全是公开测试数据。Subject 必须由可信认证与服务端记录构造;不能将请求 JSON 直接解码成这个结构就当作已认证主体。
go.mod
go.mod
module example.com/security02
go 1.27.2main.go
代码块 · 76 行 · 点击收起 / 展开
go
package main
import (
"fmt"
"sync"
)
type Subject struct {
ID, Tenant, Role string
Active bool
Groups map[string]bool
}
type Document struct {
ID, Tenant, Owner, ViewerGroup string
Locked bool
}
// Subject and Document must come from trusted server-side records.
func Permit(s Subject, action string, d Document) bool {
if !s.Active || s.ID == "" || s.Tenant == "" || d.ID == "" || s.Tenant != d.Tenant {
return false
}
switch action {
case "read":
return s.Role == "admin" || s.ID == d.Owner || (d.ViewerGroup != "" && s.Groups[d.ViewerGroup])
case "edit":
return !d.Locked && (s.Role == "admin" || s.ID == d.Owner)
case "delete":
return s.Role == "admin"
default:
return false
}
}
// The lock makes the local authorization check and modification one operation.
// In a real database use a suitable transaction or a conditional UPDATE.
type Store struct {
mu sync.Mutex
documents map[string]Document
}
func (st *Store) ChangeOwner(s Subject, id, next string) bool {
st.mu.Lock()
defer st.mu.Unlock()
d, ok := st.documents[id]
if !ok || next == "" || !Permit(s, "edit", d) {
return false
}
d.Owner = next
st.documents[id] = d
return true
}
func fixture() (Subject, Document) {
return Subject{"alice", "team-a", "member", true, map[string]bool{"design": true}}, Document{"doc-1", "team-a", "alice", "", false}
}
func main() {
alice, d := fixture()
fmt.Println("owner-read:", Permit(alice, "read", d))
other := d
other.Owner = "bob"
fmt.Println("other-user:", Permit(alice, "read", other))
shared := other
shared.ViewerGroup = "design"
fmt.Println("group-share:", Permit(alice, "read", shared))
cross := d
cross.Tenant = "team-b"
fmt.Println("cross-tenant:", Permit(alice, "read", cross))
fmt.Println("member-delete:", Permit(alice, "delete", d))
fmt.Println("unknown-action:", Permit(alice, "publish", d))
admin := alice
admin.Role = "admin"
fmt.Println("admin-other-tenant:", Permit(admin, "delete", cross))
st := Store{documents: map[string]Document{d.ID: d}}
fmt.Println("owner-change:", st.ChangeOwner(alice, d.ID, "bob"))
fmt.Println("old-owner-write:", st.ChangeOwner(alice, d.ID, "alice"))
}main_test.go
代码块 · 81 行 · 点击收起 / 展开
go
package main
import (
"sync"
"testing"
)
func TestPolicyMatrix(t *testing.T) {
a, d := fixture()
tests := []struct {
name string
s Subject
action string
doc Document
want bool
}{
{"owner-read", a, "read", d, true}, {"owner-edit", a, "edit", d, true}, {"member-delete", a, "delete", d, false}, {"unknown", a, "dance", d, false},
}
disabled := a
disabled.Active = false
tests = append(tests, struct {
name string
s Subject
action string
doc Document
want bool
}{"disabled", disabled, "read", d, false})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Permit(tt.s, tt.action, tt.doc); got != tt.want {
t.Fatalf("got %v", got)
}
})
}
for _, role := range []string{"member", "admin"} {
s := a
s.Role = role
cross := d
cross.Tenant = "other"
for _, action := range []string{"read", "edit", "delete"} {
if Permit(s, action, cross) {
t.Fatal("cross-tenant allowed")
}
}
}
locked := d
locked.Locked = true
if Permit(a, "edit", locked) {
t.Fatal("locked document allowed")
}
shared := d
shared.Owner = "bob"
shared.ViewerGroup = "design"
if !Permit(a, "read", shared) || Permit(a, "edit", shared) {
t.Fatal("share must be read-only")
}
a.Groups = map[string]bool{}
if Permit(a, "read", shared) {
t.Fatal("revoked group still allowed")
}
}
func TestChangeOwnershipAtomic(t *testing.T) {
a, d := fixture()
st := Store{documents: map[string]Document{d.ID: d}}
var wg sync.WaitGroup
out := make(chan bool, 2)
for range 2 {
wg.Go(func() { out <- st.ChangeOwner(a, d.ID, "bob") })
}
wg.Wait()
close(out)
n := 0
for ok := range out {
if ok {
n++
}
}
if n != 1 {
t.Fatalf("successful changes=%d", n)
}
}运行命令
sh
go mod download
go run .
go test -race -count=1 ./...
go vet ./...