Spring Boot 4.0 多租户 RBAC 权限系统设计
前言
多租户系统只加一个 tenant_id 查询条件还不够。身份里的租户、请求头里的租户、数据库连接使用的租户和对象归属必须能互相对应,否则一个伪造 Header 就可能把 A 公司带入 B 公司。
本章在 JWT 中加入租户、部门、角色和权限,使用 @PreAuthorize 控制资源操作,再用过滤器检查 X-Tenant-Id 是否与登录声明一致。
读者需要完成 JWT 登录章节。完整代码位于 dvsusan/spring-boot4-demo 的 com.susan.training.security 包。
最终结果
GET /api/v1/security/rbac/orders/{orderId}
DELETE /api/v1/security/rbac/orders/{orderId}读取需要 order:read,删除需要 order:delete。即使拥有读取权限,请求头中的租户与 JWT 声明不一致也会被拒绝。

图 1:先校验租户,再判断权限;两者都是访问业务数据的前置条件。
第 1 步:定义安全身份
登录用户和 JWT 最终都转换为 SecurityIdentity:
public record SecurityIdentity(
String username,
String tenantId,
String departmentId,
List<String> roles,
List<String> permissions
) {
}业务服务接收这个快照,而不是直接读取请求参数。这样 Basic、JWT 和 OIDC 都可以映射到同一套业务权限模型。
第 2 步:把角色和权限写入 JWT
签发时写入四类声明:
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(issuer)
.subject(user.getUsername())
.id(tokenId)
.issuedAt(issuedAt)
.expiresAt(expiresAt)
.claim("token_use", "access")
.claim("tenant_id", user.getTenantId())
.claim("department_id", user.getDepartmentId())
.claim("roles", user.getRoles())
.claim("permissions", user.getPermissions())
.build();tenant_id 用于隔离,roles 用于粗粒度身份,permissions 用于实际操作。不能把“管理员”直接等同于“可以删除所有租户数据”。
第 3 步:转换 JWT 权限
Resource Server 的转换器同时生成角色和权限:
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<GrantedAuthority> authorities = new ArrayList<>();
claimList(jwt, "roles").stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.forEach(authorities::add);
claimList(jwt, "permissions").stream()
.map(SimpleGrantedAuthority::new)
.forEach(authorities::add);
return authorities;
});hasRole('ADMIN') 检查 ROLE_ADMIN,hasAuthority('order:read') 检查原始权限。两种表达可以共存,但团队应统一命名规范。
第 4 步:使用方法级权限
RbacController 把权限放到操作旁边:
@GetMapping("/{orderId}")
@PreAuthorize("hasAuthority('order:read')")
public ApiResponse<String> read(@PathVariable String orderId) {
return ApiResponse.success("read:" + orderId);
}
@DeleteMapping("/{orderId}")
@PreAuthorize("hasAuthority('order:delete')")
public ApiResponse<String> delete(@PathVariable String orderId) {
return ApiResponse.success("deleted:" + orderId);
}Controller 只表达协议入口,真正业务方法也应保留参数和对象归属校验。仅靠注解无法阻止“有权删除,但删除了其他租户对象”的问题。
第 5 步:阻止伪造租户头
TenantAccessFilter 只处理 JWT 请求:
if (authentication instanceof JwtAuthenticationToken jwtAuthentication) {
String tokenTenant = jwtAuthentication.getToken()
.getClaimAsString("tenant_id");
String requestedTenant = request.getHeader(TENANT_HEADER);
if (requestedTenant != null
&& !requestedTenant.isBlank()
&& !requestedTenant.equals(tokenTenant)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(
response.getOutputStream(),
ApiResponse.failure(
"TENANT_MISMATCH",
"请求租户与登录身份不一致"
)
);
return;
}
}过滤器放在 Bearer Token 认证之后,才能读取已验签的 JWT。请求头只用来选择用户已经拥有的租户上下文,不能成为提权参数。
第 6 步:实机验证
登录分析员账号并获取 JWT:
curl -sS -X POST http://localhost:18106/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"analyst","password":"analyst123"}'读取订单:
curl -i http://localhost:18106/api/v1/security/rbac/orders/SO1001 \
-H 'Authorization: Bearer <accessToken>'返回 200。尝试删除时返回 403,因为分析员只有 order:read。
再伪造另一个租户:
curl -i http://localhost:18106/api/v1/security/data/orders \
-H 'Authorization: Bearer <accessToken>' \
-H 'X-Tenant-Id: tenant_globex'实际返回:
{
"code": "TENANT_MISMATCH",
"message": "请求租户与登录身份不一致",
"data": null
}为什么 URL 规则和权限注解都要有?
URL 规则适合处理“整个接口是否允许匿名访问”,方法权限适合表达具体操作。生产项目还应在 Repository 查询中强制带租户条件,形成三层保护:
- 认证层确认用户是谁。
- 授权层确认能否执行操作。
- 数据层确认对象是否属于当前租户。
只做第一层是登录系统,不是权限系统;只做第三层容易产生重复和遗漏。
常见问题排查
| 现象 | 原因 | 处理方式 |
|---|---|---|
hasRole 检查失败 | JWT 转换器没有生成 ROLE_ 前缀 | 统一角色映射规则 |
| 请求头租户始终被拒绝 | JWT 中缺少 tenant_id | 登录时写入可信租户声明 |
| 权限注解返回 500 | 未处理 AuthorizationDeniedException | 添加 403 异常映射 |
| 用户能查询其他租户数据 | 只检查 Header,SQL 没有租户条件 | 在查询层强制租户过滤 |
| 权限更新后旧用户仍可操作 | JWT 中权限还未过期 | 缩短 TTL 或增加权限版本号 |
练习题
增加 order:read:department 与 order:read:self 两种权限,并在 Service 层根据身份转换为数据查询条件。验收标准是 Controller 不接收任意 tenantId,所有租户条件都来自可信身份。
总结
多租户 RBAC 的核心不是角色表,而是可信身份、租户一致性、方法权限和查询条件共同成立。Header 可以表达上下文,但绝不能成为用户自行声明权限的入口。
下一篇继续深入数据层:同一接口对管理员、部门分析员和普通员工返回不同的行与列。
上一节:OAuth2 与 SSO
下一节:数据权限
相关阅读:动态数据源与多租户
返回课程目录
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
