org.sdase.commons.server.opa.OpaBundle Maven / Gradle / Ivy
package org.sdase.commons.server.opa;
import static java.util.Collections.singletonList;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import io.dropwizard.client.JerseyClientBuilder;
import io.dropwizard.core.Configuration;
import io.dropwizard.core.ConfiguredBundle;
import io.dropwizard.core.setup.Bootstrap;
import io.dropwizard.core.setup.Environment;
import io.dropwizard.jackson.Jackson;
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.instrumentation.apachehttpclient.v5_2.ApacheHttpClient5Telemetry;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.WebTarget;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.glassfish.hk2.utilities.binding.AbstractBinder;
import org.glassfish.jersey.process.internal.RequestScoped;
import org.sdase.commons.server.opa.config.OpaClientConfiguration;
import org.sdase.commons.server.opa.config.OpaConfig;
import org.sdase.commons.server.opa.config.OpaConfigProvider;
import org.sdase.commons.server.opa.extension.OpaInputExtension;
import org.sdase.commons.server.opa.extension.OpaInputHeadersExtension;
import org.sdase.commons.server.opa.filter.OpaAuthFilter;
import org.sdase.commons.server.opa.filter.model.OpaInput;
import org.sdase.commons.server.opa.health.PolicyExistsHealthCheck;
import org.sdase.commons.server.opa.internal.OpaJwtPrincipalFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The OPA bundle enables support for the Open Policy
* Agent.
*
* Note, the OPA bundle is not an alternative for the @{@link
* org.sdase.commons.server.auth.AuthBundle} it is an addition for authorization. The {@link
* org.sdase.commons.server.auth.AuthBundle} is still required for validating the JWT *
*
*
A new filter is added to the invocation chain of every endpoint invocation. This filter
* invokes the OPA at the configured URL. Normally, this should be a sidecar of the actual service.
* The response includes an authorization decision and optional constraints that must be evaluated
* when querying the database or filtering the result set of the request.
*
*
The constraints should be modeled as an Java pojo and documented within this pojo. The OPA
* policies must be designed that the predefined result structure is returned, such as
*
*
{@code
* {
* "result": {
* "allow": true,
* "constraint1": true,
* "constraint2": [ "v2.1", "v2.2" ]
* }
* }
*
* }
*
* The filter evaluates the overall allow decision and adds the constraints to the {@link
* jakarta.ws.rs.core.SecurityContext} as {@link OpaJwtPrincipal}.
*
*
The endpoints for swagger are excluded from the OPA filter.
*/
public class OpaBundle implements ConfiguredBundle {
private static final Logger LOG = LoggerFactory.getLogger(OpaBundle.class);
private static final String INSTRUMENTATION_NAME = "sda-commons.opa-bundle";
private final OpaConfigProvider configProvider;
private final Map> inputExtensions;
private final OpenTelemetry openTelemetry;
private OpaBundle(
OpaConfigProvider configProvider,
Map> inputExtensions,
OpenTelemetry openTelemetry) {
this.configProvider = configProvider;
this.inputExtensions = inputExtensions;
this.openTelemetry = openTelemetry;
}
//
// Builder
//
public static ProviderBuilder builder() {
return new Builder<>();
}
/** VisibleForTesting */
@SuppressWarnings("java:S1452") // allow generic wildcard type
Map> getInputExtensions() {
return inputExtensions;
}
@Override
public void initialize(Bootstrap bootstrap) {
// no init
}
@Override
public void run(T configuration, Environment environment) {
OpaConfig config = configProvider.apply(configuration);
if (config.isDisableOpa()) {
LOG.warn("Authorization is disabled. This setting should NEVER be used in production.");
}
// Initialize a telemetry instance if not set.
OpenTelemetry currentTelemetryInstance =
this.openTelemetry == null ? GlobalOpenTelemetry.get() : this.openTelemetry;
// create own object mapper to be independent from changes in jackson
// bundle
ObjectMapper objectMapper = createObjectMapper();
// disable GZIP format since OPA cannot handle GZIP
Client client = createClient(environment, config, objectMapper, currentTelemetryInstance);
WebTarget policyTarget = client.target(buildUrl(config));
// exclude OpenAPI
List excludePattern = new ArrayList<>();
if (excludeOpenApi(config.isExcludeOpenApi())) {
excludePattern.addAll(getOpenApiExcludePatterns());
}
// register filter
environment
.jersey()
.register(
new OpaAuthFilter(
policyTarget,
config,
excludePattern,
objectMapper,
inputExtensions,
currentTelemetryInstance.getTracer(INSTRUMENTATION_NAME)));
// register health check
if (!config.isDisableOpa()) {
environment
.healthChecks()
.register(
PolicyExistsHealthCheck.DEFAULT_NAME, new PolicyExistsHealthCheck(policyTarget));
}
environment
.jersey()
.register(
new AbstractBinder() {
@Override
protected void configure() {
bindFactory(OpaJwtPrincipalFactory.class)
.to(OpaJwtPrincipal.class)
.proxy(true)
.proxyForSameScope(true)
.in(RequestScoped.class);
}
});
}
private Client createClient(
Environment environment,
OpaConfig config,
ObjectMapper objectMapper,
OpenTelemetry openTelemetry) {
OpaClientConfiguration clientConfig =
config.getOpaClient() == null ? new OpaClientConfiguration() : config.getOpaClient();
JerseyClientBuilder jerseyClientBuilder = new JerseyClientBuilder(environment);
jerseyClientBuilder.setApacheHttpClientBuilder(
new io.dropwizard.client.HttpClientBuilder(environment) {
@Override
protected org.apache.hc.client5.http.impl.classic.HttpClientBuilder createBuilder() {
return ApacheHttpClient5Telemetry.builder(openTelemetry).build().newHttpClientBuilder();
}
});
return jerseyClientBuilder.using(clientConfig).using(objectMapper).build("opaClient");
}
private ObjectMapper createObjectMapper() {
ObjectMapper objectMapper = Jackson.newObjectMapper();
objectMapper
// serialization
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
// deserialization
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES)
.disable(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE)
.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);
return objectMapper;
}
private List getOpenApiExcludePatterns() {
return singletonList("openapi\\.(json|yaml)");
}
private boolean excludeOpenApi(boolean exclude) {
try {
if (getClass().getClassLoader().loadClass("org.sdase.commons.server.openapi.OpenApiBundle")
!= null
&& exclude) {
return true;
}
} catch (ClassNotFoundException e) {
// silently ignored
}
return false;
}
/**
* builds the URL to the policy
*
* @param config OPA configuration
* @return The complete policy url
*/
private String buildUrl(OpaConfig config) {
return String.format("%s/v1/data/%s", config.getBaseUrl(), config.getPolicyPackagePath());
}
public interface ProviderBuilder {
OpaExtensionsBuilder withOpaConfigProvider(
OpaConfigProvider opaConfigProvider);
}
public interface OpaExtensionsBuilder extends OpaBuilder {
/**
* Disable the {@link OpaInputHeadersExtension} to not forward any headers to the Open
* Policy Agent.
*
* @return the buider
*/
OpaExtensionsBuilder withoutHeadersExtension();
}
public interface OpaBuilder {
/**
* Register a custom {@link OpaInputExtension} that enriches the default {@link OpaInput} with
* custom properties that are sent to the Open Policy Agent. Prefer authorization based on the
* {@link OpaJwtPrincipal#getConstraintsAsEntity(Class) constraints} that are returned after
* policy execution.
*
* Please note that it is prohibited to override properties that are already set in the
* original {@link org.sdase.commons.server.opa.filter.model.OpaInput}.
*
* @param namespace the namespace is used as property name that the input should be accessible
* as in the OPA policy
* @param extension the extension to register
* @param the type of data that is added to the input
* @throws HiddenOriginalPropertyException thrown if the namespace of an input extension
* interferes with a property name of the original {@link OpaInput}.
* @throws DuplicatePropertyException thrown if a namespace interferes with an already
* registered extension.
* @return the builder
*/
OpaBuilder withInputExtension(String namespace, OpaInputExtension extension);
OpaBuilder withOpenTelemetry(OpenTelemetry openTelemetry);
OpaBundle build();
}
public static class Builder
implements ProviderBuilder, OpaExtensionsBuilder, OpaBuilder {
private OpaConfigProvider opaConfigProvider;
private OpenTelemetry openTelemetry;
private final Map> inputExtensions = new HashMap<>();
private boolean addHeadersExtension = true; // on by default
private Builder() {
// private method to prevent external instantiation
}
private Builder(OpaConfigProvider opaConfigProvider) {
this.opaConfigProvider = opaConfigProvider;
}
@Override
public OpaExtensionsBuilder withOpaConfigProvider(
OpaConfigProvider opaConfigProvider) {
return new Builder<>(opaConfigProvider);
}
@Override
public OpaExtensionsBuilder withoutHeadersExtension() {
this.addHeadersExtension = false;
return this;
}
@Override
public OpaBuilder withInputExtension(String namespace, OpaInputExtension extension) {
// Check if the namespace of an extension would override any original field of the OpaInput.
if (Arrays.stream(OpaInput.class.getDeclaredFields())
.anyMatch(f -> f.getName().equals(namespace))) {
throw new HiddenOriginalPropertyException(namespace, extension);
}
// Check if the namespace has already been registered before
if (inputExtensions.containsKey(namespace)) {
throw new DuplicatePropertyException(namespace, extension, inputExtensions.get(namespace));
}
this.inputExtensions.put(namespace, extension);
return this;
}
@Override
public OpaBuilder withOpenTelemetry(OpenTelemetry openTelemetry) {
this.openTelemetry = openTelemetry;
return this;
}
@Override
public OpaBundle build() {
if (addHeadersExtension) {
withInputExtension("headers", OpaInputHeadersExtension.builder().build());
}
return new OpaBundle<>(opaConfigProvider, inputExtensions, openTelemetry);
}
}
public static class HiddenOriginalPropertyException extends RuntimeException {
public HiddenOriginalPropertyException(String namespace, OpaInputExtension extension) {
super(
String.format(
"The extension \"%s\" would override the original field \"%s\" of the OpaInput. This is not allowed!",
extension.getClass().getName(), namespace));
}
}
public static class DuplicatePropertyException extends RuntimeException {
public DuplicatePropertyException(
String namespace,
OpaInputExtension extension,
OpaInputExtension alreadyRegisteredExtension) {
super(
String.format(
"There is already an extension \"%s\" registered for the field \"%s\". The extension \"%s\" would override this field.",
alreadyRegisteredExtension.getClass().getName(),
namespace,
extension.getClass().getName()));
}
}
}