Open Policy Agent (OPA) with Spring boot 3

The Open Policy Agent (OPA, pronounced “oh-pa”) is an open source, general-purpose policy engine that unifies policy enforcement across the stack. OPA provides a high-level declarative language that lets you specify policy as code and simple APIs to offload policy decision-making from your software. You can use OPA to enforce policies in microservices, Kubernetes, CI/CD pipelines, API gateways, and more.

https://www.openpolicyagent.org/docs/latest/

OPA decouples policy decision-making from policy enforcement. When your application needs to make policy decisions it queries OPA and supplies JSON as Input.

OPA generates policy decisions by evaluating the query input against the policies and data

Input: JSON request that you send to OPA

Policy: The policy files written in rego that you store in OPA

Data: (Optional) Data that you can provide to OPA that can also be used to determine the response.

In the below example, we will configure Springboot 3 with Spring Security 6 and OPA with a policy file but excluding (Data) to run a demo application locally.

You can read more about how to integrate external data with OPA.

For simple cases where you only need role-based access control, then using spring @Secured("ROLE_HERE") is sufficient. Use OPA if you see that you are writing a lot of code to do policy verifications and you want to offload that to OPA instead.


Run OPA in Docker:
docker run -p 8181:8181 openpolicyagent/opa run --server --log-level debug

Using the OPA REST API , we will insert a new policy (PUT) that describes our (jcompetence.authz) API.

The payload body contains the following policy written in OPA rego:

package jcompetence.authz

import future.keywords
import future.keywords.every

default allow := false

#Validate in order to GET, you must have read OR all permission
allow if {
	input.method == "GET"
	input.path = ["v1", "users"]
	allowed_roles := ["read", "all"]
	roles := {role | some role in input.user.authorities; role.authority = allowed_roles[_]}
	count(roles) > 0
}

#Validate in order to POST, you must have all permission
allow if {
	input.method == "POST"
	input.path = ["v1", "users"]
	roles := {role | some role in input.user.authorities; role.authority = "all"}
	count(roles) == 1
}

#Validate in order to PUT, you must have write permission + You are only updating your data
allow if {
	input.method == "PUT"
	input.path = ["v1", "users"]
    allowed_roles := ["write", "all"]
	roles := {role | some role in input.user.authorities; role.authority = allowed_roles[_]}
	upper(input.payload.firstname) = upper(input.user.username)
	count(roles) > 0
}

Explanation of each block of the Policy:

default allow := false  # By default deny allow access
#Allow access to the GET /v1/users API endpoint.
#Only if the user has the role (READ), or (ALL)

allow if {
	input.method == "GET"
	input.path = ["v1", "users"]
	allowed_roles := ["read", "all"]
	roles := {role | some role in input.user.authorities; role.authority = allowed_roles[_]}
	count(roles) > 0
}
#Allow access to the POST /v1/users API endpoint
#Only allow if user has the role (ALL)

allow if {
	input.method == "POST"
	input.path = ["v1", "users"]
	roles := {role | some role in input.user.authorities; role.authority = "all"}
	count(roles) == 1
}

#Allow access to the PUT /v1/users API endpoint
#Only allow if user has the role (WRITE) or (ALL) 
#A user should only be able to UPDATE its only data, otherwise DENY.

allow if {
	input.method == "PUT"
	input.path = ["v1", "users"]
    allowed_roles := ["write", "all"]
	roles := {role | some role in input.user.authorities; role.authority = allowed_roles[_]}
	upper(input.payload.firstname) = upper(input.user.username)
	count(roles) > 0
}

The curl for reference:

curl --location --request PUT 'http://localhost:8181/v1/policies/jcompetence.authz' \
--header 'Content-Type: text/plain' \
--data 'package jcompetence.authz

import future.keywords
import future.keywords.every

default allow := false

#Validate in order to GET, you must have read OR all permission
allow if {
	input.method == "GET"
	input.path = ["v1", "users"]
	allowed_roles := ["read", "all"]
	roles := {role | some role in input.user.authorities; role.authority = allowed_roles[_]}
	count(roles) > 0
}

#Validate in order to POST, you must have all permission
allow if {
	input.method == "POST"
	input.path = ["v1", "users"]
	roles := {role | some role in input.user.authorities; role.authority = "all"}
	count(roles) == 1
}

#Validate in order to PUT, you must have write permission + You are only updating your data
allow if {
	input.method == "PUT"
	input.path = ["v1", "users"]
    allowed_roles := ["write", "all"]
	roles := {role | some role in input.user.authorities; role.authority = allowed_roles[_]}
	upper(input.payload.firstname) = upper(input.user.username)
	count(roles) > 0
}
'

Once you PUT a new Policy into OPA then you must configure your application to communicate with OPA through its /v1/data API.

