An Open Policy Agent adapter that externalizes the Firefly security platform's authorization decisions to a real OPA server. It implements the
PolicyDecisionPortdriven port by POSTing each request to OPA's Data API and mapping the Rego result to aDecision— fail-closed, so any transport error or undefined policy denies.
- Overview
- Where it sits in the platform
- What it provides
- Key types
- The request contract
- Requirements
- Installation
- Usage
- Testing
- License
This module is a policy-decision adapter for the Firefly hexagonal security platform. It moves the ABAC decision out of the application process and into an externally managed Open Policy Agent server, where authorization rules are authored in Rego and versioned independently of the services they govern.
The whole adapter is a single class — OpaPolicyDecisionAdapter — implementing the PolicyDecisionPort driven port from security-spi. For each authorize(...) call it builds an OPA input document from the SecurityPrincipal, the requested action, the target resource, and the request context, POSTs it to a boolean rule under OPA's Data API (/v1/data/<path>), and translates the JSON result into a framework Decision.
Authorization is fail-closed by construction, exactly as PolicyDecisionPort mandates. A Rego rule that evaluates to false, a policy path whose result is absent (e.g. an undefined or misnamed decision path), and any transport-level failure all resolve to a non-granting Decision — Decision.deny(...) for an explicit false, and Decision.indeterminate(...) for an OPA error, both of which callers treat as a denial since only Effect.PERMIT grants access. The adapter never throws the error up the chain and never defaults to permit.
It imports no OPA SDK: the only client is a reactive Spring WebClient, so the adapter stays non-blocking end to end and carries a minimal dependency surface.
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.
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.
| 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).
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.
- Java 21+
- A reactive web stack (Spring WebFlux / Reactor
WebClient) - A reachable Open Policy Agent server exposing the Data API (
/v1/data/...) and loaded with the Rego policy that exposes your boolean decision path
The version is managed by the Firefly parent/BOM, so you can usually omit it. Add the adapter only in deployments that externalize authorization to OPA:
<dependency>
<groupId>org.fireflyframework</groupId>
<artifactId>fireflyframework-security-adapter-opa</artifactId>
</dependency>If you are not inheriting the Firefly parent, pin the version explicitly:
<dependency>
<groupId>org.fireflyframework</groupId>
<artifactId>fireflyframework-security-adapter-opa</artifactId>
<version>26.06.01</version>
</dependency>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 == trueThe 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.
Copyright 2024-2026 Firefly Software Foundation.
Licensed under the Apache License, Version 2.0. See LICENSE for details.