如何设计支持权限继承的数据库对象细粒度授权架构?
建议先把业务数据和显式授权存放在同一个关系型数据库中,由服务端统一计算继承权限。对于 Company → Department → Position 这种固定三层结构,带索引的祖先查询通常就适合作为起点,无需仅因担心 JOIN 而引入 LDAP。
授权模型可以采用带继承的对象级 ACL:记录“谁对哪个对象拥有什么权限”,再根据对象的父子关系计算有效权限。以后有多个服务需要共享授权关系时,再评估独立的关系授权服务。
先确定继承与冲突规则
设计数据库结构前,先明确权限冲突时如何处理:用户在 Company 上是 Admin,在某个 Department 上又被授予 Read,最终应该拥有什么权限?
按照题目中“Company 的 Admin 对所有子实体仍是 Admin”的要求,可以采用以下规则:
- 假设较高级别包含较低级别的能力:
Admin > Write > Read。 - 取本级授权与所有祖先授权中的最高级别。
- 没有适用的授权时,默认拒绝访问。
- 授权只向下继承,不自动获得父对象或兄弟对象的权限。
- 子对象上的低级别授权不会降低继承权限。
对应的计算规则是:
Read = 1
Write = 2
Admin = 3
effective_level(user, object) =
max(0, 用户在该对象及所有祖先对象上的显式授权级别)
例如:
| Company 授权 | Department 授权 | Position 授权 | Position 有效权限 |
|---|---|---|---|
| Admin | Read | 无 | Admin |
| 无 | Write | Read | Write |
| Read | 无 | Admin | Admin |
| 无 | 无 | 无 | 无权限 |
这里假设所有级别都向下继承。如果只允许特定级别继承,还需要细化规则。
如果业务还要求“禁止访问”“停止继承”或“子级覆盖父级”,简单的 max() 就不够了。需要为这些要求单独定义优先级,否则同一组授权数据可能有不同解释。
只存显式授权,保留唯一的对象关系
给 Company 授予 Admin 时,只保存一条 Company 授权记录。Department 和 Position 的继承权限在查询时计算,无需给每个后代复制授权记录。
新增 Position 后,它会继承所属 Department 的权限;撤销祖先授权时,也无需逐个删除后代的权限副本。
以 PostgreSQL 为例,可以用统一的资源表保存对象身份和父子关系:
CREATE TABLE app_user (
id bigint PRIMARY KEY
);
CREATE TABLE resource (
id bigint PRIMARY KEY,
resource_type text NOT NULL
CHECK (resource_type IN ('Company', 'Department', 'Position')),
parent_id bigint REFERENCES resource(id),
CHECK (parent_id IS NULL OR parent_id <> id)
);
CREATE INDEX resource_parent_idx
ON resource(parent_id);
CREATE TABLE resource_grant (
user_id bigint NOT NULL REFERENCES app_user(id),
resource_id bigint NOT NULL
REFERENCES resource(id) ON DELETE CASCADE,
permission_level smallint NOT NULL
CHECK (permission_level BETWEEN 1 AND 3),
PRIMARY KEY (user_id, resource_id)
);
Company、Department、Position 各自的业务字段仍可存放在独立表中,通过主键关联 resource.id。父子关系以 resource.parent_id 为准,避免多张表各自保存一份可以独立修改的关系。
这份 DDL 只包含授权的基本结构,还需要通过数据库约束、触发器或受控的事务写入逻辑保证:
- Company 没有父节点。
- Department 的父节点必须是 Company。
- Position 的父节点必须是 Department。
parent_id <> id 只能防止节点直接指向自身,无法单独防止多个节点形成循环。还要严格执行上述类型层级规则,才能保证树的结构合法。
如果现有业务表已经通过外键维护了完整关系,可以直接沿用。统一资源表便于统一对象身份与授权关联,是否增加这层抽象,可以根据现有模型决定。
沿祖先链查询有效权限
PostgreSQL 支持用递归 CTE 查询树形关系,具体语法可参考其 WITH 与递归查询文档。
下面的查询从目标对象向上查找祖先,取当前用户的最高授权级别,并判断是否满足所需权限:
-- $1: target resource ID
-- $2: authenticated user ID
-- $3: required level, limited to 1, 2, or 3
WITH RECURSIVE ancestors AS (
SELECT id, parent_id
FROM resource
WHERE id = $1
UNION ALL
SELECT parent.id, parent.parent_id
FROM resource AS parent
JOIN ancestors AS child
ON parent.id = child.parent_id
)
SELECT COALESCE(MAX(g.permission_level), 0) >= $3 AS allowed
FROM ancestors AS a
JOIN resource_grant AS g
ON g.resource_id = a.id
AND g.user_id = $2;
在合法的三层结构中,查询一个 Position 只需检查 Position、Department、Company 三个节点上的授权,不会遍历 Company 的全部后代。
目标对象不存在或没有适用授权时,查询返回 false。用户 ID 应来自服务端验证过的身份,所需权限则由接口执行的操作确定,不能由客户端自行决定。
如果层级始终只有三层,也可以用固定 JOIN 或 UNION ALL 构造祖先集合。是否使用递归,主要看模型以后是否会扩展,以及哪种写法更容易维护。
在服务端集中执行授权
应用可以提供统一的授权入口:
authorize(userId, action, resourceId)
服务端负责将操作映射到所需级别,例如读取需要 Read,修改业务字段需要 Write,管理授权需要 Admin。删除和移动对象是否要求 Admin,应由业务规则单独确定。
所有读写路径都应执行同一套授权规则,包括批量操作、导出和后台任务。前端隐藏按钮可以改善体验,但服务端仍须检查权限。
创建对象时,新对象尚不存在,应检查父对象上的创建权限。移动 Department 或 Position 时,需要同时检查来源与目标范围的权限,并明确移动后按新的祖先链重新计算权限。
列表查询应在查询中加入授权过滤,或者先批量求出可访问的对象集合,再执行分页。取出全部对象后在应用内逐条检查,容易导致分页数量不准,也会产生大量重复查询。
如果要求严格撤权,还要处理“检查通过后、写入前权限被撤销”的并发情况。仅将两条语句放进事务未必足够,需要根据数据库隔离级别,配合锁定或其他并发控制方案。
用实际查询判断是否需要优化
判断查询是否过慢,不能只看 JOIN 的数量。应结合授权表规模、索引命中情况、列表访问方式和执行计划来分析。
初始方案可以使用邻接表,也就是 parent_id,再根据测量结果选择优化方式:
| 方案 | 适用情况 | 需要承担的成本 |
|---|---|---|
| 邻接表与祖先查询 | 层级浅,希望写入和移动简单 | 查询时查找祖先 |
闭包表 ancestor_id, descendant_id | 经常查询祖先、后代集合 | 移动子树时更新关系 |
| 有效权限缓存或派生表 | 授权读取频繁,实时计算已成为瓶颈 | 失效、重建与撤权一致性 |
闭包表保存祖先与后代关系,也可以包含对象到自身的记录。它能减少查询时的层级遍历,同时需要承担关系维护的成本。
缓存有效权限时,祖先授权变更和对象移动都可能影响整棵子树。需要提前确定撤权允许延迟多久,并保证能够从权威数据重建缓存。
文档数据库也可以用父引用或祖先 ID 路径建模。选择前,应确认它的事务能力和查询方式能否满足对象移动、授权更新与撤权的一致性要求。
OAuth scopes、LDAP 与独立授权服务的边界
OAuth scopes 可以与对象授权配合使用。RFC 6749 将 scope 的具体含义交给授权服务器定义,没有规定 Company、Department、Position 的继承算法,参见 RFC 6749 第 3.3 节。
可以让 scope 限制令牌可执行的 API 操作,由对象授权判断当前用户能否操作具体对象。例如,即使拥有 positions:write scope,仍需检查用户对目标 Position 是否拥有 Write。对象关系和授权会变化,长期有效的权限快照会增加撤权难度,因此无需把所有对象授权都放进令牌。
LDAP 可以提供用户、组和已有的目录关系。如果业务对象的生命周期由应用数据库管理,仅为实现这三级继承而在 LDAP 中再维护一份对象关系,会增加同步和故障处理工作。已有企业目录时,可以用其中的用户或组标识作为授权主体,业务对象关系仍由业务系统维护。
当多个服务需要共享授权模型,或者关系扩展到用户组、项目成员、共享对象和委托关系时,可以评估独立的 ReBAC(Relationship-Based Access Control)服务。OpenFGA 的父子对象建模文档介绍了如何通过父对象关系推导子对象权限。
采用独立服务时,需要明确每类数据由谁负责,才能维持 single source of truth。例如,业务数据库负责对象关系和显式授权,授权服务保存可重建的查询投影。也可以由授权服务负责显式授权,但需要相应设计对象创建、删除和关系变更的协调流程。OpenFGA 的授权数据权威来源说明讨论了这些取舍。
当前三层模型用同一个数据库保存对象关系与显式授权,并提供统一的服务端授权入口,就能覆盖核心需求。是否拆出授权服务,再根据共享需求、关系复杂度和实际性能决定。
备注:内容仅供参考。