In our case, jcompetence.authz policies will be accessible through http://localhost:8181/v1/data/jcompetence/authz


Configure Springboot Security:

We will be using an InMemoryUserDetailsManager to have InMemory Users for testing the API + OPA Integration.

package com.jcompetence.opa.and.inmemory.authentication.security;

import com.jcompetence.opa.and.inmemory.authentication.security.opa.OPAAuthorizationManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;


@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfiguration {


    @Autowired
    private OPAAuthorizationManager opaAuthorizationManager;


    @Bean
    public UserDetailsService userDetailsService() {

        var userDetailsService =
                new InMemoryUserDetailsManager();

        var adminUser = User.withUsername("Bobby")
                .password(getPasswordEncoder().encode("password"))
                .authorities("all")
                .build();

        var readUser = User.withUsername("John")
                .password(getPasswordEncoder().encode("password"))
                .authorities("read", "write")
                .build();

        var writeUser = User.withUsername("Milly")
                .password(getPasswordEncoder().encode("password"))
                .authorities("write")
                .build();


        userDetailsService.createUser(adminUser);
        userDetailsService.createUser(readUser);
        userDetailsService.createUser(writeUser);


        return userDetailsService;
    }
    @Bean
    PasswordEncoder getPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeHttpRequests(requests ->
                        requests.requestMatchers(EndpointRequest.to("health")).permitAll()
                                .requestMatchers("/v1/users").access(opaAuthorizationManager)
                                .anyRequest().authenticated()
                )
                .httpBasic()
                .and()
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
}

Create an AuthorizationManager which will intercept the requests towards our /v1/users/ endpoint and ask OPA to verify authorization.

.authorizeHttpRequests(requests ->
        requests.requestMatchers(EndpointRequest.to("health")).permitAll()
                .requestMatchers("/v1/users").access(opaAuthorizationManager)
                .anyRequest().authenticated()
package com.jcompetence.opa.and.inmemory.authentication.security.opa;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.jcompetence.opa.and.inmemory.authentication.proxy.rest.OpaClient;
import com.jcompetence.opa.and.inmemory.authentication.security.opa.cachedservlet.ContentCachingHttpServletRequest;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.stereotype.Component;
import org.springframework.web.util.WebUtils;

import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Stream;

@Component
public class OPAAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {

    @Autowired
    private OpaClient opaClient;

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    @SneakyThrows
    public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext requestAuthorizationContext) {

        var httpServletRequest = requestAuthorizationContext.getRequest();

        String[] path = httpServletRequest.getRequestURI().replaceAll("^/|/$", "").split("/");

        ContentCachingHttpServletRequest contentCachingHttpServletRequest = WebUtils.getNativeRequest(httpServletRequest, ContentCachingHttpServletRequest.class);

        Map<String, Object> input = new HashMap<>();

        input.put("user", authentication.get().getPrincipal());
        input.put("method", httpServletRequest.getMethod());
        input.put("path", path);
        input.put("payload", objectMapper.readTree(contentCachingHttpServletRequest.getInputStream()));

        OPADataResponse opaDataResponse = opaClient.authorizedToAccessAPI(new OPADataRequest(input));

        return new AuthorizationDecision(opaDataResponse.getResult().getAllow());
    }

}

The OpaClient here is a FeignClient mapped to the OPA /v1/data endpoint http://localhost:8181/v1/data/jcompetence/authz

package com.jcompetence.opa.and.inmemory.authentication.proxy.rest;

import com.jcompetence.opa.and.inmemory.authentication.security.opa.OPADataRequest;
import com.jcompetence.opa.and.inmemory.authentication.security.opa.OPADataResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;


@FeignClient(value = "opaAuthorization", url = "${app.opa.authz.url}")
public interface OpaClient {

    @PostMapping("/jcompetence/authz")
    OPADataResponse authorizedToAccessAPI(@RequestBody OPADataRequest opaDataRequest);

}

The Application will communicate with the OPA REST API, therefore we will need to map the request/response into Java classes:

package com.jcompetence.opa.and.inmemory.authentication.security.opa;

import lombok.AllArgsConstructor;
import lombok.Data;

import java.util.Map;

@AllArgsConstructor
@Data
public class OPADataRequest {

    Map<String, Object> input;

}

package com.jcompetence.opa.and.inmemory.authentication.security.opa;

import lombok.Data;

@Data
public class OPADataResponse {

    private OPAResult result;


    @Data
    public static class OPAResult{
        private Boolean allow;
    }


}

We will also need to pass the Application API Request Payload as part of the Input into OPA, so we will create a HttpServletRequestWrapper that will be wrapped around the original HttpServletRequest

