05-数据权限详细设计-行级过滤字段控制与业务关系授权
数据权限详细设计:行级过滤、字段控制与业务关系授权
用户拥有 sales:order:read,只说明具备读取订单的功能资格,并不代表可以读取租户内全部订单。真正容易产生横向越权的地方,是数据范围没有进入列表、详情、导出和写 SQL。
数据权限负责把角色、组织、ACL、ABAC 和业务关系形成的访问边界,可靠地应用到数据访问。
1. 租户隔离不是普通数据范围
租户隔离是强制边界:
Effective Rows = Current Tenant Rows ∩ Allowed Data Scope - Forced Exclusions
tenant_id 不能由前端自由传入,也不能因为用户有 TENANT_ADMIN 就省略。平台管理员跨租户操作应进入明确的目标租户上下文,并使用独立特权和审计流程。
缓存、搜索索引、消息和文件路径也必须包含租户边界,不能只在 MySQL 查询中隔离。
2. 常见数据范围
| 范围 | 含义 |
|---|---|
| SELF | 本人创建或负责的数据 |
| DEPARTMENT | 当前部门 |
| DEPARTMENT_TREE | 当前部门及下级 |
| CUSTOM_ORGANIZATION | 指定组织集合 |
| PROJECT | 指定项目 |
| CUSTOMER_OWNER | 负责客户及关联数据 |
| RESOURCE_LIST | 指定资源实例集合 |
| TENANT_ALL | 当前租户全部数据 |
Data Scope 本身不是权限。只有与具体 Action 配对才有意义:
sales:order:read → DEPARTMENT
sales:order:export → SELF
3. 多来源范围如何合并
同一功能权限的多个允许范围默认取并集,再减去强制排除:
SELF ∪ DEPARTMENT ∪ PROJECT-A - SENSITIVE_CUSTOMERS
不同 Action 不能合并。读取范围不能自动用于导出和更新。高风险写操作可以要求更严格的来源,例如只允许当前审批授权对应的组织范围,不接受其他只读角色扩展。
每个范围必须保留来源,便于撤销和解释:
DEPARTMENT 来源:SALES_MANAGER Role Assignment
PROJECT-A 来源:Project-A 用户组
4. 组织树范围
“本部门及下级”从上级向下传播,下级员工不会反向获得上级全部数据。组织树常见存储方式:
邻接表
parent_id 简单,但深层查询需要递归 CTE 或多次查询。
物化路径
保存 /1/100/110/,前缀查询方便,移动组织时要更新子树。
闭包表
保存祖先与后代所有组合,查询快,写入和维护成本更高。
选择取决于组织变化频率、层级深度和数据库能力。无论使用哪种方式,组织移动后都要重新计算角色来源、数据范围、审批人和缓存,不能只修改 parent_id。
5. ReBAC 与业务关系
很多数据权限不是简单部门条件:
张三 OWNER_OF 客户 C1001
客户 C1001 HAS_ORDER 订单 O1001
→ 张三可以读取 O1001
项目场景:
张三 MEMBER_OF Project-A
Project-A CONTAINS D1001
→ 张三可以读取 D1001
关系必须定义方向、可传递路径、最大深度和有效期。不能把任意业务表关联都自动视为权限关系,否则一个意外 JOIN 可能扩大权限。
简单关系可以直接使用业务表和 EXISTS;大规模跨服务关系可以使用专门 ReBAC 服务,但业务系统仍负责租户和动态状态。
6. 行级权限 SQL
本人数据
SELECT *
FROM sales_order
WHERE tenant_id = :tenantId
AND creator_id = :subjectId;
部门及下级
SELECT o.*
FROM sales_order o
JOIN organization_closure c
ON c.descendant_id = o.organization_id
WHERE o.tenant_id = :tenantId
AND c.tenant_id = :tenantId
AND c.ancestor_id = :scopeOrganizationId;
客户负责人关系
SELECT o.*
FROM sales_order o
WHERE o.tenant_id = :tenantId
AND EXISTS (
SELECT 1
FROM customer_owner co
WHERE co.tenant_id = o.tenant_id
AND co.customer_id = o.customer_id
AND co.principal_id = :subjectId
AND co.status = 'ACTIVE'
);
多来源合并
不要无限拼接巨大 OR。可以把有效组织、项目和资源 ID 预计算到临时表或授权索引,再通过 JOIN/EXISTS 过滤。查询规划、索引和分页必须在真实数据量下验证。
7. 写操作必须在最终 SQL 中鉴权
错误做法:先按 ID 查询并判断权限,稍后只按 ID 更新。两次操作之间可能发生权限撤销、资源转移或状态变化,形成 TOCTOU。
推荐:
UPDATE finance_payment
SET status = 'APPROVED',
approved_by = :subjectId,
version = version + 1
WHERE id = :paymentId
AND tenant_id = :tenantId
AND organization_id IN (:allowedOrganizationIds)
AND creator_id <> :subjectId
AND status = 'PENDING_APPROVAL'
AND version = :expectedVersion;
影响行数为 0 时,不要简单返回“数据不存在”。内部需要区分无权限、状态冲突、并发版本变化和真实不存在;外部错误信息可以适度模糊,避免资源枚举。
批量更新和删除同样必须把权限条件写入 SQL,并校验实际影响数量。不能先收集 ID 再无条件批量写入。
8. 字段权限
行级允许后,还要计算字段效果:
HIDDEN:响应中不存在。MASKED:返回脱敏值。READ_ONLY:可以读取,更新请求必须拒绝或忽略并记录。READ_WRITE:允许读写。EXPORT_DENIED:在线可见但禁止导出。
字段权限必须覆盖 API 响应、日志、缓存、导出、搜索索引和异步消息。只在前端隐藏银行卡号没有安全意义。
更新请求建议使用白名单 DTO,不要把无权限字段绑定到实体后再“希望它没有变化”。
9. 数据权限放在哪一层
Controller
适合触发权限上下文,不适合拼接复杂数据条件。
Service
适合检查单资源和业务关系,但容易遗漏新入口。
Repository / 查询 DSL
能把数据范围与查询绑定,适合大多数系统。应提供类型化条件,而不是把任意 SQL 片段从上层传入。
MyBatis 拦截器
适合统一注入租户和简单组织条件,但复杂 JOIN、别名、子查询和统计很容易误改 SQL。必须有白名单、解析器和回归测试,不能依赖字符串拼接。
数据库 Row-Level Security
PostgreSQL RLS 可对 SELECT、INSERT、UPDATE、DELETE 定义行策略,并区分可见行和可修改行。优势是靠近数据源、入口一致;挑战是连接池身份、调试、跨表策略和管理员绕过规则。MySQL 通常需要应用层或视图/存储过程等替代方案。
10. 列表、详情、统计和导出必须一致
常见漏洞是列表带数据范围,详情按 ID 直查;页面显示 20 条有权限数据,导出接口却导出全部;统计 API 返回其他部门总额;搜索引擎索引没有租户过滤。
需要统一保护:
- 列表和详情;
- 搜索和联想;
- 聚合、统计、仪表盘;
- 报表、打印、导出;
- 附件和历史版本;
- 异步任务和消息消费者;
- 批量读写接口。
11. 禁止事后过滤
先查询全部数据再在 Java 内存中过滤会导致:
- 未授权数据已经进入进程、日志和 APM;
- 分页和统计错误;
- 大数据量内存风险;
- 导出和异常路径容易漏过滤;
- 无法利用数据库索引。
主要安全边界应在数据源查询阶段执行。内存过滤只适合对已授权小结果集做额外展示处理。
12. 性能优化
- 为
(tenant_id, organization_id)、owner 和关系表建立组合索引。 - 对组织闭包和项目成员设置有效期、状态索引。
- 将高频范围预计算为授权索引或 Bitmap。
- 对大型
IN列表使用临时表或 JOIN。 - 搜索引擎中同步 tenant 和授权标签,并验证更新延迟。
- 缓存 Data Scope 时携带主体权限版本和组织版本。
- 统计查询使用同一授权条件构造器,避免另写“简化”SQL。
13. 可解释性
一个数据访问结果应能解释:
张三可以查看 O1001
原因:张三是 C1001 的有效负责人,O1001 属于 C1001。
来源:customer_owner 关系 R-1008
权限:sales:order:read
租户:T1001
解释能力不只用于管理员,也有助于测试生成、缓存失效和风险分析。
14. 常见错误
- tenant_id 由客户端传入并直接信任。
- 详情和导出接口没有复用列表数据范围。
- 更新只按主键,不带权限和状态条件。
- Scope 合并时跨 Action 复用。
- 字段脱敏只在前端。
- 搜索、缓存、文件存储和消息没有租户维度。
- 组织移动后旧数据范围长期有效。
- 把所有复杂数据关系硬塞进 MyBatis 拦截器。
15. 小结
数据权限不是一张 data_scope 表,而是一套从授权来源到查询、写入、字段和异步链路的执行机制。最关键原则是:租户强制隔离、功能权限先行、范围与 Action 绑定、写操作原子校验、所有入口使用同一授权语义。
评论