Spring Boot 4.0 数据权限:行级、列级与部门隔离
前言
“有订单查询权限”并不表示能看所有订单。管理员需要全租户视图,部门分析员只能看本部门,普通员工通常只能看自己负责的数据;手机号、成本、利润等列也要按职责脱敏。
本章在同一查询接口上执行两层控制:先按租户与数据范围过滤行,再按权限隐藏或脱敏列。
读者需要完成上一章的多租户 RBAC。完整代码位于 dvsusan/spring-boot4-demo,测试使用内存样例数据,避免依赖外部数据库才能看清规则。
最终结果
同一接口 GET /api/v1/security/data/orders 对不同身份返回:
| 用户 | 角色 | 可见范围 | 敏感列 |
|---|---|---|---|
| admin | ADMIN | 本租户全部 3 条 | 手机号、成本均可见 |
| analyst | ANALYST | 本部门 2 条 | 手机号可见,成本隐藏 |
| clerk | CLERK | 本人 1 条 | 手机号脱敏,成本隐藏 |

图 1:行级过滤决定“能看哪些订单”,列级权限决定“能看到哪些字段”。
第 1 步:定义范围枚举和视图
public enum DataScope {
ALL,
DEPARTMENT,
SELF
}不要从请求参数接收 scope。它必须由服务端根据角色和权限计算:
private DataScope resolveScope(SecurityIdentity identity) {
if (identity.roles().contains("ADMIN")
|| identity.permissions().contains("data:all")) {
return DataScope.ALL;
}
if (identity.permissions().contains("data:department")) {
return DataScope.DEPARTMENT;
}
return DataScope.SELF;
}默认值选择 SELF,新增角色忘记配置时只会少看数据,不会意外看到全量数据。
第 2 步:先做行级过滤
DataPermissionService 始终先限定租户:
return orders.stream()
.filter(order -> order.tenantId().equals(identity.tenantId()))
.filter(order -> visibleByScope(order, identity, scope))
.map(order -> toView(order, canReadPii, canReadCost))
.toList();范围判断:
return switch (scope) {
case ALL -> true;
case DEPARTMENT -> order.departmentId()
.equals(identity.departmentId());
case SELF -> order.ownerId().equals(identity.username());
};教学数据有 4 条,其中 1 条属于 tenant_globex。即使管理员拥有 data:all,也只能看到 tenant_acme 的 3 条,因为租户过滤先于数据范围。
第 3 步:再做列级脱敏
列权限独立判断:
private String maskPhone(String phone) {
if (phone == null || phone.length() < 7) {
return "***";
}
return phone.substring(0, 3)
+ "****"
+ phone.substring(phone.length() - 4);
}映射视图时:
new SecuredOrderView(
order.orderId(),
order.departmentId(),
order.ownerId(),
canReadPii ? order.customerPhone() : maskPhone(order.customerPhone()),
order.amount(),
canReadCost ? order.cost() : null
)无权限字段返回 null,不要返回真实值再交给前端隐藏。网络响应、日志和浏览器开发者工具都能拿到前端收到的完整数据。
第 4 步:在接口入口检查资源权限
@GetMapping
@PreAuthorize("hasAuthority('order:read')")
public ApiResponse<List<SecuredOrderView>> orders(
Authentication authentication
) {
return ApiResponse.success(
permissionService.visibleOrders(authentication)
);
}@PreAuthorize 只回答“能不能调用数据权限服务”。真正的行过滤必须在服务或 Repository 查询中完成。
第 5 步:验证三种身份
先登录并保存访问令牌:
curl -sS -X POST http://localhost:18106/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"analyst","password":"analyst123"}'查询数据:
curl -sS http://localhost:18106/api/v1/security/data/orders \
-H 'Authorization: Bearer <accessToken>'分析员的实际结果:
{
"data": [
{
"orderId": "SO-SALES-01",
"departmentId": "SALES",
"ownerId": "analyst",
"customerPhone": "13812348001",
"amount": 199.00,
"cost": null
},
{
"orderId": "SO-SALES-02",
"departmentId": "SALES",
"ownerId": "clerk",
"customerPhone": "13912348002",
"amount": 299.00,
"cost": null
}
]
}普通员工只看到 SO-SALES-02,手机号变成 139****8002;管理员看到 3 条且 cost 有值。
第 6 步:运行数据权限测试
mvn -pl demo-web -am \
-Dtest=Chapter6SecurityIntegrationTest \
-Dsurefire.failIfNoSpecifiedTests=false \
test测试会同时登录三种账号,断言行数和敏感字段。只断言 HTTP 200 不足以证明数据权限正确,必须检查返回集合与具体列。
生产查询应该怎么写?
教学版在小集合上使用 Stream 过滤,便于观察规则。生产查询应把范围转换为 SQL 条件:
WHERE tenant_id = :currentTenant
AND (
:scope = 'ALL'
OR (:scope = 'DEPARTMENT' AND department_id = :departmentId)
OR (:scope = 'SELF' AND owner_id = :userId)
)实际项目更适合在 Mapper 中组合固定 SQL 片段,防止业务开发者忘记租户条件。列脱敏则应在 DTO 转换层统一完成。
常见问题排查
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 普通员工看到全部部门 | 默认 Scope 写成 ALL | 默认使用 SELF,显式授予更大范围 |
| 管理员看到其他租户 | 数据范围先于租户过滤 | 租户条件永远放在第一位 |
| 前端隐藏了字段但仍泄露 | 后端返回了真实值 | 在 DTO 组装时置空或脱敏 |
| 导出接口绕过权限 | 只保护了页面查询接口 | 导出、详情和 RPC 共用同一权限服务 |
| 手机号脱敏破坏统计 | 对同一字段同时用生产和展示模型 | 分离原始实体、业务 DTO 和统计聚合 |
练习题
增加 REGION 范围,只允许查看同区域订单,并让成本字段只对 finance:cost:read 权限开放。验收标准是新增范围不会改变原有 ALL、DEPARTMENT、SELF 行为,所有测试仍通过。
总结
数据权限需要同时控制行、列和出口。权限判断要来自可信身份,范围默认最小,敏感字段在服务端生成响应前完成脱敏。
下一篇处理写接口的另一个高频问题:请求被重放、重复提交和第三方伪造。
上一节:多租户 RBAC
下一节:幂等、签名与防重放
相关阅读:MyBatis 多表查询与分页
返回课程目录
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
