1. 引言

在微服务与前后端分离架构中,权限管理是系统安全的基础设施之一。如果每个应用都从零实现一套鉴权逻辑,不仅重复造轮子,而且容易遗漏边界情况,导致安全漏洞。RBAC(基于角色的访问控制)通过引入“角色”这一中间层,将用户与权限解耦,是目前业界最广泛应用的权限模型。本文从零讲解 RBAC 的核心概念、数据库表设计,并使用 Spring Boot + JPA 实现一套最小可用的权限中心。

读完本文,你将理解 RBAC 模型原理,能够独立设计用户-角色-权限的多对多表结构,并编写可运行的 CRUD 与鉴权接口。

2. RBAC 核心概念与模型详解

2.1 基本元素:用户、角色、权限

RBAC 模型的核心是三个实体:用户(User)角色(Role)权限(Permission)。用户是操作的主体,权限是操作的能力,角色则是二者的桥梁。

具体关系如下:

  • 用户与角色:多对多。一个用户可以拥有多个角色(例如既是“管理员”又是“审核员”),一个角色也可以分配给多个用户。
  • 角色与权限:多对多。一个角色可以包含多个权限(例如“管理员”拥有“读取”、“写入”、“删除”权限),一个权限也可以归属多个角色(例如“读取”权限同时属于“管理员”和“普通用户”)。

这种设计使权限变更变得灵活:当业务规则变化时,只需调整角色-权限关联,而无需逐一修改用户。例如,公司调整考勤制度,需要给“考勤管理员”新增“导出报表”权限,管理员只需在角色管理中为“考勤管理员”添加该权限,所有被授予该角色的用户自动获得新能力。

2.2 权限粒度:操作级与资源级

在实际开发中,权限通常需要区分操作的对象和操作的类型。我们引入资源(Resource)动作(Action)的组合来定义权限粒度。

  • 动作:指对资源进行的操作,常见的有:create(创建)、read(读取)、update(更新)、delete(删除),简称为 CRUD。有时还会扩展出 export(导出)、approve(审批)等业务动作。
  • 资源:指操作针对的对象,例如:订单、用户、商品、报表等。

因此,一条完整的权限记录可以表述为:“允许对‘订单’资源执行‘读取’操作”。在数据库层,我们将权限表设计为 permission(resource, action)permission(code)order:read。这种组合方式既清晰又具可扩展性,当新增资源或动作时,只需在权限表中增加记录即可。

RBAC 模型分为多个层级,最基础的是 RBAC0,包含上述用户、角色、权限的三元组。RBAC1 引入角色继承(如“超级管理员”继承“管理员”的所有权限),RBAC2 加入职责分离约束(如一个用户不能同时拥有“出纳”和“会计”角色)。本文聚焦 RBAC0,它是绝大多数业务系统的起点。

3. 数据库表设计:用户、角色、权限及其关联

3.1 核心表结构

基于 RBAC0 模型,数据库至少需要五张表:

  1. **user**:用户表,存储登录账号、密码、个人信息。

  2. **role**:角色表,存储角色名称、描述。

  3. **permission**:权限表,存储资源与动作的组合,通常包含 resourceaction 两列。

  4. **user_role**:用户-角色关联表,将用户与角色建立多对多关系。

  5. **role_permission**:角色-权限关联表,将角色与权限建立多对多关系。

