Ohhnews

分类导航

$ cd ..
foojay原文

Java应用中的基于角色的访问控制(RBAC)实践

#java#访问控制#权限管理#系统架构#软件安全

目录

我们经常处理这样的 Java 应用程序:安全性的起点和终点仅仅是身份验证。验证 JWT 令牌、集成 Spring Security 并添加身份提供程序,就以为这种配置已经足够安全了。

真正的问题在于,身份验证只回答了一个问题:你是谁?但在实际应用中,我们必须回答另一个问题,而这个问题往往更复杂,处理不当的风险也更高:你被允许做什么?

这个问题涉及授权。在企业级后端应用中引入该问题的首要步骤是应用基于角色的访问控制(RBAC)。

RBAC 当然不是什么新技术,它已经存在几十年了。然而,我们在现代 Java 应用程序中应用该原则的方式,决定了系统的可维护性和演进能力,而不至于让代码陷入注解和框架隐式行为的泥潭。

在本文中,我们将探讨如何在应用层面实现 RBAC,使用 MongoDB 存储用户元数据,并将授权逻辑保持在系统核心附近。我们的目标不仅是确保安全,还要确保架构的一致性。本文使用的所有代码都在此 仓库 中。

授权是一个业务问题

在 Spring 应用程序中,很容易发现隐藏在注解中的授权逻辑:

$ java
@PreAuthorize("hasRole('ADMIN')")

或者直接嵌入在控制器中:

$ java
if (!user.getRoles().contains("ADMIN")) {
    throw new ForbiddenException();
}

从技术上讲,这两种方法都有效。真正的问题在于它们倾向于将授权规则分散在各个层级,将业务逻辑与安全概念混为一谈。这是一个复杂且微妙的差别。授权规则很少是纯粹的技术规则。例如,“只有财务人员可以审批报销”并不是框架配置细节,而是一条业务规则。

“只有订单所有者可以取消订单”同样属于业务逻辑。

然而,这并不意味着应该消除注解。相反,当注解用于强制执行技术性、横切性或重复性的授权模型时,它们非常有用。它们减少了人为错误,简化并辅助了静态代码分析,并提供了对一致性安全约束的声明式强制执行。主要的区别在于每条规则的驻留位置:

  • 业务授权(例如:“请求者必须是订单的所有者”)应该保持在包含所有业务逻辑的应用程序层中,并清晰可见。
  • 技术授权(例如:“需要身份验证”)非常适合使用注解或框架级配置。

使业务规则显式化、技术规则声明式化,能让我们同时获得清晰度和安全性。

优先建模权限

一个常见的错误是先考虑角色,再考虑权限。权限代表动作。它们是动词,是系统提供的具体操作。

假设我们有一个具有简单授权要求的系统。所有这些都可以在 Java 中表示如下:

$ java
public enum Permission {
    ORDER_CREATE,
    ORDER_CANCEL,
    ORDER_VIEW,
    REFUND_APPROVE,
    USER_MANAGE
}

这些权限描述了系统中存在的功能。它们是应用程序自身逻辑的一部分。因此,角色就变成了权限的集合:

$ java
public enum Role {
    CUSTOMER(Set.of(
        Permission.ORDER_CREATE,
        Permission.ORDER_CANCEL,
        Permission.ORDER_VIEW
    )),

    FINANCE(Set.of(
        Permission.ORDER_VIEW,
        Permission.REFUND_APPROVE
    )),
    ADMIN(Set.of(Permission.values()));

    private final Set<Permission> permissions;
    
    Role(Set<Permission> permissions) {
        this.permissions = permissions;
    }

    public Set<Permission> permissions() {
        return permissions;
    }
}

这样的设计有两个重要的后果。首先,权限清晰且可以进行版本控制:如果添加了新功能,它会在应用程序代码中体现;如果角色发生变化,差异也是可追踪的。

其次,整个逻辑不依赖于任何技术实现或任何安全框架。这种独立性是有意为之的,因为授权规则不应与基础设施或外部依赖项挂钩。

使用 MongoDB 存储用户元数据

MongoDB 是存储用户元数据的绝佳选择。用户配置文件会随时间变化:新的属性会出现,旧的会消失,角色也会变更。

因此,灵活性成为了一个基本要求。

下面让我们试着想象一个简单的表示用户的文档:

$ cat
{
  "_id": "user-123",
  "email": "alice@example.com",
  "roles": ["CUSTOMER"],
  "status": "ACTIVE"
}

使用 Spring Data MongoDB,映射非常简单:

$ java
@Document(collection = "users")
public class UserDocument {
    @Id
    private String id;
    private String email;
    private Set<String> roles;
    private String status;
    // 省略构造函数和 getter
}

MongoDB 只做一件事:持久化元数据。它不决定特定用户的权限,也不评估安全策略。它只是存储数据,从这些数据中可以做出授权决策。

这种分离保持了核心应用程序代码的整洁。

从基础设施模型到应用程序主体

与其使用 UserDocument 这种带有持久化逻辑的表示方式,我们应该使用更接近领域模型的表示,例如:

$ java
public class UserPrincipal {
    private final String id;
    private final Set<Role> roles;

    public UserPrincipal(String id, Set<Role> roles) {
        this.id = id;
        this.roles = roles;
    }

    public boolean hasPermission(Permission permission) {
        return roles.stream()
                .flatMap(role -> role.permissions().stream())
                .anyMatch(p -> p == permission);
    }

    public String id() {
        return id;
    }
}

映射可以由适配器来管理:

$ java
public class UserMapper {
    public static UserPrincipal toPrincipal(UserDocument document) {
        Set<Role> roles = document.getRoles().stream()
                .map(Role::valueOf)
                .collect(Collectors.toSet());
        return new UserPrincipal(document.getId(), roles);
    }
}

