This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 672d94a894a8 CAMEL-24830: camel-opa - add in-process WASM evaluation
mode to OpaSecurityPolicy (#26670)
672d94a894a8 is described below
commit 672d94a894a8932550b98a6363d8554bcd1289fc
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Sep 25 11:04:48 2026 +0200
CAMEL-24830: camel-opa - add in-process WASM evaluation mode to
OpaSecurityPolicy (#26670)
* CAMEL-24830: camel-opa - add in-process WASM evaluation mode to
OpaSecurityPolicy
OpaSecurityPolicy could only use the REST evaluator, so a route that wanted
the
in-process WASM engine - the mode to prefer for a hot path such as
authorizing an
AI tool call - had to fall back to interceptFrom plus the opa: producer and
a
choice/stop. The policy now takes evaluationMode/policyBundle (and
entrypoint/poolSize/borrowTimeout) and builds an OpaWasmEvaluator in wasm
mode.
The build of a wasm evaluator - validation, the entrypoint default, loading
the
bundle - moves to a single OpaWasmEvaluator.create factory that the
producer and
the policy both call, so the two construct it identically. Both evaluators
extend
OpaPolicyEvaluator, so the decision contract is unchanged: the same
CamelOpaDecision headers, a fail-closed default, and failOpen governing an
evaluation failure. A deny still throws CamelAuthorizationException.
No readiness check is registered in wasm mode: the policy builds no client,
so
ownsClient stays false and registerHealthCheck already skips - there is no
server
to probe, consistent with CAMEL-24743.
OpaSecurityPolicyWasmTest covers allow and deny through a wasm bundle, and,
with a
rest policy as a positive control, that only the rest policy registers a
check.
The camel-examples ai-tools-spiffe-opa sample can now use .policy(...)
instead of
the interceptFrom workaround; that lives in a separate repository and is
left as a
follow-up.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* Regen
* CAMEL-24830: camel-opa - cover that a wasm bundle which loads but is not
a valid module fails the route start
beforeWrap cannot throw a checked exception, and OpaWasmEvaluator borrows an
instance at startup, so the policy must surface a bad-bundle failure rather
than
swallow it. The test drives it with the Rego source as the bundle: it loads
as
bytes but is not a compiled module.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24830: camel-opa - warn on ignored server options in wasm-mode
policy, and cover the validation paths
Review feedback:
- OpaSecurityPolicy now warns at startup when
serverUrl/bearerToken/opaClient are set with evaluationMode=wasm,
matching OpaEndpoint.warnAboutIgnoredServerOptions so both entry points
behave alike (davsclaus).
- The bad-bundle tests assert the startup failure specifically: a bundle
that cannot be loaded surfaces the
RuntimeCamelException wrapper (Could not load the wasm policy bundle),
the loads-but-invalid case a RuntimeException
from addRoutes - both proving the route never starts rather than
authorizing nothing on the first exchange.
- Added tests through OpaSecurityPolicy.buildEvaluator for an unknown
evaluationMode, a missing policyBundle and
poolSize<1, which is a separate entry point from the endpoint's
validation.
- The readiness-check assertion checks the full security-policy:opa- id
prefix, not only the hostname.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24830: warn about an ignored opaClient on the endpoint too
Addresses review feedback on #26670.
warnIgnoredServerOptions on the policy warns about an injected opaClient
in wasm mode; OpaEndpoint.warnAboutIgnoredServerOptions did not, and the
endpoint's wasm branch goes straight to createWasmEvaluator() without
ever reading configuration.getOpaClient(). So the parity the policy's
javadoc claims ran the other way: the endpoint was the one dropping an
option silently.
The review suggested this might belong to #26669 instead; that PR is
merged, so this is where it can land.
No test. The warning is log-only, and neither of the two warnings it
joins - bearerToken and serverUrl - is covered on either side today.
Asserting on it needs a log-capturing appender, and LogCaptureAppender
is duplicated per module (camel-netty and camel-netty-http each carry
their own) rather than shared, so covering this one line means a third
copy. If that coverage is wanted, it is worth a shared harness pinning
all three warnings on both classes rather than only the new one.
Co-Authored-By: Claude Opus 5 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
---------
Signed-off-by: Andrea Cosentino <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Guillaume Nodet <[email protected]>
---
.../apache/camel/catalog/docs/opa-component.adoc | 20 +++
.../camel-opa/src/main/docs/opa-component.adoc | 20 +++
.../apache/camel/component/opa/OpaEndpoint.java | 44 ++---
.../camel/component/opa/OpaWasmEvaluator.java | 27 ++++
.../component/opa/security/OpaSecurityPolicy.java | 149 +++++++++++++++--
.../opa/security/OpaSecurityPolicyWasmTest.java | 177 +++++++++++++++++++++
6 files changed, 395 insertions(+), 42 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/opa-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/opa-component.adoc
index e4a344a4eb86..d674ffa40aed 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/opa-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/opa-component.adoc
@@ -87,6 +87,26 @@ from("platform-http:/orders")
The decision headers are set here too, so an
`onException(CamelAuthorizationException.class)` handler can read
`CamelOpaDecision` to build a meaningful error response.
+The policy can also evaluate a WebAssembly bundle in-process, which is the
mode to prefer for a hot path such as
+authorizing an AI tool call, where a network hop per decision is not wanted:
+
+[source,java]
+------------------------------------------------------------
+OpaSecurityPolicy opaPolicy = new OpaSecurityPolicy();
+opaPolicy.setPolicyPath("authz/orders/allow");
+opaPolicy.setEvaluationMode("wasm");
+opaPolicy.setPolicyBundle("classpath:orders-bundle.tar.gz");
+
+from("platform-http:/orders")
+ .policy(opaPolicy)
+ .to("direct:handleOrder");
+------------------------------------------------------------
+
+The decision contract is identical to `rest` mode - the same
`CamelOpaDecision` headers, and a
+`CamelAuthorizationException` on a deny - so a route need not know which
engine evaluated it. `serverUrl`,
+`bearerToken` and the readiness check do not apply in `wasm` mode; the
*Evaluation modes* section below covers
+building a bundle and what each mode gives up.
+
== The input document
Camel sends OPA an `input` document shaped like this:
diff --git a/components/camel-opa/src/main/docs/opa-component.adoc
b/components/camel-opa/src/main/docs/opa-component.adoc
index e4a344a4eb86..d674ffa40aed 100644
--- a/components/camel-opa/src/main/docs/opa-component.adoc
+++ b/components/camel-opa/src/main/docs/opa-component.adoc
@@ -87,6 +87,26 @@ from("platform-http:/orders")
The decision headers are set here too, so an
`onException(CamelAuthorizationException.class)` handler can read
`CamelOpaDecision` to build a meaningful error response.
+The policy can also evaluate a WebAssembly bundle in-process, which is the
mode to prefer for a hot path such as
+authorizing an AI tool call, where a network hop per decision is not wanted:
+
+[source,java]
+------------------------------------------------------------
+OpaSecurityPolicy opaPolicy = new OpaSecurityPolicy();
+opaPolicy.setPolicyPath("authz/orders/allow");
+opaPolicy.setEvaluationMode("wasm");
+opaPolicy.setPolicyBundle("classpath:orders-bundle.tar.gz");
+
+from("platform-http:/orders")
+ .policy(opaPolicy)
+ .to("direct:handleOrder");
+------------------------------------------------------------
+
+The decision contract is identical to `rest` mode - the same
`CamelOpaDecision` headers, and a
+`CamelAuthorizationException` on a deny - so a route need not know which
engine evaluated it. `serverUrl`,
+`bearerToken` and the readiness check do not apply in `wasm` mode; the
*Evaluation modes* section below covers
+building a bundle and what each mode gives up.
+
== The input document
Camel sends OPA an `input` document shaped like this:
diff --git
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaEndpoint.java
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaEndpoint.java
index d8bafb72c338..8a621193f8b4 100644
---
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaEndpoint.java
+++
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaEndpoint.java
@@ -121,13 +121,20 @@ public class OpaEndpoint extends DefaultEndpoint {
}
/**
- * {@code serverUrl} and {@code bearerToken} address and authenticate to
an OPA server, of which there is none in
- * {@code wasm} mode, so they are ignored - a startup warning is clearer
than silence for an operator who set one
- * and expects it to take effect. {@code failOpen} is deliberately not
among these: a {@code wasm} evaluation can
- * still fail (a busy pool, a bad bundle), and {@code failOpen} governs
that outcome exactly as in {@code rest}
- * mode, so it applies in both.
+ * {@code opaClient}, {@code serverUrl} and {@code bearerToken} reach and
authenticate to an OPA server, of which
+ * there is none in {@code wasm} mode, so they are ignored - a startup
warning is clearer than silence for an
+ * operator who set one and expects it to take effect. {@code failOpen} is
deliberately not among these: a
+ * {@code wasm} evaluation can still fail (a busy pool, a bad bundle), and
{@code failOpen} governs that outcome
+ * exactly as in {@code rest} mode, so it applies in both.
+ * <p/>
+ * Kept in step with {@code OpaSecurityPolicy.warnIgnoredServerOptions}:
the two configure the same evaluators, so
+ * an option that is silently dropped by one and warned about by the other
is a trap for anyone moving a policy path
+ * between the producer and {@code .policy(...)}.
*/
private void warnAboutIgnoredServerOptions() {
+ if (configuration.getOpaClient() != null) {
+ LOG.warn("opaClient is ignored when evaluationMode=wasm: the
policy is evaluated in-process");
+ }
if (ObjectHelper.isNotEmpty(configuration.getBearerToken())) {
LOG.warn("bearerToken is ignored when evaluationMode=wasm: there
is no server to authenticate to");
}
@@ -139,28 +146,11 @@ public class OpaEndpoint extends DefaultEndpoint {
}
private OpaPolicyEvaluator createWasmEvaluator() throws Exception {
- if (ObjectHelper.isEmpty(configuration.getPolicyBundle())) {
- throw new IllegalArgumentException(
- "policyBundle is required when evaluationMode=wasm; build
one with"
- + " opa build -t wasm -e
<entrypoint> <policy.rego>");
- }
- if (configuration.getPoolSize() < 1) {
- // OpaPolicyPool.create rejects this too, but as "maxSize must be
positive" - naming its own parameter
- // rather than the option the operator set, on a component where
poolSize is the only pool they see
- throw new IllegalArgumentException(
- "poolSize must be at least 1 when evaluationMode=wasm, was
" + configuration.getPoolSize());
- }
- // the entrypoint is fixed at build time and is not the same thing as
a data path, but opa build names it
- // after the rule, so the policy path is the right default
- String entrypoint =
ObjectHelper.isNotEmpty(configuration.getEntrypoint())
- ? configuration.getEntrypoint() : policyPath;
- OpaWasmEvaluator.Bundle bundle
- = OpaWasmEvaluator.loadPolicy(getCamelContext(),
configuration.getPolicyBundle());
- return new OpaWasmEvaluator(
- bundle.wasm(), bundle.data(), entrypoint,
configuration.getPoolSize(),
- configuration.getBorrowTimeout(), policyPath,
configuration.getAllowKey(),
- configuration.getIncludeHeaders(),
configuration.getIncludeProperties(),
- configuration.isIncludeBody(), configuration.isFailOpen());
+ return OpaWasmEvaluator.create(
+ getCamelContext(), configuration.getPolicyBundle(),
configuration.getEntrypoint(),
+ configuration.getPoolSize(), configuration.getBorrowTimeout(),
policyPath, configuration.getAllowKey(),
+ configuration.getIncludeHeaders(),
configuration.getIncludeProperties(), configuration.isIncludeBody(),
+ configuration.isFailOpen());
}
@Override
diff --git
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaWasmEvaluator.java
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaWasmEvaluator.java
index 89d2eac94df4..e67ca9bc276d 100644
---
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaWasmEvaluator.java
+++
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaWasmEvaluator.java
@@ -27,6 +27,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.styra.opa.wasm.OpaPolicy;
import org.apache.camel.CamelContext;
import org.apache.camel.support.ResourceHelper;
+import org.apache.camel.util.ObjectHelper;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
@@ -95,6 +96,32 @@ public class OpaWasmEvaluator extends OpaPolicyEvaluator
implements AutoCloseabl
}
}
+ /**
+ * Builds a wasm evaluator from a bundle location, applying the validation
and the entrypoint default in one place
+ * so that the producer and the {@code OpaSecurityPolicy} construct it
identically.
+ */
+ public static OpaWasmEvaluator create(
+ CamelContext camelContext, String policyBundle, String entrypoint,
int poolSize, long borrowTimeout,
+ String policyPath, String allowKey, String includeHeaders, String
includeProperties, boolean includeBody,
+ boolean failOpen)
+ throws Exception {
+ if (ObjectHelper.isEmpty(policyBundle)) {
+ throw new IllegalArgumentException(
+ "policyBundle is required when evaluationMode=wasm; build
one with"
+ + " opa build -t wasm -e
<entrypoint> <policy.rego>");
+ }
+ if (poolSize < 1) {
+ throw new IllegalArgumentException("poolSize must be at least 1
when evaluationMode=wasm, was " + poolSize);
+ }
+ // the entrypoint is fixed at build time and is not the same thing as
a data path, but opa build names it after
+ // the rule, so the policy path is the right default
+ String resolvedEntrypoint = ObjectHelper.isNotEmpty(entrypoint) ?
entrypoint : policyPath;
+ Bundle bundle = loadPolicy(camelContext, policyBundle);
+ return new OpaWasmEvaluator(
+ bundle.wasm(), bundle.data(), resolvedEntrypoint, poolSize,
borrowTimeout, policyPath, allowKey,
+ includeHeaders, includeProperties, includeBody, failOpen);
+ }
+
/**
* A loaded policy: the WebAssembly module, and the data document that
{@code opa build} packed beside it when the
* source was a bundle. A policy that reads {@code data.*} needs the
latter to decide the same way it would against
diff --git
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityPolicy.java
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityPolicy.java
index 54054bad0361..219c34ca68ca 100644
---
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityPolicy.java
+++
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityPolicy.java
@@ -30,6 +30,7 @@ import org.apache.camel.RuntimeCamelException;
import org.apache.camel.component.opa.OpaHttpClient;
import org.apache.camel.component.opa.OpaPolicyEvaluator;
import org.apache.camel.component.opa.OpaRestEvaluator;
+import org.apache.camel.component.opa.OpaWasmEvaluator;
import org.apache.camel.health.HealthCheckRegistry;
import org.apache.camel.spi.AuthorizationPolicy;
import org.apache.camel.support.jsse.SSLContextParameters;
@@ -57,7 +58,11 @@ public class OpaSecurityPolicy implements
AuthorizationPolicy {
private static final Logger LOG =
LoggerFactory.getLogger(OpaSecurityPolicy.class);
- private String serverUrl = "http://localhost:8181";
+ private static final String WASM_MODE = "wasm";
+ private static final String REST_MODE = "rest";
+ private static final String DEFAULT_SERVER_URL = "http://localhost:8181";
+
+ private String serverUrl = DEFAULT_SERVER_URL;
private String policyPath;
private String allowKey = "allow";
private String includeHeaders = "*";
@@ -65,6 +70,11 @@ public class OpaSecurityPolicy implements
AuthorizationPolicy {
private boolean includeBody;
private String bearerToken;
private boolean failOpen;
+ private String evaluationMode = REST_MODE;
+ private String policyBundle;
+ private String entrypoint;
+ private int poolSize = 8;
+ private long borrowTimeout = 30000;
private OPAClient opaClient;
private boolean healthCheckEnabled = true;
@@ -92,20 +102,10 @@ public class OpaSecurityPolicy implements
AuthorizationPolicy {
this.camelContext = route.getCamelContext();
if (evaluator == null) {
StringHelper.notEmpty(policyPath, "policyPath", this);
- OpaHttpClient transport = null;
- if (opaClient == null) {
- // createClient moved to OpaRestEvaluator when the evaluator
became an abstract base
- sslContext = createSslContext(route.getCamelContext());
- transport = OpaRestEvaluator.createTransport(
- bearerToken, connectionTimeout, requestTimeout,
sslContext);
- opaClient = OpaRestEvaluator.createClient(serverUrl,
transport);
- ownsClient = true;
- }
- evaluator = new OpaRestEvaluator(
- opaClient, transport, policyPath, allowKey,
includeHeaders, includeProperties, includeBody,
- failOpen);
- // a Policy has no stop hook of its own, so the transport would
outlive the routes it was built for.
- // Registering the evaluator as a service hands its close() to the
context's shutdown
+ evaluator = buildEvaluator(route.getCamelContext());
+ // a Policy has no stop hook of its own, so the evaluator - its
HTTP transport in rest mode, or its
+ // WebAssembly instance pool in wasm mode - would outlive the
routes it was built for. Registering it as a
+ // service hands its close() to the context's shutdown.
try {
route.getCamelContext().addService(evaluator);
} catch (Exception e) {
@@ -115,6 +115,60 @@ public class OpaSecurityPolicy implements
AuthorizationPolicy {
// The health check is registered and unregistered from the wrapped
processors' lifecycle
// (onProcessorStart/onProcessorStop), not here: beforeWrap does not
run again when a route is merely
// restarted, so a check registered here would be left behind when the
guarded routes stop (CAMEL-24751).
+ // In wasm mode nothing sets ownsClient, so registerHealthCheck skips
anyway - the policy is evaluated
+ // in-process and there is no server to probe (consistent with
CAMEL-24743).
+ }
+
+ /**
+ * Builds the evaluator for the configured {@code evaluationMode}. Both
share {@link OpaPolicyEvaluator}, so the
+ * decision contract - the {@code CamelOpaDecision} headers and a
fail-closed default - is identical whichever
+ * engine runs.
+ */
+ private OpaPolicyEvaluator buildEvaluator(CamelContext camelContext) {
+ if (WASM_MODE.equalsIgnoreCase(evaluationMode)) {
+ warnIgnoredServerOptions();
+ try {
+ return OpaWasmEvaluator.create(camelContext, policyBundle,
entrypoint, poolSize, borrowTimeout,
+ policyPath, allowKey, includeHeaders,
includeProperties, includeBody, failOpen);
+ } catch (RuntimeException e) {
+ // the validation messages (policyBundle, poolSize) already
read correctly; do not bury them
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeCamelException("Could not load the wasm
policy bundle for policy " + policyPath, e);
+ }
+ }
+ if (!REST_MODE.equalsIgnoreCase(evaluationMode)) {
+ throw new IllegalArgumentException(
+ "Unknown evaluationMode '" + evaluationMode + "'; expected
one of " + REST_MODE + ", " + WASM_MODE);
+ }
+ OpaHttpClient transport = null;
+ if (opaClient == null) {
+ // createClient moved to OpaRestEvaluator when the evaluator
became an abstract base
+ sslContext = createSslContext(camelContext);
+ transport = OpaRestEvaluator.createTransport(bearerToken,
connectionTimeout, requestTimeout, sslContext);
+ opaClient = OpaRestEvaluator.createClient(serverUrl, transport);
+ ownsClient = true;
+ }
+ return new OpaRestEvaluator(
+ opaClient, transport, policyPath, allowKey, includeHeaders,
includeProperties, includeBody, failOpen);
+ }
+
+ /**
+ * Warns at startup when options that only make sense for the REST engine
are set in {@code wasm} mode, matching
+ * {@code OpaEndpoint.warnAboutIgnoredServerOptions} so both entry points
behave alike. {@code failOpen} is not
+ * among them - it still governs a {@code wasm} evaluation failure. {@code
serverUrl}, {@code bearerToken} and an
+ * injected {@code opaClient} address, authenticate to or replace a server
there is none of in {@code wasm} mode.
+ */
+ private void warnIgnoredServerOptions() {
+ if (opaClient != null) {
+ LOG.warn("opaClient is ignored when evaluationMode=wasm: the
policy is evaluated in-process");
+ }
+ if (ObjectHelper.isNotEmpty(bearerToken)) {
+ LOG.warn("bearerToken is ignored when evaluationMode=wasm: there
is no server to authenticate to");
+ }
+ if (ObjectHelper.isNotEmpty(serverUrl) &&
!DEFAULT_SERVER_URL.equals(serverUrl)) {
+ LOG.warn("serverUrl '{}' is ignored when evaluationMode=wasm: the
policy is evaluated in-process", serverUrl);
+ }
}
/**
@@ -272,6 +326,71 @@ public class OpaSecurityPolicy implements
AuthorizationPolicy {
this.failOpen = failOpen;
}
+ public String getEvaluationMode() {
+ return evaluationMode;
+ }
+
+ /**
+ * How the policy is evaluated. {@code rest} (the default) asks a running
OPA server; {@code wasm} evaluates a
+ * WebAssembly bundle in-process, with no server involved - preferred for
a hot path such as authorizing an AI tool
+ * call. {@code serverUrl}, {@code bearerToken} and the readiness check do
not apply in {@code wasm} mode;
+ * {@code failOpen} still governs an evaluation failure in both.
+ */
+ public void setEvaluationMode(String evaluationMode) {
+ this.evaluationMode = evaluationMode;
+ }
+
+ public String getPolicyBundle() {
+ return policyBundle;
+ }
+
+ /**
+ * The WebAssembly policy to evaluate in {@code wasm} mode, as produced by
{@code opa build -t wasm}. Accepts a
+ * {@code file:}, {@code classpath:} or {@code http:} location holding
either the {@code bundle.tar.gz} that
+ * {@code opa build} emits or a bare {@code .wasm} module. Required when
{@code evaluationMode=wasm}.
+ */
+ public void setPolicyBundle(String policyBundle) {
+ this.policyBundle = policyBundle;
+ }
+
+ public String getEntrypoint() {
+ return entrypoint;
+ }
+
+ /**
+ * The compiled entrypoint to evaluate in {@code wasm} mode, fixed when
the bundle is built with {@code opa build
+ * -e}. This is not the same thing as the policy path; it defaults to the
policy path, which is the name
+ * {@code opa build} gives it.
+ */
+ public void setEntrypoint(String entrypoint) {
+ this.entrypoint = entrypoint;
+ }
+
+ public int getPoolSize() {
+ return poolSize;
+ }
+
+ /**
+ * How many WebAssembly evaluation instances to pool in {@code wasm} mode.
They carry mutable state and are not
+ * thread-safe, so this bounds how many exchanges are authorized at once;
an exchange that arrives when all are busy
+ * waits up to {@code borrowTimeout}.
+ */
+ public void setPoolSize(int poolSize) {
+ this.poolSize = poolSize;
+ }
+
+ public long getBorrowTimeout() {
+ return borrowTimeout;
+ }
+
+ /**
+ * How long, in milliseconds, an exchange waits for a free WebAssembly
instance in {@code wasm} mode before the
+ * evaluation fails. That failure is not a deny: it fails closed, or
proceeds under {@code failOpen}.
+ */
+ public void setBorrowTimeout(long borrowTimeout) {
+ this.borrowTimeout = borrowTimeout;
+ }
+
/**
* The policy is a bean rather than a {@code CamelContextAware} service,
so the context comes from the route it is
* wrapping - which is the only place one is available.
diff --git
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/security/OpaSecurityPolicyWasmTest.java
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/security/OpaSecurityPolicyWasmTest.java
new file mode 100644
index 000000000000..2de9e8da8c09
--- /dev/null
+++
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/security/OpaSecurityPolicyWasmTest.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.opa.security;
+
+import java.util.List;
+
+import org.apache.camel.CamelAuthorizationException;
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.health.HealthCheck;
+import org.apache.camel.health.HealthCheckRegistry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * {@link OpaSecurityPolicy} enforcing a route with an in-process WebAssembly
bundle. The decision contract is the same
+ * as the REST engine - a match proceeds, a non-match throws {@code
CamelAuthorizationException} - and no server
+ * readiness check is registered, because the policy is evaluated in-process
(CAMEL-24830).
+ */
+public class OpaSecurityPolicyWasmTest extends CamelTestSupport {
+
+ private final OpaSecurityPolicy wasmPolicy = new OpaSecurityPolicy();
+ private final OpaSecurityPolicy restPolicy = new OpaSecurityPolicy();
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ wasmPolicy.setEvaluationMode("wasm");
+ wasmPolicy.setPolicyBundle("classpath:authz.wasm");
+ wasmPolicy.setPolicyPath("authz/allow");
+
+ // a rest-mode policy is the positive control for the readiness-check
assertion: it registers a check, the
+ // wasm one must not
+ restPolicy.setServerUrl("http://opa-rest:8181");
+ restPolicy.setPolicyPath("authz/allow");
+
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:wasm").policy(wasmPolicy).to("mock:allowed");
+ from("direct:rest").policy(restPolicy).to("mock:rest");
+ }
+ };
+ }
+
+ @Test
+ void allowsWhenTheWasmPolicyMatches() throws Exception {
+ MockEndpoint allowed = getMockEndpoint("mock:allowed");
+ allowed.expectedMessageCount(1);
+
+ template.sendBodyAndHeader("direct:wasm", "payload", "user", "alice");
+
+ allowed.assertIsSatisfied();
+ }
+
+ @Test
+ void deniesWhenTheWasmPolicyDoesNotMatch() throws Exception {
+ MockEndpoint allowed = getMockEndpoint("mock:allowed");
+ allowed.expectedMessageCount(0);
+
+ assertThatThrownBy(() -> template.sendBodyAndHeader("direct:wasm",
"payload", "user", "mallory"))
+ .isInstanceOf(CamelExecutionException.class)
+ .hasCauseInstanceOf(CamelAuthorizationException.class);
+
+ allowed.assertIsSatisfied();
+ }
+
+ @Test
+ void registersOnlyTheRestPolicysReadinessCheck() {
+ HealthCheckRegistry registry = HealthCheckRegistry.get(context);
+ assertThat(registry).isNotNull();
+ List<HealthCheck> checks = registry.stream()
+ .filter(hc -> hc.getId().startsWith("security-policy:opa-"))
+ .toList();
+
+ // exactly one, and it is the rest policy's - the wasm policy
evaluates in-process with no server to probe.
+ // Assert the full ID contract, not just the hostname, so a refactor
of the ID-building logic cannot pass here
+ // silently: OpaSecurityPolicyHealthCheck builds it as
"security-policy:opa-" + sanitized serverUrl/policyPath.
+ assertThat(checks).hasSize(1);
+
assertThat(checks.get(0).getId()).startsWith("security-policy:opa-").contains("opa-rest");
+ }
+
+ @Test
+ void failsRouteStartOnABundleThatLoadsButIsNotAValidModule() {
+ // OpaWasmEvaluator borrows an instance at startup so a broken bundle
fails fast, and beforeWrap - which cannot
+ // throw a checked exception - must surface that rather than swallow
it, or a route would start and then
+ // authorize nothing. authz.rego is the Rego source: it loads as bytes
but is not a compiled wasm module.
+ OpaSecurityPolicy corrupt = new OpaSecurityPolicy();
+ corrupt.setEvaluationMode("wasm");
+ corrupt.setPolicyBundle("classpath:authz.rego");
+ corrupt.setPolicyPath("authz/allow");
+
+ // OpaPolicy rejects the module during the warmup borrow with an
unchecked exception, which buildEvaluator's
+ // catch(RuntimeException) rethrows as-is; reaching the caller of
addRoutes is what proves the route did not
+ // start (a first-exchange failure would not surface here).
+ assertThatThrownBy(() -> context.addRoutes(routeWith(corrupt)))
+ .isInstanceOf(RuntimeException.class);
+ }
+
+ @Test
+ void failsRouteStartWhenTheBundleResourceCannotBeLoaded() {
+ // a bundle location that resolves to nothing is a checked failure in
loadPolicy, which beforeWrap wraps; the
+ // wrapper message is what proves the failure surfaced at startup
rather than being swallowed
+ OpaSecurityPolicy missing = new OpaSecurityPolicy();
+ missing.setEvaluationMode("wasm");
+ missing.setPolicyBundle("classpath:does-not-exist.wasm");
+ missing.setPolicyPath("authz/allow");
+
+ assertThatThrownBy(() -> context.addRoutes(routeWith(missing)))
+ .isInstanceOf(RuntimeCamelException.class)
+ .hasMessageContaining("Could not load the wasm policy bundle");
+ }
+
+ @Test
+ void failsRouteStartOnAnUnknownEvaluationMode() {
+ // this path lives entirely in the policy's buildEvaluator and is
covered by no endpoint test
+ OpaSecurityPolicy bogus = new OpaSecurityPolicy();
+ bogus.setEvaluationMode("bogus");
+ bogus.setPolicyPath("authz/allow");
+
+ assertThatThrownBy(() -> context.addRoutes(routeWith(bogus)))
+ .hasRootCauseInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Unknown evaluationMode");
+ }
+
+ @Test
+ void failsRouteStartWhenNoWasmBundleIsConfigured() {
+ // buildEvaluator forwards to OpaWasmEvaluator.create, so its
validation applies through the policy too
+ OpaSecurityPolicy noBundle = new OpaSecurityPolicy();
+ noBundle.setEvaluationMode("wasm");
+ noBundle.setPolicyPath("authz/allow");
+
+ assertThatThrownBy(() -> context.addRoutes(routeWith(noBundle)))
+ .hasRootCauseInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("policyBundle is required");
+ }
+
+ @Test
+ void failsRouteStartOnAPoolSizeBelowOne() {
+ OpaSecurityPolicy badPool = new OpaSecurityPolicy();
+ badPool.setEvaluationMode("wasm");
+ badPool.setPolicyBundle("classpath:authz.wasm");
+ badPool.setPolicyPath("authz/allow");
+ badPool.setPoolSize(0);
+
+ assertThatThrownBy(() -> context.addRoutes(routeWith(badPool)))
+ .hasRootCauseInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("poolSize must be at least 1");
+ }
+
+ private static RouteBuilder routeWith(OpaSecurityPolicy policy) {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:probe").policy(policy).to("mock:never");
+ }
+ };
+ }
+}