3.2 建表 SQL 示例(PostgreSQL)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
-- 用户表
CREATE TABLE "user" (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 角色表
CREATE TABLE role (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
description VARCHAR(255)
);

-- 权限表
CREATE TABLE permission (
id BIGSERIAL PRIMARY KEY,
resource VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
UNIQUE (resource, action)
);

-- 用户-角色关联表
CREATE TABLE user_role (
user_id BIGINT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
role_id BIGINT NOT NULL REFERENCES role(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);

-- 角色-权限关联表
CREATE TABLE role_permission (
role_id BIGINT NOT NULL REFERENCES role(id) ON DELETE CASCADE,
permission_id BIGINT NOT NULL REFERENCES permission(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);

3.3 设计要点

  • 主键与唯一约束user_rolerole_permission 使用联合主键,天然保证记录唯一性,避免重复分配。permission 表通过 UNIQUE (resource, action) 防止权限定义重复。
  • 外键与级联删除:关联表的外键使用 ON DELETE CASCADE,当删除用户或角色时,自动清除关联记录。

但注意:不要在产品角色的权限表上使用 ON DELETE CASCADE 直接删除父表记录,通常业务上仅做逻辑删除,物理删除仅在测试或清理冗余时使用。

  • 索引:关联表上的外键列建议单独建立索引,加快查询速度。例如:
1
2
CREATE INDEX idx_user_role_user_id ON user_role(user_id);
CREATE INDEX idx_role_permission_role_id ON role_permission(role_id);

4. 项目搭建与依赖配置(Spring Boot 实战)

4.1 Maven 依赖(pom.xml)

创建一个 Spring Boot 项目,引入以下核心依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>

如果使用 MySQL,将 postgresql 替换为 mysql-connector-j

4.2 配置文件(application.yml)

1
2
3
4
5
6
7
8
9
10
11
12
spring:
datasource:
url: jdbc:postgresql://localhost:5432/rbac_demo
username: your_username
password: your_password
jpa:
hibernate:
ddl-auto: update # 开发阶段使用update,生产环境建议validate并手动管理DDL
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect

注意ddl-auto: update 会根据实体类自动更新表结构,适合开发调试。生产环境请关闭此功能,使用 Flyway 或 Liquibase 管理数据库版本。

4.3 目录结构

1
2
3
4
5
6
7
8
9
src/main/java/com/example/rbac/
├── config/ # 配置类(如Redis、安全配置)
├── controller/ # REST接口层
├── entity/ # JPA实体类
├── repository/ # 数据访问层接口
├── service/ # 业务逻辑层
├── dto/ # 数据传输对象
├── exception/ # 异常定义
└── RbacApplication.java

5. 实体类与 Repository 实现

5.1 实体类代码

User 实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Entity
@Table(name = "\"user\"")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, unique = true, length = 50)
private String username;

@Column(nullable = false, length = 255)
private String password;

private Boolean enabled = true;

@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "user_role",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
@JsonIgnoreProperties("users") // 防止双向序列化死循环
private Set<Role> roles = new HashSet<>();
}

Role 实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Entity
@Table(name = "role")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, unique = true, length = 50)
private String name;

private String description;

@ManyToMany(mappedBy = "roles", fetch = FetchType.LAZY)
@JsonIgnoreProperties("roles")
private Set<User> users = new HashSet<>();

@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "role_permission",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "permission_id")
)
@JsonIgnoreProperties("roles")
private Set<Permission> permissions = new HashSet<>();
}

Permission 实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Entity
@Table(name = "permission", uniqueConstraints = {
@UniqueConstraint(columnNames = {"resource", "action"})
})
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Permission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, length = 50)
private String resource;

@Column(nullable = false, length = 50)
private String action;

@ManyToMany(mappedBy = "permissions", fetch = FetchType.LAZY)
@JsonIgnoreProperties("permissions")
private Set<Role> roles = new HashSet<>();
}

5.2 Repository 接口

1
2
3
4
5
6
7
8
9
10
11
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}

public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByName(String name);
}

public interface PermissionRepository extends JpaRepository<Permission, Long> {
Optional<Permission> findByResourceAndAction(String resource, String action);
}

说明@ManyToMany 双向关系中,需要指定一方为维护方(可以理解为“拥有外键”的一方)。这里将 UserRole 的双向关系维护方放在 User@JoinTable 中,Role 使用 mappedByRolePermission 的维护方放在 Role@JoinTable 中。

注意 mappedBy 的值必须与对方实体中关联字段的变量名一致。

6. 核心业务逻辑:角色分配与权限校验

6.1 角色分配服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
@RequiredArgsConstructor
public class UserRoleService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;

@Transactional
public void assignRolesToUser(Long userId, List<Long> roleIds) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
List<Role> roles = roleRepository.findAllById(roleIds);
if (roles.size() != roleIds.size()) {
throw new RuntimeException("部分角色不存在");
}
user.getRoles().addAll(roles);
userRepository.save(user);
}
}

RoleService.assignPermissions 实现思路类似,使用 role.getPermissions().addAll(...) 并调用 roleRepository.save(role)

6.2 权限校验服务

权限校验的核心逻辑是通过联合查询判断传入的用户是否拥有指定资源-动作组合的权限。

1
2
3
4
5
6
7
8
9
10
@Service
@RequiredArgsConstructor
public class PermissionCheckService {
private final UserRepository userRepository;

public boolean hasPermission(Long userId, String resource, String action) {
// 使用JPQL或原生SQL进行联表查询,避免内存中加载全部数据
return userRepository.existsByUserIdAndResourceAndAction(userId, resource, action);
}
}

UserRepository 中添加自定义查询方法:

1
2
3
4
5
6
@Query("SELECT COUNT(u) > 0 FROM User u " +
"JOIN u.roles r JOIN r.permissions p " +
"WHERE u.id = :userId AND p.resource = :resource AND p.action = :action")
boolean existsByUserIdAndResourceAndAction(@Param("userId") Long userId,
@Param("resource") String resource,
@Param("action") String action);

该查询利用 JPA 的 JOIN 操作,在数据库层面完成权限判断,效率较高。如果需要更细粒度的控制,可以在此方法基础上扩展,例如加入资源实例 ID(数据权限)。