通过这样做,授权逻辑作用于应用程序领域,而不是 MongoDB 实体或特定的框架原则。

集中化授权逻辑

我们显式引入 AuthorizationService,将授权控制集中在一个地方,而不是分散在整个代码库中。

$ java
public class AuthorizationService {
    public void checkPermission(UserPrincipal user, Permission permission) {
        if (!user.hasPermission(permission)) {
            throw new ForbiddenException(
                "Missing permission: " + permission
            );
        }
    }
}

现在,应用程序服务可以清晰地暴露其授权逻辑:

$ java
public class OrderService {
    private final AuthorizationService authorizationService;

    public OrderService(AuthorizationService authorizationService) {
        this.authorizationService = authorizationService;
    }

    public void cancelOrder(UserPrincipal user, String orderId) {
        authorizationService.checkPermission(user, Permission.ORDER_CANCEL);
        // 此处为领域逻辑
    }
}

在阅读此方法时,授权要求一目了然:没有隐藏或嵌套的注解,没有代理魔法。规则位于重要的地方,即保护应用程序行为与领域之间的边界。

上下文规则:RBAC 的局限

RBAC 策略能很好地处理静态权限,但现实世界的系统很少停留在这一层面。例如,用户可能有权取消订单,但这并不自动意味着他们可以取消任何订单。

这些约束可以通过两种互补的方式进行建模:

  • 领域规则:应用程序逻辑检查“此时此地”的上下文。
  • 上下文感知 RBAC(或条件 RBAC):RBAC 的扩展,其中基于角色的权限仅在特定条件(例如用户、资源或环境属性)为真时才有效。

上下文规则驻留在领域逻辑中:

$ java
public void cancelOrder(UserPrincipal user, Order order) {
    authorizationService.checkPermission(user, Permission.ORDER_CANCEL);
    if (!order.belongsTo(user.id())) {
        throw new ForbiddenException("Cannot cancel another user's order");
    }
    // 继续执行取消操作
}

相同的约束也可以表示为上下文感知 RBAC,使权限依赖于角色:

$ java
public boolean canCancel(Order order, Authentication authentication) {
     if (order == null || authentication == null || !authentication.isAuthenticated()) {
         return false;
     }

     Object principal = authentication.getPrincipal();

     if (principal instanceof UserPrincipal up) {
         // 上下文约束:只能取消自己的订单
         return order.belongsTo(up.id());
     }
     return false;
}

实际上,RBAC 告诉您操作是否“在总体上”被允许,而上下文约束(无论您选择在领域逻辑中实现,还是作为策略引擎中的上下文感知 RBAC)告诉您该操作在“此时此地”是否被允许。保持这些层面的分离有助于避免将基于身份的访问与特定业务约束混淆。

RBAC 与 ABAC:当角色不再够用时

如前所述,随着系统在功能和复杂性上的增长,RBAC 策略会显现其局限性。让我们再举几个例子:

  • 财务部门的用户可以审批费用报销,但仅限于 10,000 美元以下。
  • 区域经理可以访问数据,但仅限于分配给他们的区域。
  • 支持代理可以查看用户帐户,但不能查看被标记为高风险的帐户。

这些规则不再仅仅基于角色:它们取决于用户、资源和环境的属性。

这就是 ABAC(基于属性的访问控制)策略发挥作用的地方。

在 ABAC 策略中,决策基于动态评估属性的规则。系统不再问“用户是否有 X 角色?”,而是问“给定这些属性,此策略是否得到遵守?”

ABAC 允许您解决这种复杂性和可扩展性问题,但它也引入了其他挑战:

  • 策略可能会变得难以管理。
  • 决策逻辑变得难以追踪。
  • 测试需要评估规则引擎,而不仅仅是简单的角色检查。

对于绝大多数企业应用程序,特别是那些具有明确业务规则的应用程序,将 RBAC 与上下文领域控制相结合就已经足够了。但基本思想是,您不必预先排他性地在 RBAC 和 ABAC 之间做出选择。可以在应用层面设计授权,以允许未来的演进和扩展。

如果权限是显式的且授权是集中化的,我们可以从这种方法开始,然后通过应用更丰富的策略(甚至是基于属性的策略)来扩展 AuthorizationService。在这种情况下,架构提供了灵活性。

架构收益

将 RBAC 策略作为配置来实现,往往会让我们认为仅仅是添加了功能。而在应用程序的核心中对 RBAC 策略进行建模,则使这些规则成为业务逻辑的一部分。权限描述能力,角色描述责任,而授权控制描述意图。

MongoDB 存储元数据,Spring Security 进行身份验证。关于用户可以做什么的决策是业务逻辑,因此,责任在于应用程序层。

应用这种责任划分使我们能够获得长期的收益,例如:

  • 领域层不包含框架注解。
  • 授权逻辑可以在没有基础设施的情况下进行测试。
  • 规则在应用程序代码中可见且可版本化。
  • 架构在需求演进为更复杂策略时仍然保持适应性。

真正的优势在于,通过阅读应用程序的服务层,您不仅知道该应用程序做什么,还知道谁被授权做什么。

结论

我们经常将基于角色的访问控制功能视为可以激活的东西,是安全设置中需要勾选的框。在许多情况下,它只是方法上方的一个注解。

在 Java 企业应用程序中,授权必须反映业务规则;它不能仅仅是技术机制。

秘诀很简单:我们显式地对授权进行建模,将它们分组为角色,在数据库中保存元数据,并在应用程序层应用访问规则。安全性追随架构,一切都各就其位。

核心思想是使授权及其相关逻辑变得可见且可测试,避免这些逻辑被应用程序框架或基础设施对象所污染。

您可以在以下 链接 中找到如何应用这些概念的示例。