opa-services
August 17, 2026 · View on GitHub
Full OPA runtime with plugin support for bundle management, decision logging, status reporting, and service discovery.
Overview
The services module provides the Opa class, which wraps the core evaluator with a complete plugin system. It handles automatic bundle downloading and activation, decision audit logging, status reporting, and dynamic configuration via discovery.
Usage
Configuration File
import io.github.open_policy_agent.opa.Opa;
Opa opa = new Opa.Builder()
.withConfigFile("opa-config.yaml")
.withDefaultEntrypoint("example/allow")
.build();
Opa.DecisionResult result = opa.makeDecision("{\"user\": \"alice\"}");
boolean allowed = result.getResult().asBoolean();
String decisionId = result.getId();
Programmatic Configuration
import io.github.open_policy_agent.opa.Opa;
import io.github.open_policy_agent.opa.config.Config;
Config config = new Config()
.addService(new Config.ServiceConfig()
.setName("local")
.setUrl("file://"))
.addBundle("authz", new Config.BundleConfig()
.setService("local")
.setResource("/path/to/bundle.tar.gz"))
.setDecisionLogs(new Config.DecisionLogsConfig()
.setConsole(true));
Opa opa = new Opa.Builder()
.withConfig(config)
.withDefaultEntrypoint("example/allow")
.build();
Minimal Configuration
For local evaluation without remote services:
Config config = new Config()
.addService(new Config.ServiceConfig()
.setName("local")
.setUrl("file://"))
.addBundle("authz", new Config.BundleConfig()
.setService("local")
.setResource("/path/to/bundle.tar.gz"));
Opa opa = new Opa.Builder()
.withConfig(config)
.withDefaultEntrypoint("example/allow")
.build();
With Remote Services
# opa-config.yaml
services:
acmecorp:
url: https://example.com/control-plane-api/v1
credentials:
bearer:
token: "my-secret-token"
bundles:
authz:
service: acmecorp
resource: /bundles/authz.tar.gz
polling:
min_delay_seconds: 60
max_delay_seconds: 120
decision_logs:
service: acmecorp
status:
service: acmecorp
TLS and mTLS
Services support two related TLS blocks, mirroring Go-OPA:
services.<name>.tls— trust roots used to verify the server certificate.services.<name>.credentials.client_tls— client certificate and key presented during the TLS handshake (mTLS).
Both apply to all HTTP traffic for the service: bundle downloads, decision-log uploads, status reports, and discovery.
services:
acmecorp:
url: https://policy.example.com
tls:
ca_cert: /etc/ssl/corp-ca.pem
system_ca_required: true
credentials:
client_tls:
cert: /etc/ssl/client.pem
private_key: /etc/ssl/client-key.pem
cert_reread_interval_seconds: 3600
| Field | Description |
|---|---|
tls.ca_cert | Path to a PEM file containing one or more trust roots for verifying the server. |
tls.truststore.{path,password,type} | Java-native JKS / PKCS#12 truststore (alternative to ca_cert). Mutually exclusive with ca_cert. |
tls.system_ca_required | When true, the JVM's default trust store is also trusted in addition to ca_cert / truststore. |
credentials.client_tls.cert | Path to a PEM file with the client certificate (and any intermediates). |
credentials.client_tls.private_key | Path to an unencrypted PKCS#8 PEM file with the client private key. |
credentials.client_tls.cert_reread_interval_seconds | If set, the cert and key are reloaded from disk on this interval to support runtime rotation. |
credentials.client_tls.keystore.{path,password,key_password,type} | JKS / PKCS#12 keystore alternative (path is mutually exclusive with cert / private_key; supports password-protected keys). |
Only unencrypted PKCS#8 PEM private keys are accepted by the file-based loader (the JDK has no first-class support for legacy PKCS#1 / SEC1 / encrypted PEMs without third-party crypto). Convert PKCS#1 keys with:
openssl pkcs8 -topk8 -nocrypt -in key.pem -out key-pkcs8.pem
For encrypted or password-protected keys, use a JKS / PKCS#12 keystore instead:
services:
acmecorp:
url: https://policy.example.com
tls:
truststore:
path: /etc/ssl/truststore.jks
password: ${TRUSTSTORE_PASSWORD}
type: JKS
credentials:
client_tls:
keystore:
path: /etc/ssl/client.p12
password: ${KEYSTORE_PASSWORD}
key_password: ${KEY_PASSWORD}
Programmatic equivalent (file-based mTLS):
Config config = new Config()
.addService(new Config.ServiceConfig()
.setName("acmecorp")
.setUrl("https://policy.example.com")
.setTls(new Config.TlsConfig()
.setCaCert("/etc/ssl/corp-ca.pem")
.setSystemCaRequired(true))
.setCredentials(new Config.CredentialsConfig()
.setClientTls(new Config.ClientTlsConfig()
.setCert("/etc/ssl/client.pem")
.setPrivateKey("/etc/ssl/client-key.pem")
.setCertRereadIntervalSeconds(3600))));
Programmatic equivalent (in-memory keystore from a secret manager — no files on disk):
KeyStore clientStore = loadFromVault();
KeyStore trustStore = loadCaTrust();
Config.ServiceConfig service = new Config.ServiceConfig()
.setName("acmecorp")
.setUrl("https://policy.example.com")
.setTls(new Config.TlsConfig()
.setTruststore(new Config.TruststoreConfig().setKeyStore(trustStore)))
.setCredentials(new Config.CredentialsConfig()
.setClientTls(new Config.ClientTlsConfig()
.setKeystore(new Config.KeystoreConfig()
.setKeyStore(clientStore)
.setKeyPassword("vault-issued-key-pw"))));
For keystores that cannot be expressed any other way (HSM-backed keys, custom KeyManager chains), supply a fully constructed SSLContext directly. When set, file-based and keystore TLS fields are rejected during validation:
SSLContext sslContext = buildSslContextFromHsm();
Config.ServiceConfig service = new Config.ServiceConfig()
.setName("acmecorp")
.setUrl("https://policy.example.com")
.setSslContext(sslContext);
Environment-variable interpolation
Any string in YAML / JSON config may reference an environment variable with ${VAR}. The SDK substitutes references at load time, matching Go-OPA's behaviour, so secrets stay out of committed config files:
services:
acmecorp:
url: https://policy.example.com
credentials:
bearer:
token: ${OPA_BEARER_TOKEN}
tls:
truststore:
path: /etc/ssl/truststore.jks
password: ${TRUSTSTORE_PASSWORD}
Missing variables produce a ConfigurationException at startup — silent empty substitution would mask credential and TLS misconfiguration. Escape with a leading backslash to keep a literal ${VAR} in the config (\${VAR}).
Lifecycle Management
Opa opa = new Opa.Builder()
.withConfigFile("opa-config.yaml")
.withDefaultEntrypoint("example/allow")
.withWaitForPlugins(false) // don't block on build
.build();
// Check readiness
while (!opa.ready()) {
Thread.sleep(100);
}
// Use the instance...
// Clean shutdown
opa.close();
Plugins
| Plugin | Description |
|---|---|
| ServicePlugin | Manages HTTP clients for remote service communication |
| BundlePlugin | Downloads and activates OPA policy/data bundles |
| DecisionLogPlugin | Logs decisions to console or uploads to a remote service |
| StatusPlugin | Reports instance status (bundle revisions, plugin health, labels) |
| DiscoveryPlugin | Downloads configuration bundles for dynamic configuration updates |
Custom Plugins
import io.github.open_policy_agent.opa.plugins.Plugin;
import io.github.open_policy_agent.opa.plugins.PluginManager;
Plugin myPlugin = new Plugin() {
@Override
public Set<String> validate(PluginManager manager) {
return Collections.emptySet();
}
@Override
public Plugin initialize(PluginManager manager) {
return this;
}
@Override
public void start() {
// Plugin startup logic
}
@Override
public void stop() {
// Cleanup
}
};
Opa opa = new Opa.Builder()
.withConfigFile("opa-config.yaml")
.withDefaultEntrypoint("example/allow")
.withPlugin("my-plugin", myPlugin)
.build();
Decision Log Masking
Decision events can be redacted before they are buffered, uploaded, or written to the console. The
decision_logs.mask_decision path (default system/log/mask) is evaluated with the decision event
as its input, and the rules it returns are applied to the event:
package system.log
import rego.v1
# Shorthand form: remove the field.
mask contains "/input/password" if {
input.input.password
}
# Structured form: remove or upsert a value.
mask contains {"op": "upsert", "path": "/result/token", "value": "**REDACTED**"} if {
input.result.token
}
Rule paths must be slash-prefixed and start with input, result, or nd_builtin_cache — the
decision's input, its result, and the non-deterministic builtin cache, as they appear in the event.
Removed paths are recorded in the event's erased array, upserted paths in masked. Paths that are
undefined in the event are skipped.
Rules are applied in the order the policy returns them, so when two of them touch the same path the
last one decides what the event ends up holding — a remove followed by an upsert leaves the field
in place with the masked value, while the reverse order drops it. Both rules still record themselves,
so the path can appear in erased and masked at once. A mask contains ... rule builds a set, and
a set has no authored order: OPA orders its elements by type and then value, which puts the shorthand
string form ahead of the structured object form. Return an array from a complete rule
(mask := [...]) when the order has to be explicit.
Removing an element of an array shortens that array, and removing a whole event field (/input)
takes the field off the event entirely. One case diverges from OPA's Go implementation: removing an
element of an array-valued event field — /input/1 where the decision's input is itself an array —
shortens the array here, where Go skips the rule, because Go cannot write the shortened slice back
through the event's field pointer. Mask a nested path or the whole field to stay on behavior both
implementations share.
Because this SDK evaluates compiled IR plans, the mask policy has to be built as an entrypoint:
opa build -t plan -e authz/allow -e system/log/mask -o bundle.tar.gz policies/
If no plan for the configured path is present, masking is skipped — and if mask_decision was set
to something other than the default, that is logged as a warning, since it means events are being
logged unmasked. If the mask policy fails to evaluate, or returns a rule the SDK cannot parse, the
error is logged and the event is dropped rather than logged unmasked (matching OPA's Go
implementation).
Decision Options
For fine-grained control over individual decisions:
Opa.DecisionOptions options = new Opa.DecisionOptions()
.setInput(inputJson)
.setPath("example/allow")
.setDecisionID("custom-id")
.showMetrics()
.setProfiler(new Profiler());
Opa.DecisionResult result = opa.makeDecision(options);
Hot Reload
The Opa runtime handles policy and data hot-reloading automatically. When the bundle plugin polls and detects an updated bundle, it activates the new bundle in the store and the engine's compiled policy is refreshed transparently. No application code changes are needed.
- Data changes: visible immediately on the next
makeDecision()call (data is read live from the store on every evaluation). - Policy changes: visible on the next
makeDecision()call after the bundle plugin activates the new bundle. The plugin fires aBundleActivationListenercallback that triggersengine.refresh().
This matches upstream OPA behavior, where the Go SDK clears its query cache on bundle commit and reads data per-evaluation via store transactions.