
不少团队做权限,第一反应是 RBAC。我们一开始也是这套:用户挂角色,角色挂菜单,菜单上放几个操作权限点。早期后台功能少,账号也少,完全够用。
后来就不太行了。系统里开始出现文档权限、订单数据权限、外部协作者权限,需求逐渐变成"只能看本部门订单""财务只能导金额超过 100 万的合同""文档要分公开、部门内、仅自己可见"。要是继续沿用 RBAC,就得设计一堆 order:read:department、document:read:private 这种权限点,或者干脆在 service 方法第一行写 if 判断。
不管哪种,最后都会很难受。这里的问题不是角色不够多,而是权限判断里的"资源属性"和"环境上下文"已经绕不开 RBAC 了。
于是我们把权限模型往 ABAC 移了一部分:不是全替换,而是在核心资源上引入属性判断,最后沉淀出了一个比较薄的策略引擎。
ABAC 说穿了就是"用属性拼布尔表达式"
ABAC 不像 RBAC 那样在"角色"和"权限"之间建一堆绑定关系。它把一次访问拆成四件事:
发起的人是谁,想操作什么资源,要干什么动作,以及现在处于什么环境。人和资源都抽成属性,能不能放行就看策略表达式算出来是 true 还是 false。
比如:
text
subject.department == resource.department && action == "read"
这条策略想表达的就是"用户只能看本部门资源"。放在 RBAC 里你可能要建角色,但如果再加一个"仅限工作时间"的限制,RBAC 又得换一拨角色或者改拦截器。
ABAC 的空间就简单了:
text
subject.department == resource.department
&& action == "read"
&& environment.hour >= 9 && environment.hour <= 18
所以我的经验是:先别急着自己做复杂策略语言,核心就是把条件抽象成一组属性对,剩下的就是找一个地方执行这些条件。
Spring Security 的扩展点,以及我踩过的配置坑
Spring Security 本身的方法级安全可以通过 @PreAuthorize 实现。我们使用的是:
java
@PreAuthorize("hasPermission(#orderId, 'Order', 'read')")
这里 hasPermission 会被转发到 PermissionEvaluator 接口。实现它不需要动认证逻辑,只是把原来的"角色判断"换成"策略引擎判断"。
先放配置。注意如果你用的 Spring Security 6.x,不要再依赖老版本的 @EnableGlobalMethodSecurity,直接使用:
java
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(PolicyEngine policyEngine) {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(new AbacPermissionEvaluator(policyEngine));
return handler;
}
}
然后是 PermissionEvaluator 的实现。
PermissionEvaluator 有两个 hasPermission 重载。一个传入目标对象,一个传入目标 ID 和目标类型。大多数业务场景用的是 ID 那个,因为服务层通常在拿到实体前就需要拦截。
java
public class AbacPermissionEvaluator implements PermissionEvaluator {
private final PolicyEngine policyEngine;
public AbacPermissionEvaluator(PolicyEngine policyEngine) {
this.policyEngine = policyEngine;
}
@Override
public boolean hasPermission(Authentication authentication,
Object targetDomainObject,
Object permission) {
return policyEngine.evaluate(
authentication,
targetDomainObject,
permission.toString()
);
}
@Override
public boolean hasPermission(Authentication authentication,
Serializable targetId,
String targetType,
Object permission) {
return policyEngine.evaluate(
authentication,
targetId,
targetType,
permission.toString()
);
}
}
这里有个容易踩的细节:targetId 类型是 Serializable,不是 Object。写代码时如果直接写 Object,Spring Security 6 下会编译报错或者提示方法签名不匹配。
配置完成后,业务代码入口就很干净了:
java
@PreAuthorize("hasPermission(#orderId, 'Order', 'read')")
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
有一个必须记住的点:#orderId 这种 SpEL 参数引用,依赖于方法参数名。如果你没开 -parameters 编译参数,Spring 拿不到参数名,就得在参数上显式加 @Param("orderId")。
策略存储,不建议自己搞一套条件树 DSL
很多 ABAC 框架喜欢把策略设计成 JSON 条件树,比如:
json
{
"conditions": {
"all": [
{
"equal": {
"subject.department": "resource.department"
}
}
]
}
}
理论上没问题,但自己实现一套"条件树解析器"成本不低。要支持 and、or、not、比较器、集合、日期范围,这些逻辑写好了基本又重新造了一个规则引擎。
我后来还是选了一个更务实的方案:策略里存 SpEL 字符串。
策略对象长得像这样:
java
public class Policy {
private String policyId;
private String resourceType;
private String action;
private Effect effect; // ALLOW 或 DENY
private String condition;
}
存到数据库或者配置中心的格式类似:
json
{
"policyId": "order-read-dept-only",
"resourceType": "Order",
"action": "read",
"effect": "ALLOW",
"condition": "#subject.deptId == #resource.deptId"
}
condition 可以直接给 SpEL 解析。在 SpEL 上下文中,我们放入三个变量:
#subject:当前用户属性#resource:目标资源属性#environment:环境属性
Spring 的 StandardEvaluationContext 默认对 Map 的属性访问支持不强,所以需要手动加一个 MapAccessor:
java
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariable("subject", subjectAttrs);
context.setVariable("resource", resourceAttrs);
context.setVariable("environment", envAttrs);
context.addPropertyAccessor(new MapAccessor());
比如上面那条策略,resource.deptId 可以直接点出来。如果不加 MapAccessor,就只能用 #resource['deptId'],写策略的人会骂你的。
PolicyEngine 的决策流程
我自己实现的核心引擎非常朴素,没有复杂的组合算法。
策略列表里分两类:ALLOW 和 DENY。决策时我采用"DENY 优先":
- 先遍历所有命中该资源和动作的 DENY 策略;
- 如果有任意一条 DENY 条件成立,直接拒绝;
- 再遍历 ALLOW,只要有任意一条成立就放行;
- 如果都没有命中,默认拒绝。
这样规则管理上有一个很明确的逃生通道:某个高危动作哪怕默认开放,你也可以加一条全局 DENY 策略覆盖掉。
核心逻辑大致如下:
java
public boolean evaluate(Authentication auth,
Serializable resourceId,
String resourceType,
String action) {
if (auth == null) {
return false;
}
Map<String, Object> subjectAttrs = subjectAttributeResolver.resolve(auth.getPrincipal());
// 根据资源类型找到对应的业务属性加载器
ResourceAttributeResolver resolver = resourceResolverRegistry.get(resourceType);
if (resolver == null) {
// 没有注册 resolver 的资源,不建议默认放行
return false;
}
Map<String, Object> resourceAttrs = resolver.resolve(resourceId);
Map<String, Object> envAttrs = environmentContext.build();
Policy[] policies = policyRetriever.listByTarget(resourceType, action);
if (policies.length == 0) {
return false;
}
// 先看 DENY
for (Policy policy : policies) {
if (policy.getEffect() == Policy.Effect.DENY
&& evaluateCondition(policy.getCondition(), subjectAttrs, resourceAttrs, envAttrs)) {
return false;
}
}
// 再看 ALLOW
for (Policy policy : policies) {
if (policy.getEffect() == Policy.Effect.ALLOW
&& evaluateCondition(policy.getCondition(), subjectAttrs, resourceAttrs, envAttrs)) {
return true;
}
}
return false;
}
这里有个很现实的性能问题:ResourceAttributeResolver 如果每次决策都去查一次数据库,那系统根本扛不住。我的处理方式有两种:
- 对于单条数据操作,允许查一次库,但要在 resolver 里加短期缓存;
- 对于列表查询,不走 PolicyEngine 逐条判断,而是把权限条件转成 SQL,这一步后面再讲。
三个真实业务场景的例子
部门订单隔离
策略配置:
json
{
"policyId": "order-read-dept-only",
"resourceType": "Order",
"action": "read",
"effect": "ALLOW",
"condition": "#subject.deptId == #resource.deptId"
}
OrderResourceResolver 里根据订单查出 deptId 放到资源属性中。业务方法上只写:
java
@PreAuthorize("hasPermission(#orderId, 'Order', 'read')")
public Order getOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
这里还隐含一个值得注意的点:如果传入的是 orderId,你无法避免在进入 service 前再查一次订单数据。所以对于高频接口,最好在调用时就把订单对象传进来,或者在 Controller 层确保缓存已经命中。
文档按公开范围控制
文档有 visibility 字段,取值为 PUBLIC、DEPARTMENT、PRIVATE。策略可以配置三个 ALLOW:
json
{
"policyId": "doc-read-public",
"effect": "ALLOW",
"condition": "#resource.visibility == 'PUBLIC'"
}
json
{
"policyId": "doc-read-dept",
"effect": "ALLOW",
"condition": "#resource.visibility == 'DEPARTMENT' && #resource.deptId == #subject.deptId"
}
json
{
"policyId": "doc-read-private",
"effect": "ALLOW",
"condition": "#resource.visibility == 'PRIVATE' && #resource.ownerId == #subject.userId"
}
这种场景如果用 RBAC 表达,最终一定会出现 document:read:public、document:read:department、document:read:private 这类权限点,然后你再想加一个"密级"维度,死路一条。
外部协作者只能在工作日操作
以前我们可能把时间判断写在 service 里,现在策略里直接配:
json
{
"policyId": "contract-import-work-time",
"resourceType": "Contract",
"action": "import",
"effect": "ALLOW",
"condition": "#environment.dayOfWeek <= 5 && #environment.currentTime >= '09:00' && #environment.currentTime <= '18:00'"
}
#environment.dayOfWeek 和 #environment.currentTime 是 EnvironmentContext 里预生成好的属性,避免策略里去执行 T(java.time.LocalDateTime).now() 这类写法。
不过这里也要说句实话:策略表达式不该让业务人员直接写。SpEL 能调方法,如果你开放给运营自己去配,绝对有人写出一堆看不懂的黑魔法。最终我们还是做了一层薄薄的策略后台,把大多数条件映射成了表单控件,底层还是存 SpEL,只是不暴露出来。
列表数据权限,最好单独走 SQL 过滤
使用 PolicyEngine 逐条判断单个资源没问题,但如果查询订单列表,返回一千条订单,总不能每条都跑一次 ABAC。
我们采用的方式是把"列表型数据权限"和"单点授权"分开。
单点授权策略只回答 can or cannot。列表型数据权限专门配置一个可以生成 SQL 片段的条件,例如:
json
{
"policyId": "order-list-dept",
"resourceType": "Order",
"action": "list",
"effect": "ALLOW",
"sql": "dept_id = {deptId}"
}
然后在 MyBatis 层做拦截处理。大致思路是拦截 Executor.query,在 SQL 解析前判断是否命中数据权限策略,再动态拼接 where 条件。
这里我倾向于不手写 SQL 字符串拼接,而是让代码经过 SQL 解析器修改 AST。如果你用 MyBatis-Plus,可以去看它提供的 DataPermissionHandler,它会把 SQL 解析成语法树后再处理。手写条件拼接一旦遇到子查询、别名,很容易出事。
策略热更新,做了一个不完美但够用的方案
策略一开始放在配置中心,后面发现上线的策略需要经常调整,每次通过配置中心发布也有点重。
我的方案是:
- 策略存在数据库,同时在每个应用节点上用 Caffeine 缓存活跃策略;
- 管理后台修改策略后,通过 Redis Pub/Sub 广播一个
policy:refresh事件; - 应用节点收到事件后重新加载策略,并递增本地的策略版本号;
- 同时每 5 分钟主动拉一次策略版本号,和本地比对,防止 Redis 消息丢失。
决策缓存同样和策略版本号绑定。如果用户权限发生变化,比如被移出部门,短暂时间内缓存还会命中旧决策。我们采取的办法很简单:决策缓存 TTL 设置得很短,默认 8 秒。
这不是一个特别"实时"的方案,但 ABAC 策略变动频率本来就不高,8 秒内生效已经可以让运营接受了。
还有几个避坑经验
Spring Security 方法安全对某些调用方式不生效。 this.getDoc(id) 这种内部自调用不会经过方法安全代理,必须自己注入代理或者拆到另一个 Bean 里。
不要在策略表达式里查库。 SpEL 可以调用 Spring Bean,但如果策略里写 @someBean.loadResource(#id),性能问题会非常隐蔽。我的原则是:策略需要的属性必须提前放到上下文里,策略引擎只做纯计算。
DENY 要能覆盖所有东西。 在生产系统里,有一条全局 DENY 能把所有动态上线的策略临时关掉,相当于一个"总开关"。不然新的策略配置错了,你只能紧急改代码重启。
决策日志一定要打。 每次授权都记录 policyId、决策结果、调用者、资源和命中条件。踩过一次坑,用户说"我能看见这个数据是因为什么权限",如果没日志,就得整个人站起来查半天。
写在最后
ABAC 不是银弹,我也不同意把所有权限都换成 ABAC。RBAC 在功能权限上仍然很好用,真正需要 ABAC 的是那些资源属性复杂、上下文判断多的业务场景。
如果要从零开始接,我建议先选一类你最头疼的资源,比如订单或文档,单独做一个 ABAC 策略引擎和 RBAC 并行跑,不要一上来全量迁移。权限模型这种东西,最怕一边改一边出线上事故。
代码不要直接抄,我这里写的是骨架和思路。真正难的部分永远是怎么把你的业务抽象成一套干净、可理解的属性模型,这个过程甚至比重构权限代码本身更耗时间。
🎁 福利时间
如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。