package com.jcompetence.opa.and.inmemory.authentication.security.opa.cachedservlet;

import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import lombok.SneakyThrows;
import org.springframework.util.StreamUtils;

import java.io.*;

public class ContentCachingHttpServletRequest extends HttpServletRequestWrapper {

    private byte[] cachedBody;

    public ContentCachingHttpServletRequest(HttpServletRequest request) throws IOException {
        super(request);

        InputStream requestInputStream = request.getInputStream();
        this.cachedBody = StreamUtils.copyToByteArray(requestInputStream);

    }

    @Override
    public ServletInputStream getInputStream() throws IOException {

        return new ServletInputStream() {

            private InputStream cachedBodyInputStream = new ByteArrayInputStream(cachedBody);

            @Override
            @SneakyThrows
            public boolean isFinished() {
                return cachedBodyInputStream.available() == 0;
            }

            @Override
            public boolean isReady() {
                return true;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
                throw new UnsupportedOperationException();
            }

            @Override
            public int read() throws IOException {
                return cachedBodyInputStream.read();
            }
        };
    }

    @Override
    public BufferedReader getReader() throws IOException {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody);
        return new BufferedReader(new InputStreamReader(byteArrayInputStream));
    }

}



Register it using a Filter:

package com.jcompetence.opa.and.inmemory.authentication.security.opa.cachedservlet;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
 * Need to cache the HttpServletRequest payload, because we cant read it more than once.
 *
 */
@Order(value = Ordered.HIGHEST_PRECEDENCE)
@Component
@WebFilter(filterName = "ContentCachingFilter", urlPatterns = "/*")
public class ContentCachingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        ContentCachingHttpServletRequest contentCachingRequestWrapper = new ContentCachingHttpServletRequest(request);
        filterChain.doFilter(contentCachingRequestWrapper, response);
    }
}

The REST Controller has a static response, with no database for demo purpose:

package com.jcompetence.opa.and.inmemory.authentication.controller;

import com.jcompetence.opa.and.inmemory.authentication.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/v1/users")
@Slf4j
public class UserResource {

    @GetMapping
    public List<User> getAllUsers() {
        return List.of(new User("Bobby", "Field", 1), new User("Milly", "Brown", 1), new User("John", "Cusack", 3));
    }

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public void createUser(@RequestBody User user) {
        log.info("Added new User {}", user);
    }

    @PutMapping
    public User updateUserDetails(@RequestBody User user) {
        log.info("Update User details {}", user);
        return user;
    }
}

Verify OPA Policies using Postman:

When: User Bobby who has role ALL
Then: User can see all users

When: User Bobby who has role ALL
Then: User can create a new user

When: User Bobby who has role ALL
Then: Bobby can not update other user profiles

When: User Bobby who has role ALL
Then: Bobby can update only his profile.

When: User Milly has role WRITE
Then: Milly can not get users profiles.

And so on, simply change the Username/Password in Postman to see how the application API reacts to policy enforcements from OPA.


Running on Kubernetes:

You can run OPA as a side container along with your microservice.
Create a Configmap named example-app-opa-policy with the rego policies, then in your DeploymentConfig mount the Configmap as a volume to the openpolicyagent sidecar image.

package jcompetence.authz

import future.keywords
import future.keywords.every

default allow := false

#Validate in order to GET, you must have read OR all permission
allow if {
	input.method == "GET"
	input.path = ["v1", "users"]
	allowed_roles := ["read", "all"]
	roles := {role | some role in input.user.authorities; role.authority = allowed_roles[_]}
	count(roles) > 0
}

#Validate in order to POST, you must have all permission
allow if {
	input.method == "POST"
	input.path = ["v1", "users"]
	roles := {role | some role in input.user.authorities; role.authority = "all"}
	count(roles) == 1
}

#Validate in order to PUT, you must have write permission + You are only updating your data
allow if {
	input.method == "PUT"
	input.path = ["v1", "users"]
    allowed_roles := ["write", "all"]
	roles := {role | some role in input.user.authorities; role.authority = allowed_roles[_]}
	upper(input.payload.firstname) = upper(input.user.username)
	count(roles) > 0
}

    volumes:
        - configMap:
            name: 'example-app-opa-policy'
          name: example-app-opa-policy
   
      volumeMounts:
        - name: example-app-opa-policy
          mountPath: /policies
      terminationMessagePolicy: File
      image: 'openpolicyagent/opa:edge-rootless'
      args:
        - run
        - '--ignore=.*'
        - '--server'
        - '--log-level=debug'
        - '--log-format=json-pretty'
        - '--set=decision_logs.console=true'
        - /policies

Code on Github

Note: There is another opensource project that can be used along with OPA for administration of policies, called OPAL.

Leave a comment