7. 接口暴露与测试(Controller 层)

7.1 RESTful 接口设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class PermissionController {
private final UserRoleService userRoleService;
private final PermissionCheckService permissionCheckService;

// 为用户分配角色
@PostMapping("/users/roles")
public Result<Void> assignRoles(@RequestParam Long userId, @RequestBody List<Long> roleIds) {
userRoleService.assignRolesToUser(userId, roleIds);
return Result.success();
}

// 为角色分配权限
@PostMapping("/roles/permissions")
public Result<Void> assignPermissions(@RequestParam Long roleId, @RequestBody List<Long> permissionIds) {
// 具体实现略,思路同 assignRolesToUser
return Result.success();
}

// 检查用户是否拥有指定权限
@GetMapping("/users/{userId}/check")
public Result<Boolean> checkPermission(@PathVariable Long userId,
@RequestParam String resource,
@RequestParam String action) {
boolean has = permissionCheckService.hasPermission(userId, resource, action);
return Result.success(has);
}
}

7.2 统一返回格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {
private int code; // 200 表示成功
private String message;
private T data;

public static <T> Result<T> success() {
return new Result<>(200, "success", null);
}

public static <T> Result<T> success(T data) {
return new Result<>(200, "success", data);
}

public static <T> Result<T> error(int code, String message) {
return new Result<>(code, message, null);
}
}

7.3 测试验证

启动 Spring Boot 项目后,使用 Postman 或 curl 进行验证:

  1. 为用户分配角色

    1
    2
    3
    POST /api/users/roles?userId=1
    Content-Type: application/json
    Body: [1, 2]

    如果用户 ID 为 1,角色 ID 为 1(管理员)和 2(查看者),则用户同时拥有两个角色。

  2. 检查权限

    1
    GET /api/users/1/check?resource=order&action=delete

    返回 {"code":200,"message":"success","data":true},表示用户 1 拥有删除订单的权限。

提示:开发阶段可开启 swagger(springdoc-openapi),在浏览器直接调用接口,方便调试。

8. 进阶技巧:细粒度权限控制与缓存优化

8.1 数据权限扩展

上述方案只控制了“能否对某个资源执行某个操作”,没有涉及具体数据行。例如,普通业务员只能查看“自己”的订单,部门主管可以查看“本部门”的订单。这称为数据权限行级权限

扩展思路:在权限表中增加 scope 字段,表示作用域(如 ALLDEPARTMENTSELF),并在鉴权时结合当前用户上下文进行过滤。例如:

1
2
3
4
public boolean hasDataPermission(Long userId, String resource, String action, Long dataOwnerId) {
// 先判断是否拥有该权限
// 再根据 scope 判断:ALL 允许任何行,DEPARTMENT 需要 user 与 dataOwner 同部门,SELF 要求 dataOwner 等于当前用户
}

8.2 使用 AOP 实现方法级别鉴权

在每个接口方法里手动调用 hasPermission 会产生重复代码。可以采用自定义注解 + Spring AOP 简化。

定义注解:

1
2
3
4
5
6
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
String resource();
String action();
}

在切面中拦截:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Aspect
@Component
@RequiredArgsConstructor
public class PermissionAspect {
private final HttpServletRequest request;
private final PermissionCheckService permissionCheckService;

@Around("@annotation(requirePermission)")
public Object check(ProceedingJoinPoint joinPoint, RequirePermission requirePermission) throws Throwable {
// 获取当前用户(例如从请求头或 Token 中解析)
Long currentUserId = getCurrentUserId();
if (!permissionCheckService.hasPermission(currentUserId, requirePermission.resource(), requirePermission.action())) {
throw new AccessDeniedException("无权限");
}
return joinPoint.proceed();
}
}

之后在 Controller 方法上直接加注解:

1
2
3
4
5
@GetMapping("/orders")
@RequirePermission(resource = "order", action = "read")
public Result<List<Order>> listOrders() {
// ...
}

8.3 引入 Redis 缓存

每次鉴权都联表查询数据库,在高并发场景下性能不佳。可以为用户权限集合建立缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
@RequiredArgsConstructor
public class PermissionCacheService {
private final StringRedisTemplate redisTemplate;
private static final String CACHE_KEY_PREFIX = "user_permissions:";

public Set<String> getCachedPermissions(Long userId) {
Set<String> permissions = redisTemplate.opsForSet().members(CACHE_KEY_PREFIX + userId);
if (permissions == null || permissions.isEmpty()) {
// 从数据库加载,格式如 "order:read, order:delete"
permissions = loadPermissionsFromDB(userId);
redisTemplate.opsForSet().add(CACHE_KEY_PREFIX + userId, permissions.toArray(new String[0]));
redisTemplate.expire(CACHE_KEY_PREFIX + userId, Duration.ofMinutes(30));
}
return permissions;
}

private Set<String> loadPermissionsFromDB(Long userId) {
// 调用 repository 的联表查询,返回 Set<String> 如 ["order:read", "order:delete", "user:create"]
}
}

