This is an automated email from the ASF dual-hosted git repository.
davsclaus 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 f8fb7bf55452 CAMEL-26829: Preserve per-item OPA batch decision
outcomes (#29637)
f8fb7bf55452 is described below
commit f8fb7bf55452fff68f8016141250adac15936710
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Nov 12 21:45:50 2026 +0100
CAMEL-26829: Preserve per-item OPA batch decision outcomes (#29637)
Preserve the Boolean batch-result list and add immutable per-item outcomes
containing the normalized decision, sanitized failure category and fail-open
flag. Set the aggregate failed-open header when any element used fail-open
handling.
Outcomes have a readable diagnostic representation and support
serialization in persisted exchange headers. Documents the shared outcome
semantics for whole-batch and per-element unavailability, with
component/catalog and migration documentation.
Codex on behalf of oscerd.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-authored-by: Codex <[email protected]>
---
.../org/apache/camel/catalog/components/opa.json | 5 +-
.../apache/camel/catalog/docs/opa-component.adoc | 22 +++++
.../org/apache/camel/component/opa/opa.json | 5 +-
.../camel-opa/src/main/docs/opa-component.adoc | 22 +++++
.../camel/component/opa/OpaBatchOutcome.java | 86 +++++++++++++++++
.../apache/camel/component/opa/OpaConstants.java | 10 +-
.../camel/component/opa/OpaPolicyEvaluator.java | 67 +++++++++-----
.../camel/component/opa/OpaRestEvaluator.java | 6 +-
.../component/opa/OpaBatchEvaluationTest.java | 102 +++++++++++++++++++++
.../camel/component/opa/OpaBatchOutcomeTest.java | 64 +++++++++++++
.../camel/component/opa/OpaInputDocumentTest.java | 3 +-
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 7 ++
.../endpoint/dsl/OpaEndpointBuilderFactory.java | 20 +++-
13 files changed, 387 insertions(+), 32 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/opa.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/opa.json
index 6d841fa2a489..7eec7e2fdf00 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/opa.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/opa.json
@@ -52,8 +52,9 @@
"CamelOpaDecisionAllow": { "index": 0, "kind": "header", "displayName":
"", "group": "producer", "label": "producer", "required": false, "javaType":
"Boolean", "deprecated": false, "autowired": false, "secret": false,
"description": "The allow\/deny verdict of the policy evaluation. Always
overwritten by the component, so a value set by an inbound message never
survives into the route.", "constantName":
"org.apache.camel.component.opa.OpaConstants#DECISION_ALLOW" },
"CamelOpaDecision": { "index": 1, "kind": "header", "displayName": "",
"group": "producer", "label": "producer", "required": false, "javaType":
"Object", "deprecated": false, "autowired": false, "secret": false,
"description": "The raw decision document returned by OPA. Useful for policies
that return more than a boolean, such as obligations, row filters or deny
reasons.", "constantName":
"org.apache.camel.component.opa.OpaConstants#DECISION" },
"CamelOpaPolicyPath": { "index": 2, "kind": "header", "displayName": "",
"group": "producer", "label": "producer", "required": false, "javaType":
"String", "deprecated": false, "autowired": false, "secret": false,
"description": "The policy path that was evaluated. Set by the component for
observability; it is not read as an input and cannot be used to select a
different policy.", "constantName":
"org.apache.camel.component.opa.OpaConstants#POLICY_PATH" },
- "CamelOpaDecisionFailedOpen": { "index": 3, "kind": "header",
"displayName": "", "group": "producer", "label": "producer", "required": false,
"javaType": "Boolean", "deprecated": false, "autowired": false, "secret":
false, "description": "Set to true only when the exchange proceeded because
failOpen is enabled and the policy could not be evaluated - nothing authorized
it. Absent on every decision an actual policy made, so a route or an audit
trail can tell the two apart rather than s [...]
- "CamelOpaBatchDecision": { "index": 4, "kind": "header", "displayName":
"", "group": "producer", "label": "producer", "required": false, "javaType":
"java.util.List<Boolean>", "deprecated": false, "autowired": false, "secret":
false, "description": "The per-element allow\/deny verdicts of a batch
evaluation (batch=true), as a List of Boolean parallel to the List body. Always
overwritten by the component. An element whose evaluation could not be reached
is denied, unless failOpen is s [...]
+ "CamelOpaDecisionFailedOpen": { "index": 3, "kind": "header",
"displayName": "", "group": "producer", "label": "producer", "required": false,
"javaType": "Boolean", "deprecated": false, "autowired": false, "secret":
false, "description": "Set to true only when the exchange proceeded because
failOpen is enabled and the policy could not be evaluated - nothing authorized
it. Absent on every decision an actual policy made, so a route or an audit
trail can tell the two apart rather than s [...]
+ "CamelOpaBatchDecision": { "index": 4, "kind": "header", "displayName":
"", "group": "producer", "label": "producer", "required": false, "javaType":
"java.util.List<Boolean>", "deprecated": false, "autowired": false, "secret":
false, "description": "The per-element allow\/deny verdicts of a batch
evaluation (batch=true), as a List of Boolean parallel to the List body. Always
overwritten by the component. An element whose evaluation could not be reached
is denied, unless failOpen is s [...]
+ "CamelOpaBatchOutcomes": { "index": 5, "kind": "header", "displayName":
"", "group": "producer", "label": "producer", "required": false, "javaType":
"java.util.List<org.apache.camel.component.opa.OpaBatchOutcome>", "deprecated":
false, "autowired": false, "secret": false, "description": "Immutable
per-element outcomes of a batch evaluation, parallel to the List body. Each
outcome exposes the effective allowed verdict, nullable Boolean policy
decision, failure category and failedOpen [...]
},
"bodyTypes": {
"producerIn": { "carriers": [ "any" ], "byOption": [ { "option": "batch",
"value": "true", "carriers": [ "list" ] } ], "description": "Not read unless
includeBody=true: then a String, number, boolean, Map or List goes into the
input document as it is, and another body as its string form (a POJO as its
toString). With batch=true a List, one input document per element: another body
fails" },
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 6c5f3077c2b5..0c92bf687881 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
@@ -308,6 +308,28 @@ one element failed, nor allowed because most of it
succeeded. A call that fails
reached at all - fails the exchange rather than denying each element: it
carries no verdict at all, not a list of
`false`. Under `failOpen` a call that could not reach the server allows every
element instead.
+`CamelOpaBatchOutcomes` provides an immutable `List<OpaBatchOutcome>` in the
same order, while
+`CamelOpaBatchDecision` keeps its existing Boolean-list contract. Each outcome
has:
+
+[cols="1,3",options="header"]
+|===
+|Property |Meaning
+|`allowed` |Whether the element may proceed, including an explicit fail-open
result.
+|`decision` |The normalized Boolean policy verdict, or `null` when no decision
was obtained. This is not the raw policy document.
+|`failureCategory` |`NONE`, `UNAVAILABLE`, `EVALUATION_ERROR`,
`MISSING_RESULT` or `INTERRUPTED`.
+|`failedOpen` |`true` only when the element proceeded because its decision
point was unavailable and `failOpen` was enabled.
+|===
+
+These outcomes contain no exception objects or failure messages. A genuine
policy denial has `decision=false`,
+`failureCategory=NONE` and `failedOpen=false`. An unavailable element allowed
under `failOpen` has
+`allowed=true`, `decision=null`, `failureCategory=UNAVAILABLE` and
`failedOpen=true`.
+`CamelOpaDecisionFailedOpen=true` marks a batch whenever *any* element failed
open; inspect the outcomes to identify
+which ones. A call that could not reach the server at all is reported like an
element whose own decision point was
+unavailable: under `failOpen` every element is `UNAVAILABLE` and
`failedOpen=true`, and the outcomes do not say which
+of the two happened, since the route proceeds the same way in both cases.
Empty batches return empty verdict and outcome lists. A whole-batch failure
that fails closed carries
+neither list. The outcome header and aggregate marker are cleared before every
evaluation, including a subsequent
+single-element evaluation.
+
`batch` requires `evaluationMode=rest`: it saves the per-element HTTP
round-trip, which has no meaning for in-process
`wasm` evaluation, and the endpoint rejects the combination at startup.
diff --git
a/components/camel-opa/src/generated/resources/META-INF/org/apache/camel/component/opa/opa.json
b/components/camel-opa/src/generated/resources/META-INF/org/apache/camel/component/opa/opa.json
index 6d841fa2a489..7eec7e2fdf00 100644
---
a/components/camel-opa/src/generated/resources/META-INF/org/apache/camel/component/opa/opa.json
+++
b/components/camel-opa/src/generated/resources/META-INF/org/apache/camel/component/opa/opa.json
@@ -52,8 +52,9 @@
"CamelOpaDecisionAllow": { "index": 0, "kind": "header", "displayName":
"", "group": "producer", "label": "producer", "required": false, "javaType":
"Boolean", "deprecated": false, "autowired": false, "secret": false,
"description": "The allow\/deny verdict of the policy evaluation. Always
overwritten by the component, so a value set by an inbound message never
survives into the route.", "constantName":
"org.apache.camel.component.opa.OpaConstants#DECISION_ALLOW" },
"CamelOpaDecision": { "index": 1, "kind": "header", "displayName": "",
"group": "producer", "label": "producer", "required": false, "javaType":
"Object", "deprecated": false, "autowired": false, "secret": false,
"description": "The raw decision document returned by OPA. Useful for policies
that return more than a boolean, such as obligations, row filters or deny
reasons.", "constantName":
"org.apache.camel.component.opa.OpaConstants#DECISION" },
"CamelOpaPolicyPath": { "index": 2, "kind": "header", "displayName": "",
"group": "producer", "label": "producer", "required": false, "javaType":
"String", "deprecated": false, "autowired": false, "secret": false,
"description": "The policy path that was evaluated. Set by the component for
observability; it is not read as an input and cannot be used to select a
different policy.", "constantName":
"org.apache.camel.component.opa.OpaConstants#POLICY_PATH" },
- "CamelOpaDecisionFailedOpen": { "index": 3, "kind": "header",
"displayName": "", "group": "producer", "label": "producer", "required": false,
"javaType": "Boolean", "deprecated": false, "autowired": false, "secret":
false, "description": "Set to true only when the exchange proceeded because
failOpen is enabled and the policy could not be evaluated - nothing authorized
it. Absent on every decision an actual policy made, so a route or an audit
trail can tell the two apart rather than s [...]
- "CamelOpaBatchDecision": { "index": 4, "kind": "header", "displayName":
"", "group": "producer", "label": "producer", "required": false, "javaType":
"java.util.List<Boolean>", "deprecated": false, "autowired": false, "secret":
false, "description": "The per-element allow\/deny verdicts of a batch
evaluation (batch=true), as a List of Boolean parallel to the List body. Always
overwritten by the component. An element whose evaluation could not be reached
is denied, unless failOpen is s [...]
+ "CamelOpaDecisionFailedOpen": { "index": 3, "kind": "header",
"displayName": "", "group": "producer", "label": "producer", "required": false,
"javaType": "Boolean", "deprecated": false, "autowired": false, "secret":
false, "description": "Set to true only when the exchange proceeded because
failOpen is enabled and the policy could not be evaluated - nothing authorized
it. Absent on every decision an actual policy made, so a route or an audit
trail can tell the two apart rather than s [...]
+ "CamelOpaBatchDecision": { "index": 4, "kind": "header", "displayName":
"", "group": "producer", "label": "producer", "required": false, "javaType":
"java.util.List<Boolean>", "deprecated": false, "autowired": false, "secret":
false, "description": "The per-element allow\/deny verdicts of a batch
evaluation (batch=true), as a List of Boolean parallel to the List body. Always
overwritten by the component. An element whose evaluation could not be reached
is denied, unless failOpen is s [...]
+ "CamelOpaBatchOutcomes": { "index": 5, "kind": "header", "displayName":
"", "group": "producer", "label": "producer", "required": false, "javaType":
"java.util.List<org.apache.camel.component.opa.OpaBatchOutcome>", "deprecated":
false, "autowired": false, "secret": false, "description": "Immutable
per-element outcomes of a batch evaluation, parallel to the List body. Each
outcome exposes the effective allowed verdict, nullable Boolean policy
decision, failure category and failedOpen [...]
},
"bodyTypes": {
"producerIn": { "carriers": [ "any" ], "byOption": [ { "option": "batch",
"value": "true", "carriers": [ "list" ] } ], "description": "Not read unless
includeBody=true: then a String, number, boolean, Map or List goes into the
input document as it is, and another body as its string form (a POJO as its
toString). With batch=true a List, one input document per element: another body
fails" },
diff --git a/components/camel-opa/src/main/docs/opa-component.adoc
b/components/camel-opa/src/main/docs/opa-component.adoc
index 6c5f3077c2b5..0c92bf687881 100644
--- a/components/camel-opa/src/main/docs/opa-component.adoc
+++ b/components/camel-opa/src/main/docs/opa-component.adoc
@@ -308,6 +308,28 @@ one element failed, nor allowed because most of it
succeeded. A call that fails
reached at all - fails the exchange rather than denying each element: it
carries no verdict at all, not a list of
`false`. Under `failOpen` a call that could not reach the server allows every
element instead.
+`CamelOpaBatchOutcomes` provides an immutable `List<OpaBatchOutcome>` in the
same order, while
+`CamelOpaBatchDecision` keeps its existing Boolean-list contract. Each outcome
has:
+
+[cols="1,3",options="header"]
+|===
+|Property |Meaning
+|`allowed` |Whether the element may proceed, including an explicit fail-open
result.
+|`decision` |The normalized Boolean policy verdict, or `null` when no decision
was obtained. This is not the raw policy document.
+|`failureCategory` |`NONE`, `UNAVAILABLE`, `EVALUATION_ERROR`,
`MISSING_RESULT` or `INTERRUPTED`.
+|`failedOpen` |`true` only when the element proceeded because its decision
point was unavailable and `failOpen` was enabled.
+|===
+
+These outcomes contain no exception objects or failure messages. A genuine
policy denial has `decision=false`,
+`failureCategory=NONE` and `failedOpen=false`. An unavailable element allowed
under `failOpen` has
+`allowed=true`, `decision=null`, `failureCategory=UNAVAILABLE` and
`failedOpen=true`.
+`CamelOpaDecisionFailedOpen=true` marks a batch whenever *any* element failed
open; inspect the outcomes to identify
+which ones. A call that could not reach the server at all is reported like an
element whose own decision point was
+unavailable: under `failOpen` every element is `UNAVAILABLE` and
`failedOpen=true`, and the outcomes do not say which
+of the two happened, since the route proceeds the same way in both cases.
Empty batches return empty verdict and outcome lists. A whole-batch failure
that fails closed carries
+neither list. The outcome header and aggregate marker are cleared before every
evaluation, including a subsequent
+single-element evaluation.
+
`batch` requires `evaluationMode=rest`: it saves the per-element HTTP
round-trip, which has no meaning for in-process
`wasm` evaluation, and the endpoint rejects the combination at startup.
diff --git
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaBatchOutcome.java
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaBatchOutcome.java
new file mode 100644
index 000000000000..6952ebb4d09b
--- /dev/null
+++
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaBatchOutcome.java
@@ -0,0 +1,86 @@
+/*
+ * 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;
+
+import java.io.Serializable;
+
+/**
+ * An immutable batch element outcome, distinguishing a policy decision from a
failure to obtain one.
+ * <p/>
+ * The decision is the normalized Boolean policy verdict, not the raw decision
document. Failure messages and exceptions
+ * are deliberately omitted: they can contain request data or credentials.
+ * <p/>
+ * Serializable, so that the header holding the outcomes survives an
aggregation repository that stores headers.
+ */
+public final class OpaBatchOutcome implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ public enum FailureCategory {
+ NONE,
+ UNAVAILABLE,
+ EVALUATION_ERROR,
+ MISSING_RESULT,
+ INTERRUPTED
+ }
+
+ private final boolean allowed;
+ private final Boolean decision;
+ private final FailureCategory failureCategory;
+ private final boolean failedOpen;
+
+ private OpaBatchOutcome(boolean allowed, Boolean decision, FailureCategory
failureCategory, boolean failedOpen) {
+ this.allowed = allowed;
+ this.decision = decision;
+ this.failureCategory = failureCategory;
+ this.failedOpen = failedOpen;
+ }
+
+ static OpaBatchOutcome decision(boolean allowed) {
+ return new OpaBatchOutcome(allowed, allowed, FailureCategory.NONE,
false);
+ }
+
+ static OpaBatchOutcome failure(FailureCategory category, boolean
failedOpen) {
+ return new OpaBatchOutcome(failedOpen, null, category, failedOpen);
+ }
+
+ /** Whether this element is allowed to proceed, including an explicit
fail-open result. */
+ public boolean isAllowed() {
+ return allowed;
+ }
+
+ /** The policy's normalized Boolean verdict, or null when no decision was
obtained. */
+ public Boolean getDecision() {
+ return decision;
+ }
+
+ /** Why no decision was obtained, or NONE when a policy decision was
obtained. */
+ public FailureCategory getFailureCategory() {
+ return failureCategory;
+ }
+
+ /** Whether the element proceeded only because failOpen was enabled and
its decision point was unavailable. */
+ public boolean isFailedOpen() {
+ return failedOpen;
+ }
+
+ @Override
+ public String toString() {
+ return "OpaBatchOutcome[allowed=" + allowed + ", decision=" + decision
+ ", failureCategory=" + failureCategory
+ + ", failedOpen=" + failedOpen + "]";
+ }
+}
diff --git
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaConstants.java
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaConstants.java
index 583b0dd6f49e..eda410c66972 100644
---
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaConstants.java
+++
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaConstants.java
@@ -43,7 +43,8 @@ public final class OpaConstants {
description = "Set to true only when the exchange proceeded
because failOpen is enabled and the policy"
+ " could not be evaluated - nothing authorized
it. Absent on every decision an actual"
+ " policy made, so a route or an audit trail can
tell the two apart rather than seeing"
- + " the same CamelOpaDecisionAllow=true for both.",
+ + " the same CamelOpaDecisionAllow=true for both.
For a batch, set when any element"
+ + " proceeded because failOpen was enabled;
CamelOpaBatchOutcomes identifies the elements.",
javaType = "Boolean")
public static final String DECISION_FAILED_OPEN = HEADER_PREFIX +
"DecisionFailedOpen";
@@ -54,6 +55,13 @@ public final class OpaConstants {
javaType = "java.util.List<Boolean>")
public static final String BATCH_DECISION = HEADER_PREFIX +
"BatchDecision";
+ @Metadata(label = "producer",
+ description = "Immutable per-element outcomes of a batch
evaluation, parallel to the List body. Each"
+ + " outcome exposes the effective allowed verdict,
nullable Boolean policy decision,"
+ + " failure category and failedOpen flag. Contains
no exception or failure message.",
+ javaType =
"java.util.List<org.apache.camel.component.opa.OpaBatchOutcome>")
+ public static final String BATCH_OUTCOMES = HEADER_PREFIX +
"BatchOutcomes";
+
private OpaConstants() {
}
}
diff --git
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaPolicyEvaluator.java
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaPolicyEvaluator.java
index 18f4a66b6532..1e20ad88f96e 100644
---
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaPolicyEvaluator.java
+++
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaPolicyEvaluator.java
@@ -177,12 +177,15 @@ public abstract class OpaPolicyEvaluator {
/**
* Authorizes a list in one call and records the per-element verdicts in
{@link OpaConstants#BATCH_DECISION}, a
- * {@code List<Boolean>} parallel to the input.
+ * {@code List<Boolean>} parallel to the input, and the per-element {@link
OpaBatchOutcome}s in
+ * {@link OpaConstants#BATCH_OUTCOMES}, which also say why an element has
no decision.
+ * {@link OpaConstants#DECISION_FAILED_OPEN} is set when any element
proceeded because it failed open.
* <p/>
* Fail-closed is per element: an element whose evaluation failed is
denied while the others decide normally, and
* under {@code failOpen} it is allowed only when its decision point was
unavailable. Only a batch call that fails
* as a whole denies every element - or, under {@code failOpen}, allows
every element when the server could not be
- * reached.
+ * reached. The outcomes report that case like an element whose own
decision point was unavailable: every element is
+ * {@code UNAVAILABLE} and failed open; they do not say which of the two
happened.
*/
public List<Boolean> evaluateBatch(Exchange exchange, List<?> elements)
throws OpaPolicyEvaluationException {
clearDecisionHeaders(exchange);
@@ -191,7 +194,7 @@ public abstract class OpaPolicyEvaluator {
// failure - and so, fail-closed, into an error thrown for an empty
list. Answer it deterministically instead.
if (elements.isEmpty()) {
List<Boolean> verdicts = List.of();
- setBatchDecisionHeaders(exchange, verdicts);
+ setBatchDecisionHeaders(exchange, verdicts, List.of());
return verdicts;
}
Map<String, Map<String, Object>> inputs = new LinkedHashMap<>();
@@ -199,11 +202,11 @@ public abstract class OpaPolicyEvaluator {
inputs.put(Integer.toString(i), buildInput(exchange,
elements.get(i), true));
}
- List<Boolean> verdicts = new ArrayList<>(elements.size());
+ List<OpaBatchOutcome> outcomes = new ArrayList<>(elements.size());
try {
Map<String, BatchElement> results = evaluateBatchDecisions(inputs);
for (int i = 0; i < elements.size(); i++) {
- verdicts.add(verdictFor(i, results != null ?
results.get(Integer.toString(i)) : null));
+ outcomes.add(outcomeFor(i, results != null ?
results.get(Integer.toString(i)) : null));
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
@@ -220,32 +223,48 @@ public abstract class OpaPolicyEvaluator {
LOG.warn("Batch policy {} could not be evaluated, allowing all {}
elements because failOpen is enabled."
+ " Reason: {}",
getPolicyPath(), elements.size(), e.getMessage());
+ outcomes.clear();
for (int i = 0; i < elements.size(); i++) {
- verdicts.add(Boolean.TRUE);
+
outcomes.add(OpaBatchOutcome.failure(OpaBatchOutcome.FailureCategory.UNAVAILABLE,
true));
}
}
- setBatchDecisionHeaders(exchange, verdicts);
+ List<Boolean> verdicts = new ArrayList<>(outcomes.size());
+ for (OpaBatchOutcome outcome : outcomes) {
+ verdicts.add(outcome.isAllowed());
+ }
+ setBatchDecisionHeaders(exchange, verdicts, outcomes);
return verdicts;
}
- private boolean verdictFor(int index, BatchElement element) {
- if (element != null && element.succeeded()) {
- return isAllowed(element.decision());
+ private OpaBatchOutcome outcomeFor(int index, BatchElement element) {
+ if (element == null) {
+ return
OpaBatchOutcome.failure(OpaBatchOutcome.FailureCategory.MISSING_RESULT, false);
}
- if (element != null && element.failure() != null) {
- restoreInterruptIfWrapped(element.failure());
+ if (element.succeeded()) {
+ return OpaBatchOutcome.decision(isAllowed(element.decision()));
}
- // a single element failed: deny it without failing the whole batch,
unless failOpen is set and its decision
- // point was unavailable
- if (failOpen && element != null && element.failure() != null &&
isDecisionPointUnavailable(element.failure())) {
- String reason = element.failure().getMessage();
+ Exception failure = element.failure();
+ restoreInterruptIfWrapped(failure);
+ OpaBatchOutcome.FailureCategory category = failureCategory(failure);
+ // Keep the same narrow fail-open scope as a single evaluation: a
server that answered did not go away.
+ boolean failedOpen = failOpen && category ==
OpaBatchOutcome.FailureCategory.UNAVAILABLE;
+ if (failedOpen) {
LOG.warn("Batch element {} of policy {} could not be evaluated,
allowing it because failOpen is enabled."
+ " Reason: {}",
- index, getPolicyPath(), reason);
- return true;
+ index, getPolicyPath(), failure.getMessage());
}
- return false;
+ return OpaBatchOutcome.failure(category, failedOpen);
+ }
+
+ private OpaBatchOutcome.FailureCategory failureCategory(Exception failure)
{
+ for (Throwable cause = failure; cause != null; cause =
cause.getCause()) {
+ if (cause instanceof InterruptedException) {
+ return OpaBatchOutcome.FailureCategory.INTERRUPTED;
+ }
+ }
+ return isDecisionPointUnavailable(failure)
+ ? OpaBatchOutcome.FailureCategory.UNAVAILABLE :
OpaBatchOutcome.FailureCategory.EVALUATION_ERROR;
}
/**
@@ -393,6 +412,7 @@ public abstract class OpaPolicyEvaluator {
// fail-open read as a decision a policy actually made
message.removeHeader(OpaConstants.DECISION_FAILED_OPEN);
message.removeHeader(OpaConstants.BATCH_DECISION);
+ message.removeHeader(OpaConstants.BATCH_OUTCOMES);
}
private void setDecisionHeaders(Exchange exchange, Object decision,
boolean allowed) {
@@ -408,9 +428,13 @@ public abstract class OpaPolicyEvaluator {
* As in {@link #evaluate}, a fail-closed batch failure never reaches
here, leaving the exchange carrying no
* verdict.
*/
- private void setBatchDecisionHeaders(Exchange exchange, List<Boolean>
verdicts) {
+ private void setBatchDecisionHeaders(Exchange exchange, List<Boolean>
verdicts, List<OpaBatchOutcome> outcomes) {
exchange.getMessage().setHeader(OpaConstants.BATCH_DECISION, verdicts);
+ exchange.getMessage().setHeader(OpaConstants.BATCH_OUTCOMES,
List.copyOf(outcomes));
exchange.getMessage().setHeader(OpaConstants.POLICY_PATH, policyPath);
+ if (outcomes.stream().anyMatch(OpaBatchOutcome::isFailedOpen)) {
+ exchange.getMessage().setHeader(OpaConstants.DECISION_FAILED_OPEN,
true);
+ }
}
private static boolean isIncluded(Set<String> filter, String name) {
@@ -442,7 +466,8 @@ public abstract class OpaPolicyEvaluator {
|| OpaConstants.DECISION.equalsIgnoreCase(name)
|| OpaConstants.POLICY_PATH.equalsIgnoreCase(name)
|| OpaConstants.DECISION_FAILED_OPEN.equalsIgnoreCase(name)
- || OpaConstants.BATCH_DECISION.equalsIgnoreCase(name);
+ || OpaConstants.BATCH_DECISION.equalsIgnoreCase(name)
+ || OpaConstants.BATCH_OUTCOMES.equalsIgnoreCase(name);
}
/**
diff --git
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaRestEvaluator.java
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaRestEvaluator.java
index 2a39c6e571e5..2116e6ae0270 100644
---
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaRestEvaluator.java
+++
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/OpaRestEvaluator.java
@@ -130,10 +130,8 @@ public class OpaRestEvaluator extends OpaPolicyEvaluator
implements AutoCloseabl
outcomes.put(entry.getKey(), new
BatchElement(result.getValue(), null));
} else {
Exception failure = result != null ? result.getException() :
null;
- if (failure == null) {
- failure = new IllegalStateException("no result returned
for batch element " + entry.getKey());
- }
- outcomes.put(entry.getKey(), new BatchElement(null, failure));
+ // A missing result is different from an evaluation that
reported a failure, even though both deny.
+ outcomes.put(entry.getKey(), failure != null ? new
BatchElement(null, failure) : null);
}
}
return outcomes;
diff --git
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaBatchEvaluationTest.java
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaBatchEvaluationTest.java
index 10291002fbc9..1f5b29c3d561 100644
---
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaBatchEvaluationTest.java
+++
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaBatchEvaluationTest.java
@@ -67,6 +67,11 @@ public class OpaBatchEvaluationTest extends CamelTestSupport
{
when(client.evaluateBatch(eq(PATH), anyMap())).thenReturn(results);
}
+ @SuppressWarnings("unchecked")
+ private static List<OpaBatchOutcome> outcomes(Exchange exchange) {
+ return exchange.getMessage().getHeader(OpaConstants.BATCH_OUTCOMES,
List.class);
+ }
+
@Test
void reportsAVerdictParallelToEachElement() throws Exception {
Map<String, OPAResult> results = new LinkedHashMap<>();
@@ -81,6 +86,9 @@ public class OpaBatchEvaluationTest extends CamelTestSupport {
assertThat(out.getException()).isNull();
assertThat(out.getMessage().getHeader(OpaConstants.BATCH_DECISION,
List.class))
.containsExactly(true, false, true);
+
assertThat(outcomes(out)).extracting(OpaBatchOutcome::getDecision).containsExactly(true,
false, true);
+
assertThat(outcomes(out)).extracting(OpaBatchOutcome::isFailedOpen).containsOnly(false);
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_FAILED_OPEN)).isNull();
// batch mode must set the policy-path header too, so observability
tooling reads the same
// CamelOpaPolicyPath as it does after a single evaluation
assertThat(out.getMessage().getHeader(OpaConstants.POLICY_PATH,
String.class)).isEqualTo(PATH);
@@ -167,6 +175,8 @@ public class OpaBatchEvaluationTest extends
CamelTestSupport {
assertThat(out.getException()).isNull();
assertThat(out.getMessage().getHeader(OpaConstants.BATCH_DECISION,
List.class)).isEmpty();
+ assertThat(outcomes(out)).isEmpty();
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_FAILED_OPEN)).isNull();
assertThat(out.getMessage().getHeader(OpaConstants.POLICY_PATH,
String.class)).isEqualTo(PATH);
verify(client, never()).evaluateBatch(anyString(), anyMap());
}
@@ -199,6 +209,11 @@ public class OpaBatchEvaluationTest extends
CamelTestSupport {
assertThat(out.getException()).isNull();
assertThat(out.getMessage().getHeader(OpaConstants.BATCH_DECISION,
List.class))
.containsExactly(true, true, true);
+
assertThat(outcomes(out)).extracting(OpaBatchOutcome::getDecision).containsOnlyNulls();
+
assertThat(outcomes(out)).extracting(OpaBatchOutcome::isFailedOpen).containsOnly(true);
+
assertThat(outcomes(out)).extracting(OpaBatchOutcome::getFailureCategory)
+ .containsOnly(OpaBatchOutcome.FailureCategory.UNAVAILABLE);
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_FAILED_OPEN)).isEqualTo(true);
}
@Test
@@ -231,4 +246,91 @@ public class OpaBatchEvaluationTest extends
CamelTestSupport {
assertThat(out.getException()).isInstanceOf(OpaPolicyEvaluationException.class);
assertThat(out.getMessage().getHeader(OpaConstants.BATCH_DECISION)).isNull();
}
+
+ @Test
+ void distinguishesDecisionsFailuresAndMissingResults() throws Exception {
+ Map<String, OPAResult> results = new LinkedHashMap<>();
+ results.put("0", new OPAResult(Boolean.TRUE));
+ results.put("1", new OPAResult(Boolean.FALSE));
+ results.put("2", failed(unreachable(PATH)));
+ results.put("3", failed(evaluationError(PATH)));
+ // Index 4 is absent; index 5 exists but the SDK returned no result
for it.
+ results.put("5", null);
+ stubBatch(results);
+
+ Exchange out = template.request("opa:" + PATH +
"?opaClient=#opaClient&batch=true&failOpen=true",
+ e -> e.getMessage().setBody(List.of("a", "b", "c", "d", "e",
"f")));
+
+ assertThat(out.getException()).isNull();
+ assertThat(out.getMessage().getHeader(OpaConstants.BATCH_DECISION,
List.class))
+ .containsExactly(true, false, true, false, false, false);
+ List<OpaBatchOutcome> outcomes = outcomes(out);
+ assertThat(outcomes).extracting(OpaBatchOutcome::isAllowed)
+ .containsExactly(true, false, true, false, false, false);
+ assertThat(outcomes).extracting(OpaBatchOutcome::getDecision)
+ .containsExactly(true, false, null, null, null, null);
+
assertThat(outcomes).extracting(OpaBatchOutcome::getFailureCategory).containsExactly(
+ OpaBatchOutcome.FailureCategory.NONE,
OpaBatchOutcome.FailureCategory.NONE,
+ OpaBatchOutcome.FailureCategory.UNAVAILABLE,
OpaBatchOutcome.FailureCategory.EVALUATION_ERROR,
+ OpaBatchOutcome.FailureCategory.MISSING_RESULT,
OpaBatchOutcome.FailureCategory.MISSING_RESULT);
+ assertThat(outcomes).extracting(OpaBatchOutcome::isFailedOpen)
+ .containsExactly(false, false, true, false, false, false);
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_FAILED_OPEN)).isEqualTo(true);
+ assertThatThrownBy(() ->
outcomes.add(outcomes.get(0))).isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ @Test
+ void identifiesAnInterruptedElementWithoutFailingOpen() throws Exception {
+ Map<String, OPAResult> results = new LinkedHashMap<>();
+ results.put("0", failed(interrupted(PATH)));
+ stubBatch(results);
+ try {
+ Exchange out = template.request("opa:" + PATH +
"?opaClient=#opaClient&batch=true&failOpen=true",
+ e -> e.getMessage().setBody(List.of("a")));
+
+ assertThat(Thread.currentThread().isInterrupted()).isTrue();
+ assertThat(out.getException()).isNull();
+
assertThat(outcomes(out)).extracting(OpaBatchOutcome::getFailureCategory)
+
.containsExactly(OpaBatchOutcome.FailureCategory.INTERRUPTED);
+
assertThat(outcomes(out)).extracting(OpaBatchOutcome::isAllowed).containsExactly(false);
+
assertThat(outcomes(out)).extracting(OpaBatchOutcome::isFailedOpen).containsExactly(false);
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_FAILED_OPEN)).isNull();
+ } finally {
+ Thread.interrupted();
+ }
+ }
+
+ @Test
+ void clearsOldDiagnosticsBeforeAWholeBatchFailure() throws Exception {
+ when(client.evaluateBatch(eq(PATH),
anyMap())).thenThrow(unreachable(PATH));
+
+ Exchange out = template.request("opa:" + PATH +
"?opaClient=#opaClient&batch=true", e -> {
+ e.getMessage().setBody(List.of("a"));
+ e.getMessage().setHeader(OpaConstants.BATCH_OUTCOMES, List.of("old
outcome"));
+ e.getMessage().setHeader(OpaConstants.DECISION_FAILED_OPEN, true);
+ });
+
+
assertThat(out.getException()).isInstanceOf(OpaPolicyEvaluationException.class);
+ assertThat(outcomes(out)).isNull();
+
assertThat(out.getMessage().getHeader(OpaConstants.BATCH_DECISION)).isNull();
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_FAILED_OPEN)).isNull();
+ }
+
+ @Test
+ void clearsBatchDiagnosticsBeforeASubsequentSingleDecision() throws
Exception {
+ stubBatch(Map.of("0", failed(unreachable(PATH))));
+ when(client.evaluate(eq(PATH), anyMap(),
eq(Object.class))).thenReturn(Boolean.TRUE);
+ Exchange out = template.request("opa:" + PATH +
"?opaClient=#opaClient&batch=true&failOpen=true",
+ e -> e.getMessage().setBody(List.of("a")));
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_FAILED_OPEN)).isEqualTo(true);
+
+ template.send("opa:" + PATH + "?opaClient=#opaClient", out);
+
+ assertThat(out.getException()).isNull();
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true);
+ assertThat(outcomes(out)).isNull();
+
assertThat(out.getMessage().getHeader(OpaConstants.BATCH_DECISION)).isNull();
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_FAILED_OPEN)).isNull();
+ }
+
}
diff --git
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaBatchOutcomeTest.java
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaBatchOutcomeTest.java
new file mode 100644
index 000000000000..f581327b9713
--- /dev/null
+++
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaBatchOutcomeTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OpaBatchOutcomeTest {
+
+ @Test
+ void describesTheSanitizedDecisionAndFailureState() {
+ assertThat(OpaBatchOutcome.decision(false).toString())
+ .isEqualTo("OpaBatchOutcome[allowed=false, decision=false,
failureCategory=NONE, failedOpen=false]");
+
assertThat(OpaBatchOutcome.failure(OpaBatchOutcome.FailureCategory.UNAVAILABLE,
true).toString())
+ .isEqualTo("OpaBatchOutcome[allowed=true, decision=null,
failureCategory=UNAVAILABLE, failedOpen=true]");
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void preservesTheBatchHeaderListThroughJavaSerialization() throws
Exception {
+ List<OpaBatchOutcome> header = List.copyOf(List.of(
+ OpaBatchOutcome.decision(true),
+ OpaBatchOutcome.decision(false),
+
OpaBatchOutcome.failure(OpaBatchOutcome.FailureCategory.UNAVAILABLE, true),
+
OpaBatchOutcome.failure(OpaBatchOutcome.FailureCategory.EVALUATION_ERROR,
false)));
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (ObjectOutputStream output = new ObjectOutputStream(bytes)) {
+ output.writeObject(header);
+ }
+
+ List<OpaBatchOutcome> restored;
+ try (ObjectInputStream input = new ObjectInputStream(new
ByteArrayInputStream(bytes.toByteArray()))) {
+ restored = (List<OpaBatchOutcome>) input.readObject();
+ }
+
+
assertThat(restored).extracting(OpaBatchOutcome::isAllowed).containsExactly(true,
false, true, false);
+
assertThat(restored).extracting(OpaBatchOutcome::getDecision).containsExactly(true,
false, null, null);
+
assertThat(restored).extracting(OpaBatchOutcome::getFailureCategory).containsExactly(
+ OpaBatchOutcome.FailureCategory.NONE,
OpaBatchOutcome.FailureCategory.NONE,
+ OpaBatchOutcome.FailureCategory.UNAVAILABLE,
OpaBatchOutcome.FailureCategory.EVALUATION_ERROR);
+
assertThat(restored).extracting(OpaBatchOutcome::isFailedOpen).containsExactly(false,
false, true, false);
+ }
+}
diff --git
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaInputDocumentTest.java
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaInputDocumentTest.java
index fa06c74a1321..1046286c56b1 100644
---
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaInputDocumentTest.java
+++
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaInputDocumentTest.java
@@ -83,11 +83,12 @@ class OpaInputDocumentTest extends CamelTestSupport {
e.getMessage().setHeader(OpaConstants.DECISION_ALLOW, true);
e.getMessage().setHeader(OpaConstants.DECISION, Map.of("allow",
true));
e.getMessage().setHeader(OpaConstants.POLICY_PATH,
"authz/some-other-policy");
+ e.getMessage().setHeader(OpaConstants.BATCH_OUTCOMES, "claimed
outcome");
});
assertThat(headersOf(input)).containsEntry("user", "alice");
assertThat(headersOf(input)).doesNotContainKeys(
- OpaConstants.DECISION_ALLOW, OpaConstants.DECISION,
OpaConstants.POLICY_PATH);
+ OpaConstants.DECISION_ALLOW, OpaConstants.DECISION,
OpaConstants.POLICY_PATH, OpaConstants.BATCH_OUTCOMES);
}
@Test
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 91fe68dc137b..01eea679d7f5 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -13,6 +13,13 @@ See the xref:camel-upgrade-recipes-tool.adoc[documentation]
page for details.
== Upgrading Camel 4.23 to 4.24
+=== camel-opa - batch fail-open marker
+
+Batch evaluation now sets `CamelOpaDecisionFailedOpen=true` when any element
proceeds because its decision point
+was unavailable and `failOpen=true`. Previously this marker was set only for
single-element evaluation. Routes
+that inspect the marker after a batch should account for it applying to any
element; the per-element outcome
+list identifies the affected elements. The existing `CamelOpaBatchDecision`
Boolean list is unchanged.
+
=== camel-observability-services - promoted to Stable
The `camel-observability-services` component has been promoted from _Preview_
to _Stable_ support level.
diff --git
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpaEndpointBuilderFactory.java
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpaEndpointBuilderFactory.java
index 55eaf1dd52b1..0f2697bd44aa 100644
---
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpaEndpointBuilderFactory.java
+++
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpaEndpointBuilderFactory.java
@@ -736,7 +736,9 @@ public interface OpaEndpointBuilderFactory {
* enabled and the policy could not be evaluated - nothing authorized
* it. Absent on every decision an actual policy made, so a route or an
* audit trail can tell the two apart rather than seeing the same
- * CamelOpaDecisionAllow=true for both.
+ * CamelOpaDecisionAllow=true for both. For a batch, set when any
+ * element proceeded because failOpen was enabled;
CamelOpaBatchOutcomes
+ * identifies the elements.
*
* The option is a: {@code Boolean} type.
*
@@ -762,6 +764,22 @@ public interface OpaEndpointBuilderFactory {
public String opaBatchDecision() {
return "CamelOpaBatchDecision";
}
+ /**
+ * Immutable per-element outcomes of a batch evaluation, parallel to
the
+ * List body. Each outcome exposes the effective allowed verdict,
+ * nullable Boolean policy decision, failure category and failedOpen
+ * flag. Contains no exception or failure message.
+ *
+ * The option is a: {@code
+ * java.util.List<org.apache.camel.component.opa.OpaBatchOutcome>}
type.
+ *
+ * Group: producer
+ *
+ * @return the name of the header {@code OpaBatchOutcomes}.
+ */
+ public String opaBatchOutcomes() {
+ return "CamelOpaBatchOutcomes";
+ }
}
static OpaEndpointBuilder endpointBuilder(String componentName, String
path) {
class OpaEndpointBuilderImpl extends AbstractEndpointBuilder
implements OpaEndpointBuilder, AdvancedOpaEndpointBuilder {