数据权限详细设计:行级过滤、字段控制与业务关系授权

用户拥有 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 绑定、写操作原子校验、所有入口使用同一授权语义。

参考资料