hasPermission 方法改为检查缓存:

1
2
3
4
public boolean hasPermission(Long userId, String resource, String action) {
Set<String> cache = permissionCacheService.getCachedPermissions(userId);
return cache.contains(resource + ":" + action);
}

缓存失效策略:当角色的权限发生变更(如移除某个权限),需要清除受影响用户的缓存。可以在 assignPermissionsremovePermissions 方法后,调用 redisTemplate.delete(CACHE_KEY_PREFIX + userId) 实现。如果用户量较大,可以用消息队列批量清理。

9. 踩坑记录与常见问题

9.1 N+1 查询问题

@ManyToMany 默认为懒加载(FetchType.LAZY),如果代码中遍历角色的用户集合,每次访问都会触发一条额外 SQL,导致 N+1 问题。解决方案:

  • 使用 @EntityGraph 定义实体图显式加载关联:
    1
    2
    3
    @EntityGraph(attributePaths = {"roles", "roles.permissions"})
    @Query("SELECT u FROM User u WHERE u.id = :id")
    Optional<User> findWithRoles(@Param("id") Long id);
  • 或在 JPQL 中使用 JOIN FETCH

9.2 循环依赖与 JSON 序列化死循环

双向 @ManyToMany 关系在序列化为 JSON 时,会因为相互引用导致堆栈溢出。解决方法:

  • User 中的 roles 字段上添加 @JsonIgnoreProperties("users")
  • Role 中的 users 字段上添加 @JsonIgnoreProperties("roles")
  • 或者直接在一端的关联字段上使用 @JsonIgnore,放弃另一方在 JSON 中的序列化。

9.3 权限变更即时生效

缓存中的权限数据可能过时。设计缓存时,除了设置过期时间外,还应在修改角色的权限后主动失效。建议:

  • RoleServiceassignPermissionsremovePermissions 方法中,调用 permissionCacheService.evictCache(roleId)
  • 如果需要,可以遍历该角色的所有用户,逐一清理缓存。

9.4 删除级联的误操作

JPA 的 CascadeType.REMOVE 会级联删除关联表中的记录。例如,若 Role 实体在 @ManyToMany 上配置了 cascade = CascadeType.REMOVE,则删除角色时会尝试删除关联的 Permission,这通常不是我们期望的行为。只在 user_rolerole_permission 这两个关联表的外键上使用数据库自身的 ON DELETE CASCADE,实体中尽量不配置 cascade

10. 总结与拓展

本文围绕 RBAC 权限模型,从概念到实现完整讲解了最小可用权限中心的设计与编码。核心要点总结如下:

  1. 模型价值:RBAC 通过角色解耦用户与权限,降低权限管理复杂度,支持灵活的批量授权与回收。

  2. 表结构设计:五张表(用户、角色、权限、用户-角色关联、角色-权限关联)即可表达 RBAC0 的全部语义;permission 表采用 resource + action 组合,支持细粒度的操作级控制。

  3. 实现路径:Spring Boot + JPA 可以有效降低开发成本,实体类使用 @ManyToMany 配合 @JoinTable,注意明确维护方与 mappedBy。权限校验通过 JOIN 查询或缓存实现。

  4. 常见坑点:N+1 查询、循环序列化、缓存一致性问题,以及级联删除的意外行为。

拓展方向包括:

  • 角色继承(RBAC1):建立角色层级关系,子角色自动继承父角色的权限。表中增加 parent_id 字段即可实现。

  • 职责分离(RBAC2):通过约束规则禁止用户同时拥有冲突的角色(如“出纳”与“会计”),需要在分配角色时校验。

  • 属性基访问控制(ABAC):当权限规则依赖环境属性(如 IP、时间、用户上下文)时,ABAC 比 RBAC 更灵活,但模型复杂度更高。

  • 与 Spring Security 集成:将 hasPermission 逻辑嵌入 PermissionEvaluator,在 @PreAuthorize 中直接使用表达式,进一步减少样板代码。

推荐阅读《RBAC96 模型》原文和 Apache Shiro 的权限设计源码,有助于理解大规模系统中的权限工程实践。后续可围绕「Spring Security 集成 RBAC 实战」和「权限中心缓存方案对比」继续展开。


关键词:RBAC权限模型入门、手写RBAC权限中心教程、RBAC模型设计步骤、Java实现RBAC权限管理、用户角色权限多对多。

总结

通过本文的学习,相信你已经对「RBAC权限模型入门」有了更深入的理解。建议结合实际项目多加练习。如有疑问,欢迎交流!