ACL 数据结构设计

做 ACL 系统,第一步就是搭数据架子。架子搭不好,后面全是坑。我个人习惯先把最核心的三个要素定下来:谁、干什么、对谁干。说白了就是用户、权限、资源这三样。

用户-资源-权限三元组模型

这个模型其实不复杂。你想想看,每次访问控制本质上就是在回答三个问题:

  • 谁在请求?(用户 / 角色)
  • 对什么请求?(资源 / 对象)
  • 做什么?(操作 / 权限)

把这三个答案拼在一起,就是一个三元组:(用户, 资源, 权限)。我在项目中遇到过有人把用户和角色混在一起存,结果后面权限继承逻辑乱成一锅粥。所以一开始就要分清楚:用户是实体,角色是集合,别搞混。

核心公式:访问决策 = 匹配 (Subject, Resource, Action) → 返回 Allow / Deny

三元组的好处是直观。但实际落地时你会发现,如果每个用户对每个资源都存一条记录,数据量会爆炸。所以一般会引入「用户组」或「角色」来压缩条目数。不过那是后面章节的事,咱们先把基础架子搭稳。

ACL 条目结构设计

一条 ACL 条目长什么样?我直接给一个我在生产环境用过的结构:

// ACL 条目核心字段
{
  "id": "acl_001",              // 条目唯一标识
  "subject": "user_1001",       // 主体(用户/角色/组)
  "subject_type": "user",       // 主体类型:user | role | group
  "resource": "doc_2024",       // 资源标识
  "resource_type": "document",  // 资源类型:document | api | menu
  "permissions": 0b00000111,    // 权限掩码(后面细讲)
  "effect": "allow",            // 效果:allow | deny
  "priority": 100,              // 优先级(数字越小越优先)
  "conditions": {},             // 条件约束(时间/IP/设备等)
  "create_time": 1700000000,
  "expire_time": null           // 过期时间,null 表示永久
}

嗯,这里要注意几个关键点:

  • subject_type 一定要单独存。我曾经接手过一个老系统,主体类型靠字段前缀猜(比如 user_ 开头就是用户),结果维护了两年后彻底失控。
  • effect 字段。别小看它,Deny 优先级高于 Allow 是 ACL 的铁律,后面讲决策引擎时会细说。
  • priority 用来处理多条匹配时的冲突。我建议留 0-100 的区间,别用负数,容易出 bug。

我的习惯:每条 ACL 条目都带一个 expire_time。哪怕你当前不需要过期功能,也先占个字段。等业务方突然说「这个权限下周失效」时,你会感谢这个字段的。

权限掩码设计

权限掩码是 ACL 里最巧妙的设计之一。说白了就是用二进制位来表示权限集合。为什么用位?因为快,而且省空间。

我直接给一个 8 位掩码的经典分配方案:

位位置 位掩码 权限名 说明
Bit 0 0b00000001 (1) READ 读取/查看
Bit 1 0b00000010 (2) WRITE 写入/编辑
Bit 2 0b00000100 (4) EXECUTE 执行/调用
Bit 3 0b00001000 (8) DELETE 删除
Bit 4 0b00010000 (16) ADMIN 管理/授权
Bit 5-7 0b11100000 预留 业务自定义

为什么用 2 的幂?因为每个位独立,组合权限时直接按位或(|)就行。比如读写权限就是 READ | WRITE = 0b00000011 = 3。检查权限时用按位与(&),一行代码搞定。

// 权限检查示例(Go 语言)
const (
    READ    = 1 << 0  // 1
    WRITE   = 1 << 1  // 2
    EXECUTE = 1 << 2  // 4
    DELETE  = 1 << 3  // 8
    ADMIN   = 1 << 4  // 16
)

// 检查是否有读权限
func CanRead(permMask int) bool {
    return permMask & READ != 0
}

// 检查是否有读写权限
func CanReadWrite(permMask int) bool {
    return permMask & (READ | WRITE) == (READ | WRITE)
}

我曾经踩过的坑:一开始用 32 位掩码,觉得够用。结果业务方要求每个资源类型有 20 多种自定义操作,32 位根本不够。后来改成 64 位,但数据库存 bigint 又有一堆兼容问题。所以建议一开始就设计成可扩展的,比如用字符串存多个掩码,或者直接上位图数组。

权限掩码还有一个好处:可以快速做集合运算。比如你想给某个角色「除了删除以外的所有权限」,直接 ALL_PERMS & ^DELETE 就行。这种位运算在决策引擎里跑起来飞快,毫秒级判断几万条规则不是问题。

下面我把整个 ACL 数据结构的关系画成一张图,方便你理解:

ACL 数据结构核心关系图 用户 (Subject) user_1001 资源 (Resource) doc_2024 权限掩码 (Mask) 0b00000111 ACL 条目 = (Subject, Resource, PermissionMask) effect: allow | deny | priority: 0-100 | conditions: {...} 决策引擎 匹配 → 优先级排序 → 返回 Allow/Deny 💡 关键设计原则: 1. 每个 ACL 条目必须同时包含主体、资源、权限三要素 2. 权限掩码用位运算,Deny 条目优先级高于 Allow

这张图把三个核心要素和 ACL 条目的关系串起来了。你注意看,权限掩码是独立于用户和资源存在的,它通过位运算和条目绑定。这样做的好处是:权限定义可以复用,不同资源类型可以共用一套掩码体系。

实际项目建议:权限掩码的定义最好放在配置文件或枚举里,别硬编码。我见过有人在代码里到处写 magic number(比如 if perm == 3),后来加个权限要改 50 个文件。用常量或枚举,维护成本低一个数量级。

最后总结一下这节的核心:三元组是骨架,条目结构是血肉,权限掩码是灵魂。这三样东西定好了,后面的决策引擎、缓存策略、扩展设计都会顺很多。别急着写代码,先把数据结构画清楚,想清楚边界情况——比如同一条 ACL 既匹配用户又匹配角色时怎么处理?这些细节我会在后面的章节展开。