Skip to content
Ashish.
All posts
Diagram illustrating the flow of an authorization request from Spring Security to Open Policy Agent.
9 min readtechnicalAdvancedFeatured#abac#spring-security#opa#rego#access-control#java#security#authorization

Implementing Attribute-Based Access Control (ABAC) with Spring Security

A technical guide on implementing attribute-based access control (ABAC) using Spring Security and Open Policy Agent for dynamic policy enforcement.

By Ashish Srivastava

In traditional Role-Based Access Control (RBAC), the system asks, "Is this user in the 'Manager' role?" and grants access if the answer is yes. This works for coarse-grained permissions but collapses when you need to answer, "Can Alice edit the invoice if she is the department head of the department owning the invoice and the invoice is not in 'archived' status?" The mechanism of RBAC relies on static membership. The mechanism of Attribute-Based Access Control (ABAC) relies on dynamic evaluation of properties. When you implement ABAC in Spring Security, you stop writing if statements inside your controllers and start defining a protocol where the application becomes a client that queries a policy engine.

The Mechanism of Externalized Evaluation

The core architectural shift in ABAC is moving the evaluation logic out of the application thread. In a monolithic Java application, you might have a @PreAuthorize("hasRole('ADMIN')") annotation. This is a compile-time check. ABAC requires a runtime check where the decision depends on the state of the data at the moment of the request.

Imagine a scenario where a request arrives for GET /api/invoices/101. The application does not know the answer. Instead, it constructs a "context" object containing all relevant attributes: the authenticated user's ID, their department, the target resource's ID, its current status, and the requested HTTP method. This context is serialized and sent to an external decision engine, typically Open Policy Agent (OPA). OPA evaluates the request against a set of rules written in Rego, a declarative language designed for policy. The application then waits for a single boolean response: true (allow) or false (deny).

This separation ensures that if the business logic changes—say, a new rule about "invoice age"—you update the Rego file and redeploy the policy, without touching the Java code or restarting the application server.

Constructing the Request Context

To make this work, Spring Security must act as a gatekeeper. In Spring Security 6, the legacy FilterChain is replaced by the AuthorizationManager abstraction. This manager is responsible for intercepting the request before it reaches the controller.

Consider a concrete scenario: A user named alice with department = 'finance' attempts to DELETE an invoice inv_55 which belongs to department = 'engineering'. The Spring Security configuration defines a custom AuthorizationManager<ServerWebExchange> that extracts these attributes.

The code below demonstrates how to extract the necessary data and handle the reactive flow correctly. We assume the user details are available in the security context and the resource path is parsed from the exchange.

public class AbacAuthorizationManager implements AuthorizationManager<ServerWebExchange> {
    private final OpaClient opaClient; // Custom client for OPA
 
    @Override
    public Mono<Void> check(ServerWebExchange exchange) {
        SecurityContext context = exchange.getSecurityContext();
        Authentication auth = context.getAuthentication();
        
        String userDept = auth.getPrincipal().getDepartment(); // Extracted from principal
        
        String resourceId = extractResourceId(exchange.getRequest().getURI());
        String action = exchange.getRequest().getMethod().name();
 
        // Construct the input payload for OPA
        OpaInput input = new OpaInput(
            "user", 
            Map.of("id", auth.getName(), "department", userDept),
            "resource", 
            Map.of("id", resourceId, "owner_id", "engineering"), // Aligned with Rego policy
            "action", action
        );
 
        // Reactive chain: evaluate and handle result
        return opaClient.evaluate(input)
            .flatMap(allowed -> allowed ? Mono.empty() : Mono.error(new AccessDeniedException("Access denied by ABAC policy")));
    }
}

Here, the OpaInput is the artifact that travels across the network. It is a JSON structure that maps the Java object graph to a flat schema that Rego can understand. The mechanism here is serialization and transport. If the database lookup for the resource owner is slow, it becomes part of the latency budget of the authorization check.

A technical architecture diagram showing a Java Spring Security application sending a JSON payload via HTTP to an Open Policy Agent (OPA) server. The diagram should highlight the extraction of user attributes, resource attributes, and action from the request, and the return of…

Writing the Policy in Rego

Once the context reaches OPA, the engine needs to know how to interpret it. We write policies in Rego. A Rego policy is a set of rules that define what constitutes an "allow" decision. The rule name usually matches the path in the input data structure.

For our invoice scenario, we define a rule that checks if the user's department matches the resource's owner. If they match, we allow the action. If the action is DELETE, we might add a stricter rule requiring the user to be the owner, not just a member of the same department.

package main
 
# Default deny: if no rule matches, access is denied
default allow = false
 
# Rule: Allow if user department matches resource owner
allow if {
    input.user.department == input.resource.owner
    input.action == "GET"
}
 
