权限系统设计:从访问控制理论到工程落地
权限的本质:从现实世界到软件系统
在现实世界中,权限无处不在:门禁卡决定你能进入哪些楼层,银行柜台需要身份证才能办理业务,公司的保密文件只有特定级别的人才能阅读。这些场景的共同点是:一个主体试图对一个客体执行某种操作,而系统需要判断这次操作是否被允许。
这就是访问控制的核心模型 —— 主体-客体-操作三元组(Subject-Object-Action Triple):
| 概念 | 定义 | 软件系统中的映射 |
|---|---|---|
| Subject(主体) | 发起访问请求的实体 | 用户、服务账号、API Client |
| Object(客体) | 被访问的资源 | API 接口、数据记录、页面、文件 |
| Action(操作) | 主体对客体执行的动作 | 读取、写入、删除、审批 |
在这个三元组之上,安全领域有一个经典的 AAA 模型,定义了访问控制的三个阶段:
Authentication(认证) 回答"你是谁"的问题。用户名密码、OAuth Token、生物识别都属于认证手段。认证的输出是一个可信的身份标识(Identity)。
Authorization(授权) 回答"你能做什么"的问题。在确认身份之后,系统根据策略判断该身份是否有权执行请求的操作。这是权限系统的核心。
Accounting(审计) 回答"你做了什么"的问题。记录所有访问行为,用于事后追溯、合规审查和异常检测。
很多系统把认证和授权混为一谈,典型的表现是在登录接口里同时返回用户信息和权限列表,导致权限逻辑散落在各处。清晰的分层是权限系统设计的第一步:认证层只负责验证身份,授权层只负责判断权限,两者通过标准化的身份上下文(如 JWT Claims)衔接。
一句话总结:权限系统的本质是回答"谁能对什么做什么"的问题,而 AAA 模型将这个问题拆解为认证、授权、审计三个正交的关注点。
访问控制模型演进:从 DAC 到 ABAC
访问控制模型经历了近 50 年的演进,从最初的自主访问控制发展到基于属性的细粒度控制。理解这条演进脉络,才能在实际项目中做出正确的模型选型。
DAC:自主访问控制
DAC(Discretionary Access Control)是最早的访问控制模型,核心思想是资源的拥有者自主决定谁可以访问。Linux 文件系统的 rwx 权限就是典型的 DAC 实现:文件的 owner 可以通过 chmod 命令授权其他用户读写。
# Linux DAC 示例:文件 owner 自主授权
chmod 750 /data/report.csv
# owner: rwx (读写执行)
# group: r-x (只读执行)
# others: --- (无权限)
DAC 的优点是灵活性高,资源拥有者可以按需分配权限。但致命弱点是权限可以被传递 —— 用户 A 把文件共享给用户 B,B 可以再共享给 C,权限管理最终会失控。在企业级系统中,DAC 的这种"自主性"反而是安全隐患。
MAC:强制访问控制
MAC(Mandatory Access Control)是对 DAC 的纠正,由 Bell-LaPadula 模型在 1970 年代提出,核心思想是由系统强制执行安全策略,用户无权更改。
MAC 为每个主体和客体分配一个安全等级(Security Level),访问决策由两条规则驱动:
| 规则 | 含义 | 目的 |
|---|---|---|
| No Read Up(简单安全属性) | 主体不能读取高于自身等级的客体 | 防止信息泄露(向上泄露) |
| No Write Down(*-属性) | 主体不能向低于自身等级的客体写入 | 防止信息降密(向下流动) |
MAC 在军事和政府系统中广泛使用(如 SELinux),但对商业软件来说过于僵化 —— 现实中的权限需求远比"高/低等级"复杂。一个销售经理需要看到自己团队的客户数据但不能看到其他团队的,这种"同级别但不同范围"的需求 MAC 无法优雅表达。
RBAC:基于角色的访问控制
RBAC(Role-Based Access Control)在 1990 年代由 Ferraiolo 和 Kuhn 提出,2004 年被 NIST 标准化为 ANSI/INCITS 359-2004。RBAC 的核心洞察是:权限不应该直接分配给用户,而应该分配给"角色",用户通过担任角色获得权限。
这个看似简单的间接层解决了 DAC 和 MAC 的核心问题:管理员可以集中管理角色-权限的映射关系,而不需要为每个用户单独配置。当员工转岗时,只需要调整角色分配,而不是重新配置几百条权限。
RBAC 是目前工业界使用最广泛的访问控制模型,下一章将深入展开。
ABAC:基于属性的访问控制
ABAC(Attribute-Based Access Control)由 NIST SP 800-162 定义,是目前最灵活的访问控制模型。ABAC 不预设任何固定的权限结构,而是基于主体属性、客体属性、环境属性和操作属性的组合来动态计算访问决策。
例如一条 ABAC 策略可以表达为:"部门=财务 且 职级>=P7 且 时间在工作日 9:00-18:00 且 IP 在办公网段内,允许读取月度财报"。这种策略在 RBAC 中需要为每种组合创建一个角色,会导致角色爆炸问题。
ABAC 将在后文"ABAC:基于属性的细粒度控制"一节详细展开。
四种模型的对比
| 维度 | DAC | MAC | RBAC | ABAC |
|---|---|---|---|---|
| 控制主体 | 资源拥有者 | 系统管理员 | 系统管理员 | 策略引擎 |
| 灵活性 | 高 | 低 | 中 | 高 |
| 管理复杂度 | 低(但不可控) | 高 | 中 | 高(策略编写) |
| 粒度 | 资源级 | 等级级 | 角色级 | 属性组合级 |
| 典型场景 | 文件系统、Wiki | 军事/政府系统 | 企业管理后台 | 云平台、金融合规 |
| 工业界采用度 | 中 | 低 | 高 | 增长中 |
| 代表实现 | Linux FS、Google Docs | SELinux、Windows MIC | AWS IAM Role、K8s RBAC | AWS IAM Policy、OPA |
在实际系统中,这四种模型并非互斥。最常见的做法是 RBAC 为主、ABAC 补充:用 RBAC 解决 80% 的常规权限分配,用 ABAC 策略处理需要动态判断的细粒度场景。
一句话总结:访问控制模型的演进是一条从"谁拥有"到"谁担任什么角色"再到"满足什么条件"的抽象化路径,每一步都在用更高的抽象换取更强的表达力。
RBAC 深入:从 NIST 标准到工程实践
RBAC 之所以成为工业界的事实标准,很大程度上得益于 NIST 在 2004 年发布的 RBAC 标准(ANSI/INCITS 359-2004)。该标准定义了四个递进的模型层级:RBAC0(核心)、RBAC1(角色层次)、RBAC2(约束)、RBAC3(统一模型)。
RBAC0:核心模型
RBAC0 定义了 RBAC 的最小集合,包含四个基本元素:
| 元素 | 定义 | 关系 |
|---|---|---|
| Users(U) | 系统中的用户集合 | — |
| Roles(R) | 角色集合,代表组织中的职能 | — |
| Permissions(P) | 权限集合,即对资源的操作许可 | — |
| Sessions(S) | 用户激活角色的会话 | 一个 Session 映射到一个 User 和一组激活的 Roles |
NIST 标准中一个常被忽略的概念是 Session。用户可能拥有多个角色,但在一次会话中未必需要全部激活。Session 允许用户按需激活角色子集,这在实现最小权限原则(Principle of Least Privilege)时非常有用 —— 一个 DBA 同时拥有"只读分析"和"数据修改"两个角色,日常查询时只激活只读角色,需要执行 DDL 时才激活修改角色。
/**
* RBAC0 核心模型的 Java 建模
*/
public interface RbacCore {
/** 将角色授予用户 */
void assignRole(UserId userId, RoleId roleId);
/** 将权限授予角色 */
void grantPermission(RoleId roleId, Permission permission);
/** 创建 Session,激活用户的部分角色 */
Session createSession(UserId userId, Set<RoleId> activeRoles);
/** 检查当前 Session 是否拥有指定权限 */
boolean checkAccess(Session session, Permission permission);
}
public record Permission(
String resource, // 资源标识,如 "order", "user"
String action // 操作标识,如 "read", "write", "delete"
) {}
RBAC1:角色层次
RBAC1 在 RBAC0 基础上引入角色继承(Role Hierarchy)。角色之间形成偏序关系(Partial Order),高级角色自动继承低级角色的所有权限。
角色继承分为两种结构:
一般继承(General Hierarchy):允许多继承,角色关系构成有向无环图(DAG)。一个"技术总监"角色可以同时继承"后端负责人"和"前端负责人"的权限。
受限继承(Limited Hierarchy):只允许单继承,角色关系构成树形结构。一条线的层级关系:出纳员 → 财务主管 → 财务总监。
/**
* 角色继承的实现
* 使用邻接表存储继承关系,通过递归上溯收集所有继承的权限
*/
public class RoleHierarchy {
// key: 子角色, value: 直接父角色集合
private final Map<RoleId, Set<RoleId>> parentMap = new HashMap<>();
public void addInheritance(RoleId child, RoleId parent) {
parentMap.computeIfAbsent(child, k -> new HashSet<>()).add(parent);
}
/**
* 获取角色的所有有效权限(含继承)
* 递归收集所有祖先角色的权限,visited 防止循环引用导致栈溢出
*/
public Set<Permission> getEffectivePermissions(RoleId roleId,
Map<RoleId, Set<Permission>> directPerms) {
return collectPermissions(roleId, directPerms, new HashSet<>());
}
private Set<Permission> collectPermissions(RoleId roleId,
Map<RoleId, Set<Permission>> directPerms,
Set<RoleId> visited) {
if (!visited.add(roleId)) {
return Set.of(); // 检测到循环继承,终止递归
}
Set<Permission> result = new HashSet<>(
directPerms.getOrDefault(roleId, Set.of())
);
Set<RoleId> parents = parentMap.getOrDefault(roleId, Set.of());
for (RoleId parent : parents) {
result.addAll(collectPermissions(parent, directPerms, visited));
}
return result;
}
}
RBAC2:约束
RBAC2 在 RBAC0 基础上引入约束(Constraints),用于防止权限冲突和滥用。约束是 RBAC 实现安全合规的关键机制。
静态职责分离(Static Separation of Duty, SSD):在角色分配阶段就禁止冲突。如果"提交人"和"审批人"是互斥角色,那么一个用户不能同时被分配这两个角色。
/**
* 静态职责分离(SSD)实现
*/
public class StaticSoD {
// 互斥角色对
private final Set<RolePair> mutualExclusions = new HashSet<>();
// 角色的最大持有人数
private final Map<RoleId, Integer> cardinalityLimits = new HashMap<>();
public record RolePair(RoleId role1, RoleId role2) {}
/**
* 校验是否可以将角色分配给用户
* @throws SoDViolationException 违反 SSD 约束时抛出
*/
public void validateAssignment(UserId userId, RoleId newRole,
Set<RoleId> existingRoles) {
// 1. 互斥校验
for (RoleId existing : existingRoles) {
if (mutualExclusions.contains(new RolePair(existing, newRole))
|| mutualExclusions.contains(new RolePair(newRole, existing))) {
throw new SoDViolationException(
"角色 %s 与已持有角色 %s 互斥".formatted(newRole, existing));
}
}
// 2. 基数约束
int limit = cardinalityLimits.getOrDefault(newRole, Integer.MAX_VALUE);
int currentCount = countUsersWithRole(newRole);
if (currentCount >= limit) {
throw new SoDViolationException(
"角色 %s 已达持有人数上限 %d".formatted(newRole, limit));
}
}
}
动态职责分离(Dynamic Separation of Duty, DSD):允许用户同时持有互斥角色,但在同一个 Session 中不能同时激活。这比 SSD 更灵活 —— 一个审计人员可以持有"审计"和"运维"两个角色,但每次登录只能选择其中一个身份工作。
先决条件约束(Prerequisite Constraint):用户必须先拥有低级角色,才能被分配高级角色。例如要成为"高级审核员"必须先具备"审核员"角色。
RBAC3:统一模型
RBAC3 = RBAC1 + RBAC2,即同时支持角色层次和约束。这是完整的 RBAC 实现目标。需要注意的是,角色层次和约束之间可能产生冲突 —— 如果角色 A 继承了角色 B,而角色 B 和角色 C 互斥,那么持有角色 A 的用户也不应该同时持有角色 C。约束检查必须沿着继承链传播。
一句话总结:NIST RBAC 标准将 RBAC 分为核心(RBAC0)、层次(RBAC1)、约束(RBAC2)、统一(RBAC3)四层,实际项目应根据业务复杂度逐层引入,避免过度设计。
ABAC:基于属性的细粒度控制
当 RBAC 遇到以下场景时会变得力不从心:
- 权限决策依赖动态条件(时间、IP、设备类型)
- 权限粒度需要到数据行级别(只能看到自己部门的数据)
- 策略组合爆炸,导致角色数量膨胀到难以维护
这正是 ABAC 的用武之地。
XACML 架构
ABAC 最具影响力的标准化框架是 OASIS 的 XACML(eXtensible Access Control Markup Language),它定义了四个核心组件:
| 组件 | 全称 | 职责 |
|---|---|---|
| PAP | Policy Administration Point | 策略管理入口,管理员在此创建和维护策略 |
| PDP | Policy Decision Point | 策略决策引擎,接收请求并计算允许/拒绝 |
| PEP | Policy Enforcement Point | 策略执行点,拦截请求并调用 PDP 获取决策 |
| PIP | Policy Information Point | 属性提供方,为 PDP 补充决策所需的环境信息 |
一次访问控制的完整流程是:PEP 拦截请求 → 组装属性上下文 → 调用 PDP 决策 → PDP 从 PIP 获取补充属性 → PDP 评估策略 → 返回 Permit/Deny → PEP 执行决策。
PDP 做决策时依赖的属性分为四类,这也是 ABAC "基于属性"的核心含义:
| 属性类别 | 含义 | 典型属性 | 数据来源(PIP) |
|---|---|---|---|
| Subject Attributes | 请求发起者的属性 | 部门、职级、角色、地理位置 | 用户目录(LDAP/AD)、HR 系统 |
| Resource Attributes | 被访问资源的属性 | 数据分类级别、所属部门、创建者 | 资源元数据库、文件系统 |
| Action Attributes | 操作本身的属性 | 操作类型(读/写/删除)、是否批量 | 请求上下文 |
| Environment Attributes | 请求发生时的环境 | 当前时间、IP 地址、设备类型、网络位置 | 网络信息、时间服务 |
策略引擎通过组合这四类属性表达访问规则,比如"Subject.dept=财务 AND Resource.level=机密 AND Environment.network=办公网 → Permit"。
策略语言与现代引擎
XACML 本身使用 XML 表达策略,语法冗长。现代实践中更常见的策略引擎和语言包括:
OPA(Open Policy Agent)+ Rego:CNCF 毕业项目,使用 Rego 语言编写策略,以 Sidecar 或 Library 模式嵌入应用。Kubernetes Admission Control、Envoy 外部鉴权都有 OPA 的深度集成。
# OPA Rego 策略示例:只允许财务部 P7 以上在工作时间访问财报
package finance.report
import rego.v1
default allow := false
allow if {
input.subject.department == "finance"
input.subject.level >= 7
is_working_hours
is_office_network
}
is_working_hours if {
now := time.now_ns()
hour := time.clock(now)[0]
hour >= 9
hour < 18
day := time.weekday(now)
day != "Saturday"
day != "Sunday"
}
is_office_network if {
net.cidr_contains("10.0.0.0/8", input.environment.ip)
}
AWS Cedar:Amazon 开源的策略语言,语法比 Rego 更接近自然语言,支持基于实体关系的策略表达。AWS Verified Permissions 底层就是 Cedar。
// Cedar 策略示例:允许文档 owner 执行所有操作
permit (
principal,
action,
resource
) when {
principal == resource.owner
};
// 允许财务角色读取财报,限定工作时间
permit (
principal in Role::"finance-analyst",
action == Action::"read",
resource in ResourceType::"financial-report"
) when {
context.hour >= 9 && context.hour < 18
};
Casbin:Go 语言实现的通用访问控制库,支持 ACL、RBAC、ABAC 等多种模型,通过配置文件切换模型,适合中小型项目快速集成。
RBAC + ABAC 混合模式
在实际工程中,纯 ABAC 的策略管理成本很高(需要有人写和维护策略),而纯 RBAC 又不够灵活。最佳实践是分层组合:
| 层级 | 模型 | 职责 | 示例 |
|---|---|---|---|
| 粗粒度功能权限 | RBAC | 控制用户能访问哪些功能模块 | "运营角色"可以进入订单管理页面 |
| 细粒度数据权限 | ABAC | 控制用户在功能模块内能看到哪些数据 | "华东区运营"只能看到华东区的订单 |
| 敏感操作审批 | ABAC | 高风险操作追加动态条件 | 批量退款需要在办公网络且双人审批 |
这种混合模式下,RBAC 决定"能不能进门",ABAC 决定"进门之后能看到什么"。
一句话总结:ABAC 通过属性组合实现了 RBAC 无法表达的动态细粒度控制,在实际项目中以 RBAC + ABAC 分层混合为最佳实践,粗粒度用 RBAC,细粒度用 ABAC。
权限的分类与粒度
权限系统设计中最容易犯的错误是把"功能权限"和"数据权限"混为一谈。这两者的控制维度、实现机制和治理策略完全不同。
功能权限:能做什么
功能权限控制的是用户能够执行哪些操作,按粒度从粗到细分为三层:
| 粒度 | 描述 | 前端表现 | 后端校验 |
|---|---|---|---|
| 菜单权限 | 控制用户能看到哪些导航菜单 | 动态渲染菜单树 | 路由守卫 |
| 页面权限 | 控制用户能访问哪些页面 | 无权限时跳转 403 | API 网关层校验 |
| 按钮/操作权限 | 控制用户能执行哪些操作 | 按钮禁用/隐藏 | 接口级别鉴权 |
功能权限的建模通常采用树形结构,菜单和按钮构成父子关系:
/**
* 权限资源的树形建模
*/
public class PermissionResource {
private Long id;
private Long parentId; // 父节点 ID,顶层为 null
private String code; // 权限编码,如 "order:refund:approve"
private String name; // 显示名称
private ResourceType type; // MENU / PAGE / BUTTON / API
private String path; // 前端路由路径或后端 API 路径
private int sortOrder; // 同级排序
public enum ResourceType {
MENU, // 菜单
PAGE, // 页面
BUTTON, // 按钮/操作
API // API 接口
}
}
权限编码的设计直接影响后续的权限校验效率。推荐使用 冒号分隔的层级编码(如 order:refund:approve),这样可以通过前缀匹配实现通配:order:* 表示订单模块的所有权限。
数据权限:能看到什么
数据权限控制的是在具有功能权限的前提下,用户能看到哪些数据行或字段。这是权限系统中实现难度最高的部分。
行级权限(Row-Level Security):控制用户能查看或操作哪些数据行。典型场景是"销售只能看到自己负责的客户"。
列级权限(Column-Level Security):控制用户能查看哪些字段。典型场景是"客服能看到订单信息但看不到用户手机号"。
字段级脱敏:介于列级权限和全部可见之间,用户能看到字段但内容被脱敏。典型场景是"手机号显示为 138****5678"。
数据权限的实现有三种主流策略:
策略一:SQL 改写
在 SQL 执行前动态注入过滤条件。MyBatis-Plus 的 DataPermissionInterceptor 就是这种思路。
/**
* 数据权限 SQL 拦截器(基于 MyBatis Interceptor)
* 在 SQL 执行前根据用户的数据权限范围注入 WHERE 条件
*/
@Intercepts(@Signature(
type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
))
public class DataPermissionInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 获取当前用户的数据权限范围
DataScope scope = SecurityContext.currentUser().getDataScope();
if (scope == DataScope.ALL) {
return invocation.proceed(); // 全部数据权限,不注入条件
}
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
BoundSql boundSql = ms.getBoundSql(invocation.getArgs()[1]);
String originalSql = boundSql.getSql();
// 根据数据权限范围注入过滤条件
String filteredSql = switch (scope) {
case DEPT ->
injectDeptFilter(originalSql); // 本部门数据
case DEPT_AND_BELOW ->
injectDeptTreeFilter(originalSql); // 本部门及下属部门
case SELF ->
injectSelfFilter(originalSql); // 仅本人数据
default -> originalSql;
};
// 用改写后的 SQL 替换原始 SQL
resetBoundSql(ms, boundSql, filteredSql);
return invocation.proceed();
}
}
策略二:应用层数据过滤
在 Service 层通过 Repository 的查询参数传入数据权限范围,将过滤逻辑显式化。
/**
* 应用层数据过滤:在 Service 层显式传入数据权限范围
*/
public class OrderQueryService {
public Page<OrderDTO> queryOrders(OrderQueryRequest request) {
DataScope scope = SecurityContext.currentUser().getDataScope();
// 将数据权限范围转换为查询条件
OrderQuery query = OrderQuery.builder()
.status(request.getStatus())
.dateRange(request.getDateRange())
.dataScope(scope) // 显式传入数据权限
.scopeDeptIds(resolveDeptIds(scope)) // 解析为部门 ID 集合
.build();
return orderRepository.findByQuery(query);
}
private Set<Long> resolveDeptIds(DataScope scope) {
return switch (scope) {
case ALL -> Set.of(); // 空集表示不限
case DEPT -> Set.of(SecurityContext.currentUser().getDeptId());
case DEPT_AND_BELOW -> deptService.getSubTree(
SecurityContext.currentUser().getDeptId());
case SELF -> null; // null 表示按 creator 过滤
};
}
}
策略三:数据库视图/行级安全策略
直接利用数据库内置的 RLS(Row-Level Security)能力,在数据库层面强制过滤。PostgreSQL 和 SQL Server 都原生支持 RLS。
-- PostgreSQL 行级安全策略示例
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- 策略:用户只能查看本部门的订单
CREATE POLICY dept_isolation ON orders
FOR SELECT
USING (dept_id = current_setting('app.current_dept_id')::bigint);
-- 策略:管理员可以查看所有数据
CREATE POLICY admin_full_access ON orders
FOR ALL
USING (current_setting('app.current_role') = 'admin');
三种策略的对比:
| 维度 | SQL 改写 | 应用层过滤 | 数据库 RLS |
|---|---|---|---|
| 侵入性 | 低(拦截器透明注入) | 中(需改造查询接口) | 低(数据库层透明) |
| 安全性 | 中(绕过拦截器即失效) | 中(依赖开发规范) | 高(数据库强制执行) |
| 灵活性 | 高 | 高 | 中(受限于 SQL 表达力) |
| 调试难度 | 高(SQL 被改写后不易排查) | 低(逻辑显式) | 中 |
| 适用场景 | MyBatis 技术栈 | 领域驱动设计 | 多租户 SaaS |
多租户场景的权限隔离
在 SaaS 系统中,租户隔离是数据权限的特殊形态。常见的隔离策略按安全级别从高到低排列:
| 策略 | 隔离度 | 成本 | 适用场景 |
|---|---|---|---|
| 独立数据库 | 最高 | 高 | 金融、医疗等强合规行业 |
| 共享数据库 + 独立 Schema | 高 | 中 | 中大型 SaaS |
| 共享表 + tenant_id 字段 | 中 | 低 | 中小型 SaaS |
| 行级安全策略(RLS) | 中 | 低 | 与共享表配合使用 |
无论采用哪种隔离策略,权限系统都需要在每一条查询中强制注入租户上下文,遗漏一处就是跨租户数据泄露。
一句话总结:功能权限控制"能做什么"、数据权限控制"能看到什么",两者必须分层设计,数据权限的实现可在 SQL 改写、应用层过滤、数据库 RLS 三种策略中选择。
用户模型设计:组织、用户组与职位
在基础 RBAC 模型中,权限通过角色分配给用户。但当用户规模达到数百上千人时,逐一为用户分配角色变得不可行。需要在用户和角色之间引入分组机制来实现批量化管理。
组织树
企业的组织架构天然是一棵树。将角色绑定到组织节点,用户加入组织后自动继承该节点的角色,新入职员工归入部门即可获得基础权限,转岗只需变更组织归属。
组织树还承担数据权限的范围定义 —— 将用户的数据可见范围限定为其所在组织及下属组织的数据,这是实现"部门数据隔离"的基础。
/**
* 组织树服务
* 负责根据组织层级归集角色
*/
public class OrganizationRoleService {
private final OrganizationRepository orgRepository;
/**
* 获取用户通过组织继承的所有角色(含祖先节点的角色)
* @param deptId 用户所属部门 ID
*/
public Set<RoleId> getInheritedRoles(Long deptId) {
Set<RoleId> roles = new HashSet<>();
Organization current = orgRepository.findById(deptId);
while (current != null) {
roles.addAll(current.getBoundRoles());
current = current.getParentId() != null
? orgRepository.findById(current.getParentId())
: null;
}
return roles;
}
}
public class Organization {
private Long id;
private Long parentId;
private String name;
private String path; // 物化路径,如 "/1/3/7/",加速子树查询
private Set<RoleId> boundRoles; // 该组织绑定的角色
// getters...
}
用户组
用户组和组织的区别在于:组织反映行政架构,用户组反映协作关系。一个跨部门项目组、一批需要临时权限的外包人员、所有参与某次灰度测试的用户,都可以用用户组建模。
用户组与角色是多对多关系。角色分配给用户组,组内所有成员自动获得权限。与组织树不同,用户组通常是扁平结构,没有层级继承。
职位
同一个组织内部,不同职位的人需要不同的权限。财务部的总监、主管和出纳,虽然同属财务组织,但权限范围差异很大。
职位与角色的关系是多对多 —— 一个职位可以对应多个角色,一个角色也可以被多个职位使用。用户与职位通常是一对一关系(一个人在组织内只有一个职位)。
完整的用户权限归集
当组织、用户组、职位同时存在时,一个用户的有效权限是多个来源的并集:
用户的有效权限 = 直接分配的角色权限 ∪ 所属组织(含祖先节点)绑定的角色权限 ∪ 所属用户组绑定的角色权限 ∪ 当前职位绑定的角色权限
这个归集逻辑在用户登录或权限变更时执行,计算结果缓存到 Redis,避免每次请求都遍历整棵组织树。
一句话总结:组织树、用户组、职位是用户分组的三个维度,分别对应行政架构、协作关系和职能角色,用户的有效权限是所有维度的权限并集。
数据库设计与核心数据模型
基础五表
最小可用的 RBAC 数据模型由五张表组成:用户表、角色表、权限表,加上两张关联表。
-- 1. 用户表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(128) NOT NULL, -- BCrypt 哈希
real_name VARCHAR(64),
dept_id BIGINT, -- 所属部门
status TINYINT NOT NULL DEFAULT 1, -- 1:启用 0:禁用
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_dept (dept_id)
);
-- 2. 角色表
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(64) NOT NULL UNIQUE, -- 角色编码,如 "finance_manager"
name VARCHAR(128) NOT NULL, -- 角色名称
parent_id BIGINT DEFAULT NULL, -- 父角色(RBAC1 角色继承)
data_scope TINYINT NOT NULL DEFAULT 0, -- 数据权限范围:0全部 1本部门 2本部门及下属 3仅本人(注①)
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_parent (parent_id)
);
-- 3. 权限资源表(树形结构)
CREATE TABLE sys_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
parent_id BIGINT DEFAULT NULL, -- 父节点
code VARCHAR(128) NOT NULL UNIQUE, -- 权限编码 "order:refund:approve"
name VARCHAR(128) NOT NULL,
type TINYINT NOT NULL, -- 1:菜单 2:页面 3:按钮 4:API
path VARCHAR(256), -- 前端路由 / API 路径
tree_path VARCHAR(512), -- 物化路径 "/1/5/12/",加速子树查询
sort_order INT NOT NULL DEFAULT 0,
INDEX idx_parent (parent_id),
INDEX idx_tree_path (tree_path)
);
-- 4. 用户-角色关联表
CREATE TABLE sys_user_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
UNIQUE KEY uk_user_role (user_id, role_id),
INDEX idx_role (role_id)
);
-- 5. 角色-权限关联表
CREATE TABLE sys_role_permission (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
UNIQUE KEY uk_role_perm (role_id, permission_id),
INDEX idx_permission (permission_id)
);
注①:将 data_scope 放在角色表上是最简方案 —— 用一个枚举值定义数据可见范围,适合"全部/本部门/仅本人"这类固定维度的场景。当数据权限规则变得复杂(如按区域、按客户标签、按时间范围过滤)时,应将数据权限策略独立为单独的配置表或 ABAC 策略,避免与角色强耦合。这一点在后文"常见陷阱"中有进一步讨论。
扩展表
在基础五表之上,支撑完整模型还需要以下扩展表:
-- 6. 组织/部门表
CREATE TABLE sys_dept (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
parent_id BIGINT DEFAULT NULL,
name VARCHAR(128) NOT NULL,
path VARCHAR(512), -- 物化路径 "/1/3/7/"
sort_order INT NOT NULL DEFAULT 0,
INDEX idx_parent (parent_id),
INDEX idx_path (path)
);
-- 7. 用户组表
CREATE TABLE sys_user_group (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(128) NOT NULL
);
-- 8. 用户-用户组关联表
CREATE TABLE sys_user_group_member (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
group_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
UNIQUE KEY uk_group_user (group_id, user_id)
);
-- 9. 用户组-角色关联表
CREATE TABLE sys_user_group_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
group_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
UNIQUE KEY uk_group_role (group_id, role_id)
);
-- 10. 组织-角色关联表(组织绑定角色,成员自动继承)
CREATE TABLE sys_dept_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
dept_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
UNIQUE KEY uk_dept_role (dept_id, role_id)
);
-- 11. 职位表
CREATE TABLE sys_position (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(64) NOT NULL UNIQUE, -- 职位编码,如 "finance_director"
name VARCHAR(128) NOT NULL, -- 职位名称,如 "财务总监"
dept_id BIGINT, -- 所属部门(可选)
sort_order INT NOT NULL DEFAULT 0
);
-- 12. 职位-角色关联表
CREATE TABLE sys_position_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
position_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
UNIQUE KEY uk_position_role (position_id, role_id)
);
-- 13. 用户-职位关联表(通常一对一,但预留多对多扩展性)
CREATE TABLE sys_user_position (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
position_id BIGINT NOT NULL,
UNIQUE KEY uk_user_position (user_id, position_id)
);
-- 14. 角色互斥表(SSD 约束)
CREATE TABLE sys_role_exclusion (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_id_a BIGINT NOT NULL,
role_id_b BIGINT NOT NULL,
UNIQUE KEY uk_exclusion (role_id_a, role_id_b)
);
权限树的存储方案
权限资源是树形结构,存储方案的选择直接影响查询性能:
| 方案 | 原理 | 查询子树 | 查询祖先 | 写入性能 | 适用场景 |
|---|---|---|---|---|---|
| 邻接表(parent_id) | 每个节点存储父节点 ID | 递归查询 | 递归查询 | O(1) | 层级浅(3-5 层)、写多读少 |
| 物化路径(path) | 存储从根到当前节点的路径 | LIKE 前缀匹配 | 字符串截取 | O(depth) | 层级中等、读多写少 |
| 闭包表(closure table) | 存储所有祖先-后代对 | 单次 JOIN | 单次 JOIN | O(depth) | 层级深、频繁子树查询 |
权限树一般不超过 4-5 层(菜单→子菜单→页面→按钮),且写入频率远低于读取频率,物化路径是性价比最高的选择。上面的建表语句中 sys_dept 预留了 path 字段,sys_permission 预留了 tree_path 字段,都是为物化路径存储做准备。
一句话总结:基础五表(用户、角色、权限、两张关联表)是 RBAC 的最小数据模型,随着业务复杂度增长逐步引入组织表、用户组表、职位表和约束表,权限树推荐使用物化路径存储。
权限校验的技术实现
权限模型和数据库设计完成后,最后一步是在应用层实现高效的权限校验。
三种校验方式
方式一:过滤器/拦截器(全局统一)
在请求进入 Controller 之前,通过 Filter 或 Interceptor 统一校验。适合 URL 粒度的权限控制。
/**
* Spring MVC 拦截器实现 URL 级别权限校验
*/
public class PermissionInterceptor implements HandlerInterceptor {
private final PermissionService permissionService;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
// URL 模式 → 所需权限编码 的映射(启动时从数据库加载)
// key 示例: "POST:/api/orders/*/refund" value 示例: "order:refund:approve"
private final Map<String, String> urlPermissionMap;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String uri = request.getRequestURI();
String method = request.getMethod();
String lookupPath = method + ":" + uri;
// 1. 匹配 URL 对应的权限编码
String requiredPermission = urlPermissionMap.entrySet().stream()
.filter(e -> pathMatcher.match(e.getKey(), lookupPath))
.map(Map.Entry::getValue)
.findFirst().orElse(null);
if (requiredPermission == null) {
return true; // 未配置权限的接口,放行(或可改为默认拒绝)
}
// 2. 获取当前用户的权限集合(从缓存读取)
Long userId = SecurityContext.getCurrentUserId();
Set<String> userPermissions = permissionService.getUserPermissions(userId);
// 3. 校验(支持通配符匹配)
if (!matchesAny(userPermissions, requiredPermission)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return false;
}
return true;
}
/**
* 支持通配符的权限匹配
* "order:*" 可以匹配 "order:refund:approve"
*/
private boolean matchesAny(Set<String> owned, String required) {
for (String perm : owned) {
if (perm.equals(required)) return true;
if (perm.endsWith(":*")) {
String prefix = perm.substring(0, perm.length() - 1);
if (required.startsWith(prefix)) return true;
}
}
return false;
}
}
方式二:注解(方法级声明式)
在 Controller 方法上通过注解声明所需权限,由 AOP 切面统一处理。语义更清晰,适合操作级粒度。
/**
* 自定义权限注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermission {
/** 所需权限编码,支持多个 */
String[] value();
/** 多个权限的逻辑关系:AND(全部满足)或 OR(满足其一) */
Logical logical() default Logical.AND;
}
/**
* 权限注解的 AOP 切面
*/
@Aspect
@Component
public class PermissionAspect {
private final PermissionService permissionService;
@Around("@annotation(requiresPermission)")
public Object checkPermission(ProceedingJoinPoint pjp,
RequiresPermission requiresPermission) throws Throwable {
String[] required = requiresPermission.value();
Logical logical = requiresPermission.logical();
Long userId = SecurityContext.getCurrentUserId();
Set<String> owned = permissionService.getUserPermissions(userId);
boolean pass = (logical == Logical.AND)
? Arrays.stream(required).allMatch(r -> permissionService.matchesAny(owned, r))
: Arrays.stream(required).anyMatch(r -> permissionService.matchesAny(owned, r));
if (!pass) {
throw new ForbiddenException(
"权限不足: " + String.join(", ", required));
}
return pjp.proceed();
}
}
// 使用示例
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@PostMapping("/{id}/refund")
@RequiresPermission({"order:refund:approve"})
public Result approveRefund(@PathVariable Long id) {
// 业务逻辑
}
}
方式三:编程式(灵活动态)
在业务代码中显式调用权限检查,适合权限逻辑与业务逻辑耦合度高的场景。
public class OrderService {
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
// 动态权限判断:只有订单创建者或管理员可以取消
Long currentUserId = SecurityContext.getCurrentUserId();
if (!order.getCreatorId().equals(currentUserId)
&& !permissionService.hasRole(currentUserId, "admin")) {
throw new ForbiddenException("只有订单创建者或管理员可以取消订单");
}
// 取消逻辑...
}
}
权限缓存策略
权限校验发生在每个请求的关键路径上,必须做到毫秒级响应。将用户权限数据缓存到 Redis 是标准做法:
/**
* 权限缓存服务
* 用户登录时加载权限到 Redis,权限变更时主动失效
*/
@Service
public class PermissionCacheService {
private static final String CACHE_KEY_PREFIX = "perm:user:";
private static final String LOCK_KEY_PREFIX = "perm:lock:";
private static final Duration CACHE_TTL = Duration.ofMinutes(30);
private final StringRedisTemplate redis;
/**
* 获取用户权限(缓存优先)
* 使用 Redis SETNX 作为分布式锁,防止缓存击穿时多线程并发加载数据库
*/
public Set<String> getUserPermissions(Long userId) {
String key = CACHE_KEY_PREFIX + userId;
Set<String> cached = redis.opsForSet().members(key);
if (cached != null && !cached.isEmpty()) {
return cached;
}
// 缓存未命中:用分布式锁防止缓存击穿
String lockKey = LOCK_KEY_PREFIX + userId;
Boolean locked = redis.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(locked)) {
try {
// Double-check:获取锁后再检查一次缓存(可能被其他线程刚写入)
cached = redis.opsForSet().members(key);
if (cached != null && !cached.isEmpty()) {
return cached;
}
Set<String> permissions = loadFromDb(userId);
redis.opsForSet().add(key, permissions.toArray(new String[0]));
redis.expire(key, CACHE_TTL);
return permissions;
} finally {
redis.delete(lockKey);
}
}
// 未获取到锁,短暂等待后重试读缓存
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
cached = redis.opsForSet().members(key);
return (cached != null && !cached.isEmpty()) ? cached : loadFromDb(userId);
}
/**
* 权限变更时主动失效缓存
* 当角色权限变更时,需要批量失效该角色关联的所有用户
*/
public void invalidateByRole(Long roleId) {
List<Long> userIds = userRoleRepository.findUserIdsByRoleId(roleId);
List<String> keys = userIds.stream()
.map(id -> CACHE_KEY_PREFIX + id)
.toList();
redis.delete(keys);
}
}
性能优化:位运算权限码
当权限数量可控(通常 < 64 个核心操作权限)时,可以用位运算大幅提升校验性能:
/**
* 位运算权限码
* 将权限映射为 long 的每一位,校验只需一次 AND 操作
*/
public class BitPermission {
// 权限定义(每个权限占一个 bit 位)
public static final long READ = 1L; // 0001
public static final long WRITE = 1L << 1; // 0010
public static final long DELETE = 1L << 2; // 0100
public static final long APPROVE = 1L << 3; // 1000
/**
* 校验是否拥有指定权限
* 单次 AND 运算,O(1) 时间复杂度
*/
public static boolean hasPermission(long userPermBits, long requiredBits) {
return (userPermBits & requiredBits) == requiredBits;
}
/**
* 授予权限(OR 运算)
*/
public static long grant(long current, long newPerm) {
return current | newPerm;
}
/**
* 撤销权限(AND NOT 运算)
*/
public static long revoke(long current, long permToRevoke) {
return current & ~permToRevoke;
}
}
// 使用示例
long userPerms = BitPermission.grant(0L, BitPermission.READ | BitPermission.WRITE);
boolean canDelete = BitPermission.hasPermission(userPerms, BitPermission.DELETE); // false
boolean canRead = BitPermission.hasPermission(userPerms, BitPermission.READ); // true
位运算适合权限数量固定、校验频率极高的核心路径。对于动态增减的菜单级权限,仍然使用 Set 匹配方式。
一句话总结:权限校验可以通过拦截器(URL 级)、注解(方法级)、编程式(业务逻辑级)三种方式实现,配合 Redis 缓存和位运算优化可以做到每次校验微秒级完成。
微服务架构下的权限设计
单体应用的权限校验在进程内完成,逻辑相对简单。进入微服务架构后,权限系统面临三个新挑战:身份如何跨服务传播、鉴权逻辑放在哪里、服务间调用的权限如何管控。
网关统一鉴权 vs 服务自主鉴权
两种主流方案各有适用场景:
| 维度 | 网关统一鉴权 | 服务自主鉴权 |
|---|---|---|
| 核心思路 | 网关做完所有权限校验,服务信任网关转发的请求 | 网关只做认证(验 Token),授权由各服务自行处理 |
| 优点 | 鉴权逻辑集中,服务无感知 | 服务自治,可根据领域模型定制鉴权逻辑 |
| 缺点 | 网关变重,成为性能瓶颈;细粒度权限难以在网关层表达 | 鉴权逻辑分散,维护成本高 |
| 适用场景 | 权限粒度在 URL/API 级别 | 权限粒度涉及业务规则和数据权限 |
实际项目中最常见的是分层鉴权:网关负责粗粒度鉴权(认证 + URL 级权限),服务负责细粒度鉴权(操作权限 + 数据权限)。
OAuth 2.0 + OIDC 集成
在微服务架构中,OAuth 2.0 + OpenID Connect 是认证授权的事实标准协议栈:
| 协议 | 职责 | 核心概念 |
|---|---|---|
| OAuth 2.0 | 授权框架 | Access Token、Refresh Token、Scope、Grant Type |
| OpenID Connect | 认证层(基于 OAuth 2.0) | ID Token、UserInfo Endpoint、Claims |
OAuth 2.0 定义了获取 Access Token 的标准流程(Authorization Code、Client Credentials 等),Access Token 中的 scope 字段表达了粗粒度的授权范围。
OIDC 在 OAuth 2.0 之上增加了 ID Token(JWT 格式),包含用户身份信息(sub、name、email 等),解决了 OAuth 2.0 只有授权没有认证的问题。
在权限系统中,推荐的集成方式是:
- 认证服务(如 Keycloak、Casdoor)负责签发 JWT Token,Token 的 Claims 中携带用户 ID、角色列表等身份信息
- API 网关 验证 Token 签名和有效期,解析 Claims 并作为 Header 透传给下游服务
- 业务服务 从 Header 中读取身份上下文,执行业务级权限校验
JWT Token 与权限上下文透传
JWT Token 中应该携带多少权限信息?这是一个权衡:
| 策略 | Token 内容 | 优点 | 缺点 |
|---|---|---|---|
| 轻量 Token | 只含 userId、roles | Token 体积小,签发快 | 每次请求需查询权限详情 |
| 重量 Token | 含完整权限列表 | 服务端无需查库 | Token 体积大,权限变更不实时 |
| 折中方案 | 含 roles + 权限版本号 | 兼顾体积和实时性 | 需要实现版本比对逻辑 |
推荐折中方案:Token 中携带角色列表和权限版本号(permission_version),服务端缓存权限数据并通过版本号判断是否需要刷新。
/**
* 权限上下文透传
* 网关解析 JWT 后将身份信息通过 Header 传递给下游
*/
public class AuthGatewayFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = extractToken(exchange.getRequest());
Claims claims = jwtService.parseAndVerify(token);
// 将身份信息注入 Header,透传给下游服务
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.header("X-User-Id", claims.getSubject())
.header("X-User-Roles", String.join(",", claims.get("roles", List.class)))
.header("X-User-Dept-Id", claims.get("dept_id", String.class))
.header("X-Perm-Version", claims.get("perm_version", String.class))
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
}
}
服务间调用的权限管控
微服务之间的内部调用也需要权限管控,防止"A 服务调用 B 服务绕过权限检查"的问题。
方案一:透传用户上下文 — 内部调用时将原始用户的身份信息透传(通过 Header 或 gRPC Metadata),被调用服务依然按用户身份做权限校验。适合用户操作触发的链式调用。
方案二:服务身份认证 — 使用 mTLS 或 Service Account Token 标识调用方服务的身份,被调用服务基于"调用方是谁"来决定是否允许。适合服务间的后台任务和数据同步。Kubernetes 的 ServiceAccount + RBAC 就是这种模式。
方案三:混合模式 — 有用户上下文时用方案一,无用户上下文(如定时任务、消息消费)时用方案二。
一句话总结:微服务架构下推荐网关粗粒度鉴权 + 服务细粒度鉴权的分层模式,通过 OAuth 2.0/OIDC 签发 JWT Token,网关解析后将身份上下文透传给下游服务。
常见陷阱与最佳实践
角色爆炸
随着业务增长,角色数量失控增长到数百甚至上千个,每个细微的权限差异都创建一个新角色。根本原因是将本应由 ABAC 策略表达的细粒度条件硬编码为 RBAC 角色。
解决方案是引入 ABAC 层处理动态条件,RBAC 只保留与组织职能对应的核心角色。定期审计角色使用率,合并低频角色。
超级管理员绕过检查
很多系统对超级管理员角色直接 return true 跳过所有权限检查。这带来两个风险:超管账号被盗后无任何防线;审计日志中超管的操作无法追溯权限依据。
最佳实践是超管也走正常的权限校验流程,只是拥有所有权限。同时对超管的敏感操作(如修改权限配置、删除数据)增加二次确认或双人审批。
前端权限 ≠ 安全
前端通过隐藏菜单和按钮实现的"权限控制"只是用户体验优化,不是安全机制。用户可以通过浏览器开发工具、直接调用 API 绕过前端控制。后端必须独立校验每一个接口的权限,前后端的权限配置需要保持同步但独立实现。
权限变更的实时性
用户权限被修改后(如移除某个角色),如果使用了 JWT Token 且 Token 未过期,用户仍然持有旧权限。解决方案:
| 策略 | 实时性 | 复杂度 |
|---|---|---|
| 缩短 Token 有效期(如 5 分钟) | 高 | 低(但增加 Token 刷新频率) |
| 权限版本号比对 | 高 | 中(需要实现版本机制) |
| Token 黑名单 | 即时 | 高(需要维护分布式黑名单) |
| 强制重新登录 | 即时 | 低(但用户体验差) |
推荐权限版本号方案:权限变更时递增版本号,服务端校验时比对 Token 中的版本号与最新版本号,不一致则重新加载权限。
数据权限与功能权限耦合
常见的错误是在同一张配置表里混合功能权限和数据权限,导致管理混乱。例如用一个角色同时定义"能访问订单页面"和"只能看华东区的订单"。
正确做法是分层解耦:功能权限通过 RBAC 角色管理,数据权限通过独立的数据范围策略(DataScope)管理,两者在运行时组合生效。
审计:AAA 的最后一环
第一章提到了 AAA 模型中的 Accounting(审计),但很多权限系统在实现时只做了认证和授权,忽视了审计。审计日志是事后追溯、合规检查和异常检测的基础,缺失审计的权限系统是不完整的。
权限审计至少应记录以下信息:谁(用户 ID + 身份信息)、什么时候(时间戳)、做了什么(操作类型 + 目标资源)、结果是什么(成功/拒绝 + 拒绝原因)、在哪里(IP、设备、Session ID)。审计日志应写入独立存储(如 Elasticsearch),与业务库分离,并且审计记录本身不可被修改或删除。
对于敏感操作(权限配置变更、数据导出、批量删除),除了审计日志外还应触发实时告警,而不只是事后查看。
最小权限原则的落地
最小权限原则(Principle of Least Privilege)是权限设计的第一原则:只授予完成工作所必需的最小权限集合。落地建议:
- 新用户默认无权限,而不是默认拥有基础角色
- 定期审计权限使用情况,回收 90 天内未使用的权限
- 敏感权限设置有效期,到期自动回收
- 临时权限通过审批流程授予,自动过期
一句话总结:权限系统的常见陷阱多源于"图省事" —— 跳过校验、前端代替后端、角色代替策略,遵循最小权限原则和分层解耦是避免这些陷阱的根本方法。
总结
权限系统的设计可以归纳为三个层面的决策:
模型层面:选择合适的访问控制模型。大多数企业系统以 RBAC 为主体,在需要细粒度动态控制的场景(数据权限、条件审批)引入 ABAC 策略。不要为了"先进"而选择 ABAC —— 策略的编写和维护成本远高于 RBAC 的角色配置。
架构层面:在单体应用中,权限校验在过滤器或 AOP 层完成;在微服务架构中,采用网关粗粒度鉴权 + 服务细粒度鉴权的分层模式,通过 JWT Token 透传身份上下文。
工程层面:功能权限和数据权限分层实现,权限数据缓存到 Redis 并通过版本号保证一致性,核心路径可以用位运算优化到微秒级。
贯穿始终的设计原则:最小权限(只给必要的权限)、职责分离(互斥角色不能共存)、纵深防御(前端、网关、服务端多层校验,任何一层都不能独立保证安全)、可审计(所有访问行为可追溯,形成 AAA 闭环)。
权限系统不是一次性工程,而是随着业务演进持续迭代的基础设施。从 RBAC0 起步,在实际需求驱动下逐步引入角色继承、约束、ABAC 策略,这种渐进式演进远比一开始就搭建"理想模型"更务实。