Security OPA Adapter¶
Delegate policy decisions to Open Policy Agent through the security SPI.
Construct the adapter with the OPA endpoint/client and use it as PolicyDecisionPort. Align action/resource/context input with the deployed Rego policy and test unavailable/malformed responses as denial paths.
| Maven artifact | Packaging | Source project |
|---|---|---|
org.fireflyframework:fireflyframework-security-adapter-opa |
jar |
modules/security-adapter-opa |
Part of Security and identity.
In the onboarding service¶
Delegate an onboarding authorization decision to a configured OPA policy through PolicyDecisionPort. Match the application action/resource/context to the deployed Rego input contract.
Add the component¶
Installation and version availability explains source builds and published releases.
<dependency>
<groupId>org.fireflyframework</groupId>
<artifactId>fireflyframework-security-adapter-opa</artifactId>
<version>26.10.20-SNAPSHOT</version>
</dependency>
Quick start¶
The Java examples in this section are API fragments for application code. Use the stated imports and supply the application-owned collaborators described alongside them.
Contribute an OpaPolicyDecisionAdapter as your PolicyDecisionPort. Because security-webflux selects whatever PolicyDecisionPort bean is present, this single bean replaces the in-process EmbeddedPolicyDecisionAdapter for every non-permitted exchange. No other wiring required:
import org.fireflyframework.security.adapter.opa.OpaPolicyDecisionAdapter;
import org.fireflyframework.security.spi.PolicyDecisionPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
@Configuration
class OpaPolicyConfiguration {
@Bean
PolicyDecisionPort policyDecisionPort() {
WebClient opa = WebClient.create("http://opa:8181");
return new OpaPolicyDecisionAdapter(opa, "firefly/allow");
}
}
The decision is consumed reactively and is permit-only:
adapter.authorize(principal, "read", "doc:1", Map.of())
.map(Decision::granted); // true only when OPA returns result == true
Configuration and wiring¶
This module declares no @ConfigurationProperties class of its own. Use the constructor/port contracts above and the configuration of the host or related core module.
Public API and extension points¶
| Type | Contract and responsibility |
|---|---|
OpaPolicyDecisionAdapter |
PolicyDecisionPort backed by Open Policy Agent. |
Related components¶
| Related component | Why it belongs nearby |
|---|---|
| Security API | Define product-neutral principals, authorization decisions, driving use cases, and the Secure annotation. |
| Security SPI | Define driven ports for policy, relationships, revocation, keys, secrets, and other external security services. |
Build and test¶
Use Java 25. From the repository root, build this component with its required dependencies:
git clone https://github.com/fireflyframework/fireflyframework-jafly.git
cd fireflyframework-jafly
./mvnw -pl :fireflyframework-security-adapter-opa -am verify
./mvnw -pl :fireflyframework-security-adapter-opa -am install
verify runs the selected projects' tests. install makes the current build available in your local Maven repository.
Representative test sources:
Some tests use Testcontainers. A working Docker-compatible container runtime is required for those fixtures; inspect their annotations and the build output to distinguish executed tests from environment-dependent skips.
Mock, local-container, and application-context tests do not establish acceptance against a live provider account. Validate the intended provider deployment and credentials separately.
Test fixture¶
The permitsAllowedActionForAnySubject test demonstrates one concrete behavior. Excerpt from OpaPolicyDecisionAdapterIntegrationTest.java. The test class supplies its fixtures and imports.
@Test
void permitsAllowedActionForAnySubject() {
OpaPolicyDecisionAdapter adapter = new OpaPolicyDecisionAdapter(webClient, "firefly/allow");
StepVerifier.create(adapter.authorize(principal(Set.of("teller")), "read", "doc:1", Map.of()))
.expectNextMatches(d -> d.granted())
.verifyComplete();
}
Detailed reference¶
Where it sits in the platform¶
The security platform is layered hexagonally; dependencies point inward, and providers attach as outboard adapters:
security-api → security-spi → security-core → security-webflux → adapters
(ports + (driven (neutral (reactive (this module:
domain) ports) engine + Spring Security OPA policy-decision
embedded PDP) bindings) adapter, + Vault, KMS,
Keycloak, Cerbos, …)
security-apidefines the domain this adapter speaks in:SecurityPrincipal(the subject) andDecision(the outcome, with itsEffectenum).security-spidefines the driven port this module implements:PolicyDecisionPort.security-coreships the in-process default,EmbeddedPolicyDecisionAdapter, which this module replaces.security-webfluxconsumes whicheverPolicyDecisionPortbean is present through itsPolicyAuthorizationManager, so swapping the embedded engine for OPA is transparent to the filter chain.- This module is an outboard adapter: contribute an
OpaPolicyDecisionAdapterbean and every non-permitted exchange is authorized by your OPA server instead of in-process rules.
This adapter depends only on security-api, security-spi, spring-webflux, and jackson-databind. It pulls in no vendor SDK and no Spring Boot auto-configuration. It is a plain port implementation you wire as a bean.
What it provides¶
OpaPolicyDecisionAdapter contributes a single capability: a PolicyDecisionPort whose decisions come from a live OPA server.
- Reactive, SDK-free transport. Construction takes a
WebClientwhose base URL points at the OPA server plus adecisionPath(the OPA data path of the boolean allow rule, e.g.firefly/allow). A leading slash on the path is tolerated and stripped. The call is issued against/v1/data/<decisionPath>and is non-blocking throughout. - A stable
inputshape. Every request sends OPA aninputdocument with a nestedsubject(subject,authorities,scopes,tenantIddrawn from theSecurityPrincipal), plus top-levelaction,resource, andcontext(anullcontext is sent as an empty object). Rego policies bind against this contract. - Boolean-result mapping. OPA's
{"result": <bool>}envelope is deserialized into the internalOpaResultrecord (ignoring unknown fields).truemaps toDecision.permit(); anything else maps toDecision.deny("denied by OPA policy"). - Fail-closed error handling. A transport error or any failure during evaluation is logged at WARN and resolved to
Decision.indeterminate("OPA error: …")rather than propagated. a denial to every caller, never an exception and never a permit.
Key types¶
| Type | Role |
|---|---|
OpaPolicyDecisionAdapter |
PolicyDecisionPort implementation; POSTs the input document to OPA's Data API and maps the boolean result to a Decision, fail-closed. |
OpaPolicyDecisionAdapter.OpaResult |
Package-private record OpaResult(Boolean result). the deserialized subset of OPA's data response ({"result": <bool>}), @JsonIgnoreProperties(ignoreUnknown = true). |
Port implemented (from security-spi): PolicyDecisionPort. Domain types consumed (from security-api): SecurityPrincipal, Decision (and its Decision.Effect).
The request contract¶
For a call authorize(principal, action, resource, context), the adapter POSTs to /v1/data/<decisionPath>:
{
"input": {
"subject": {
"subject": "u1",
"authorities": ["teller"],
"scopes": ["reports.read"],
"tenantId": "acme"
},
"action": "read",
"resource": "doc:1",
"context": {}
}
}
A Rego policy binds against this input and exposes a boolean rule at the decision path. For the path firefly/allow:
package firefly
default allow = false
allow {
input.action == "read"
}
allow {
input.subject.authorities[_] == "admin"
}
OPA replies {"result": true} or {"result": false}; a path with no defined rule yields a response with no result, which the adapter treats as a denial.
Testing¶
The module is verified against a real Open Policy Agent server, not a mock. OpaPolicyDecisionAdapterIntegrationTest is a @Testcontainers test that starts the official openpolicyagent/opa:0.70.0 image (run --server --addr=0.0.0.0:8181), waits for OPA's /health to return 200, then uploads a Rego policy through the live Policy API (PUT /v1/policies/firefly). The policy permits the read action or any subject holding the admin authority. Tests drive the adapter with StepVerifier and assert every branch of the fail-closed contract:
- Permit (allowed action). A non-admin subject performing
readis granted.Decision.granted()istrue. - Permit (admin override). An
adminsubject performingwriteis granted, proving the second Rego rule fires. - Deny (explicit
false). A non-admin subject performingwriteevaluates tofalseand is denied. - Fail-closed (undefined path). Pointing the adapter at a non-existent decision path (
firefly/does_not_exist) returns a non-grantingDecisioneven for anadminsubject. an absent result denies, exactly as production transport errors must.
This exercises the genuine HTTP round-trip, Rego evaluation, and JSON envelope against a containerized OPA, so the permit, explicit-deny, and undefined-path branches are proven, not assumed.
Continue the application¶
Use the Security Resource Server guide for the next part of this integration. The runnable submission/query service is in Getting started.