# Rule: Allow DELETE only if user is the specific owner
allow if {
    input.user.id == input.resource.owner_id
    input.action == "DELETE"
}
 
# Rule: Deny if the invoice is archived (business logic)
allow if {
    input.resource.status != "archived"
    input.action == "DELETE"
    input.user.role == "super_admin"
}

In this example, the mechanism is pattern matching against the input JSON. If input.user.department is "finance" and input.resource.owner is "engineering", the first rule fails. The engine moves to the next. If the user is alice and alice is the owner_id, the second rule succeeds, returning true. If none match, the default allow = false rule kicks in. This explicit failure mode is critical for security; you must never rely on implicit trust.

Wiring the Decision to Spring Security

The final piece is connecting the AuthorizationManager to the Spring Security filter chain. You register the custom manager as a bean and apply it to specific URL patterns.

@Configuration
@EnableWebSecurity
public class SecurityConfig {
 
    @Bean
    public SecurityFilterChain filterChain(ServerHttpSecurity http, OpaClient opaClient) {
        http.authorizeExchange(exchanges -> exchanges
            .pathMatchers("/api/invoices/**").access(new AbacAuthorizationManager(opaClient))
            .anyExchange().permitAll()
        );
        return http.build();
    }
}

When a request hits /api/invoices/101, Spring Security invokes AbacAuthorizationManager.check. The manager builds the JSON payload, sends it to OPA (usually over gRPC or REST), and waits for the response. If OPA returns false, the manager throws an AccessDeniedException, which Spring Security translates into a 403 Forbidden response to the client.

This architecture introduces a dependency on OPA availability. If OPA is down, the application cannot make a decision. In a production environment, you must implement a fallback strategy. A common pattern is to cache the policy decision or have a local "denial" fallback, ensuring the system remains secure even during outages.

Performance and Data Flow Considerations

The performance of this system hinges on the latency of the OPA call. In a high-throughput Java application, every millisecond counts. To mitigate this, OPA supports deployment as a WASM module for in-process evaluation, but this requires a specific WASM evaluator runtime setup and is not a built-in Spring Security component. The most common pattern for complex ABAC is a remote OPA instance running as a sidecar in the same pod.

To optimize, you should minimize the size of the input payload. Do not send the entire user object or the entire resource object. Send only the attributes required for the decision. For example, if the policy only cares about department and status, do not send the user's email or the invoice's full text. This reduces network overhead and serialization time.

Furthermore, OPA supports "decision logs" which record every evaluation. This is essential for auditing. When a user claims they were denied access incorrectly, you can query the log to see exactly which rule matched and what the input values were. This transparency is a key advantage of ABAC over hard-coded logic.

Common Pitfalls

Implementing ABAC introduces specific challenges that differ from traditional RBAC.

  • Latency spikes from blocking calls: As seen in the initial code examples, blocking the event loop while waiting for an OPA response can starve the application of threads. Always use reactive chains (Mono/Flux) in WebFlux environments to avoid degrading throughput.
  • Input payload size bloat: Developers often transmit the entire entity object to OPA. This increases network latency and serialization costs. Strictly filter the payload to include only the attributes explicitly referenced in your Rego policies.
  • Stale policy caching: If your application caches OPA decisions without invalidation, it may enforce outdated rules. Ensure your caching strategy respects policy versioning or includes short TTLs to align with frequent policy updates.

Practical Takeaways

  • Externalize Logic: Move authorization logic out of your Java codebase to a dedicated policy engine like OPA to decouple business rules from application logic.
  • Reactive First: Always implement OPA calls as reactive operations in Spring WebFlux to prevent blocking the server's event loop.
  • Default Deny: Structure your Rego policies with an explicit default allow = false to ensure security is maintained even if a rule is missing or misconfigured.

FAQ

Q: Does Spring Security natively support OPA? A: No, Spring Security provides the AuthorizationManager interface to intercept requests, but you must implement the client logic to communicate with OPA yourself.

Q: How do I handle OPA downtime? A: Implement a fallback strategy within your AuthorizationManager. This could involve caching recent decisions, falling back to a "deny-all" mode, or allowing a specific subset of traffic based on a local flag.

Q: Can I use ABAC with Spring MVC (Servlet)? A: Yes, though the implementation differs. You would use a standard AuthorizationManager<Request> and check method, but you must ensure the OPA client is non-blocking or run it in a separate thread pool to avoid blocking the Servlet container.

Conclusion

Implementing ABAC with Spring Security and OPA shifts the burden of authorization from the application code to a dedicated policy engine. The mechanism involves extracting attributes from the request context, serializing them, and querying OPA for a boolean decision. This approach allows for fine-grained, dynamic access control without bloating the Java codebase with complex conditional logic. While it introduces a network dependency, the trade-off is a centralized, auditable, and highly flexible security model that scales with the complexity of your business rules.

Related posts