授权模型只会RBAC?深入 ABAC 授权模型,实现权限判断做成策略引擎

不少团队做权限,第一反应是 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 如果每次决策都去查一次数据库,那系统根本扛不住。我的处理方式有两种:

  1. 对于单条数据操作,允许查一次库,但要在 resolver 里加短期缓存;
  2. 对于列表查询,不走 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 程序员需要掌握的核心知识,有需要的自取不谢。


相关推荐
starzy199039 分钟前
KafkaProducer 源码深度剖析:支撑高吞吐的数据结构设计
大数据·数据结构·kafka
饼饼学习空间智能41 分钟前
机器人规模化部署平台怎么选?Fleet、VDA 5050、多机器人调度与运营闭环解析
大数据·深度学习·机器学习
geovindu41 分钟前
sql:SQL Views and Materialized Views using mysql 9.0
大数据·数据库·数据仓库·sql·mysql·数据库开发·数据库架构
浔溺1 小时前
Spark核心基石:RDD全方位深度解析(原理、算子、实战、优化)
大数据·分布式·spark
梦影_1 小时前
MySQL进阶教程(二)—— 储存过程与触发器(下)
开发语言·数据库·后端·sql·mysql·oracle·数据库开发
工业涂料百问1 小时前
陶瓷涂料的光泽度怎么检测?20°、60°、85° 三个角度怎么选
数据库
海绵宝宝转agent1 小时前
2026-10-21学习笔记
笔记·学习·算法
Sayai1 小时前
Elasticsearch Rollup 汇算索引实战:监控指标多粒度预聚合查询,附 painless 时间窗取每日最新值
大数据·运维·elasticsearch·搜索引擎·性能优化·监控
ha_lydms1 小时前
Xml Functions(XML 函数)
xml·大数据·hadoop·spark·数据处理·dataworks·maxcompute