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
FieldDescription
tls.ca_certPath 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_requiredWhen true, the JVM's default trust store is also trusted in addition to ca_cert / truststore.
credentials.client_tls.certPath to a PEM file with the client certificate (and any intermediates).
credentials.client_tls.private_keyPath to an unencrypted PKCS#8 PEM file with the client private key.
credentials.client_tls.cert_reread_interval_secondsIf 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

PluginDescription
ServicePluginManages HTTP clients for remote service communication
BundlePluginDownloads and activates OPA policy/data bundles
DecisionLogPluginLogs decisions to console or uploads to a remote service
StatusPluginReports instance status (bundle revisions, plugin health, labels)
DiscoveryPluginDownloads 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 a BundleActivationListener callback that triggers engine.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.