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 d81c8c493ab2 CAMEL-25404: camel-ai-tool - make an authorizationPolicy
denial observable via a CamelEvent (#27480)
d81c8c493ab2 is described below
commit d81c8c493ab2d452a1a00834f40f350f8e7b470b
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Oct 7 13:56:17 2026 +0200
CAMEL-25404: camel-ai-tool - make an authorizationPolicy denial observable
via a CamelEvent (#27480)
An authorizationPolicy denial in AiToolExecutor now fires an
AiToolAuthorizationDeniedEvent (a CamelEvent) so route operators can observe
refused tool calls through the regular EventNotifier mechanism. Notifier
failures can never affect the refusal: the notification is guarded with
catch (Throwable), consistent with EventHelper.doNotifyEvent(), so a
notifier
throwing an Error still leaves the caller with AuthorizationDenied.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
---
.../camel/catalog/docs/ai-tool-component.adoc | 31 ++-
.../src/main/docs/ai-tool-component.adoc | 31 ++-
.../ai/tool/AiToolAuthorizationDeniedEvent.java | 97 ++++++++++
.../camel/component/ai/tool/AiToolExecutor.java | 34 +++-
.../tool/AiToolAuthorizationDeniedEventTest.java | 210 +++++++++++++++++++++
5 files changed, 398 insertions(+), 5 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
index 9912e90bdf31..b5b934eb1a23 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc
@@ -426,7 +426,8 @@ YAML::
The guard runs in front of the route: it wraps the route's outer processor, so
it executes before the route's unit
of work, tracing and error handling. A denied call
(`CamelAuthorizationException`) is returned to the model as a
short refusal it can relay — not as a tool result and not as a stack trace —
regardless of the tool-execution error
-strategy; the denial is logged at `WARN`, but it does not produce a route span
or metric.
+strategy. Because the guard runs in front of the route, a denial never enters
the route's unit of work, so it does
+not produce a route span. It is instead observable as described in <<Observing
denials>> below.
The policy authorizes on *trustworthy* input only:
@@ -448,6 +449,34 @@ Which runtimes carry the caller identity:
that inspects `CamelMcpSecurityPrincipal`. Exposing a runtime-neutral
principal name and roles for the shipped
policies is tracked as a follow-up.
+=== Observing denials
+
+Because the guard runs in front of the route, a denied call is caught before
the route's unit of work and so fires no
+exchange-lifecycle event or route span. Every denial is instead:
+
+* logged at `WARN`, and
+* published as an
`org.apache.camel.component.ai.tool.AiToolAuthorizationDeniedEvent` — a
`CamelEvent` of type
+ `Custom` carrying the denied tool name, the tool exchange (for correlation),
and the `CamelAuthorizationException`
+ the policy raised.
+
+Register an `EventNotifier` to count, log or alert on denials — for example to
drive a micrometer counter — without
+changing what the model sees (still the short refusal):
+
+[source,java]
+----
+context.getManagementStrategy().addEventNotifier(new EventNotifierSupport() {
+ @Override
+ public void notify(CamelEvent event) {
+ if (event instanceof AiToolAuthorizationDeniedEvent denied) {
+ meterRegistry.counter("ai.tool.authorization.denied", "tool",
denied.getToolName()).increment();
+ }
+ }
+});
+----
+
+The event is emitted best-effort: when no `EventNotifier` is registered
nothing is published, and a failure to notify
+never affects the refusal returned to the model.
+
== See Also
* xref:langchain4j-agent-component.adoc[LangChain4j Agent Component] —
discovers ai-tool tools via the `tags` option
diff --git
a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
index 9912e90bdf31..b5b934eb1a23 100644
--- a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
+++ b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc
@@ -426,7 +426,8 @@ YAML::
The guard runs in front of the route: it wraps the route's outer processor, so
it executes before the route's unit
of work, tracing and error handling. A denied call
(`CamelAuthorizationException`) is returned to the model as a
short refusal it can relay — not as a tool result and not as a stack trace —
regardless of the tool-execution error
-strategy; the denial is logged at `WARN`, but it does not produce a route span
or metric.
+strategy. Because the guard runs in front of the route, a denial never enters
the route's unit of work, so it does
+not produce a route span. It is instead observable as described in <<Observing
denials>> below.
The policy authorizes on *trustworthy* input only:
@@ -448,6 +449,34 @@ Which runtimes carry the caller identity:
that inspects `CamelMcpSecurityPrincipal`. Exposing a runtime-neutral
principal name and roles for the shipped
policies is tracked as a follow-up.
+=== Observing denials
+
+Because the guard runs in front of the route, a denied call is caught before
the route's unit of work and so fires no
+exchange-lifecycle event or route span. Every denial is instead:
+
+* logged at `WARN`, and
+* published as an
`org.apache.camel.component.ai.tool.AiToolAuthorizationDeniedEvent` — a
`CamelEvent` of type
+ `Custom` carrying the denied tool name, the tool exchange (for correlation),
and the `CamelAuthorizationException`
+ the policy raised.
+
+Register an `EventNotifier` to count, log or alert on denials — for example to
drive a micrometer counter — without
+changing what the model sees (still the short refusal):
+
+[source,java]
+----
+context.getManagementStrategy().addEventNotifier(new EventNotifierSupport() {
+ @Override
+ public void notify(CamelEvent event) {
+ if (event instanceof AiToolAuthorizationDeniedEvent denied) {
+ meterRegistry.counter("ai.tool.authorization.denied", "tool",
denied.getToolName()).increment();
+ }
+ }
+});
+----
+
+The event is emitted best-effort: when no `EventNotifier` is registered
nothing is published, and a failure to notify
+never affects the refusal returned to the model.
+
== See Also
* xref:langchain4j-agent-component.adoc[LangChain4j Agent Component] —
discovers ai-tool tools via the `tags` option
diff --git
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEvent.java
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEvent.java
new file mode 100644
index 000000000000..09e5e4bfe1a7
--- /dev/null
+++
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEvent.java
@@ -0,0 +1,97 @@
+/*
+ * 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.ai.tool;
+
+import java.io.Serial;
+import java.util.EventObject;
+
+import org.apache.camel.CamelAuthorizationException;
+import org.apache.camel.Exchange;
+import org.apache.camel.spi.CamelEvent;
+
+/**
+ * Fired when an {@code ai-tool} route's {@code authorizationPolicy} denies a
tool call.
+ * <p>
+ * The policy guards the route's <em>outer</em> processor, so a denial is
caught in front of the route: it never enters
+ * the route's unit of work and therefore produces no exchange-lifecycle event
or route span. This event is the
+ * observable signal operators can subscribe to (with an {@code
EventNotifier}, JMX, or a micrometer counter) to see and
+ * alert on authorization denials, without changing what the model sees — the
call is still relayed back to the model as
+ * a short refusal.
+ * <p>
+ * It is a {@link CamelEvent.Type#Custom} event and deliberately not a {@link
CamelEvent.ExchangeEvent}, so it does not
+ * pollute generic exchange lifecycle metrics; {@link #getExchange()} is
public so listeners can still correlate it with
+ * the tool exchange (route id, exchange id). It is a {@link
CamelEvent.FailureEvent}, so {@link #getCause()} returns
+ * the {@link CamelAuthorizationException} the policy raised.
+ *
+ * @since 4.23
+ */
+public final class AiToolAuthorizationDeniedEvent extends EventObject
implements CamelEvent.FailureEvent {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final transient Exchange exchange;
+ private final String toolName;
+ private final transient CamelAuthorizationException cause;
+ private long timestamp;
+
+ public AiToolAuthorizationDeniedEvent(Exchange exchange, String toolName,
CamelAuthorizationException cause) {
+ super(exchange);
+ this.exchange = exchange;
+ this.toolName = toolName;
+ this.cause = cause;
+ }
+
+ /**
+ * The tool exchange whose call was denied (for correlation, e.g. route id
or exchange id).
+ */
+ public Exchange getExchange() {
+ return exchange;
+ }
+
+ /**
+ * The name (route id) of the tool whose call was denied.
+ */
+ public String getToolName() {
+ return toolName;
+ }
+
+ @Override
+ public CamelAuthorizationException getCause() {
+ return cause;
+ }
+
+ @Override
+ public Type getType() {
+ return Type.Custom;
+ }
+
+ @Override
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public void setTimestamp(long timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public String toString() {
+ return "AiToolAuthorizationDeniedEvent{toolName='" + toolName + "'}";
+ }
+}
diff --git
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
index 4e5a2f405d19..028d667f0a2b 100644
---
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
+++
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java
@@ -24,6 +24,7 @@ import java.util.Set;
import org.apache.camel.CamelAuthorizationException;
import org.apache.camel.Exchange;
import org.apache.camel.Processor;
+import org.apache.camel.spi.ManagementStrategy;
import org.apache.camel.support.DefaultConsumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -148,7 +149,7 @@ public final class AiToolExecutor {
if (exchange.getException() != null) {
Exception routeError = exchange.getException();
- AiToolResult denied = authorizationDenied(toolName,
routeError);
+ AiToolResult denied = authorizationDenied(toolName,
routeError, exchange);
if (denied != null) {
return denied;
}
@@ -161,7 +162,7 @@ public final class AiToolExecutor {
LOG.debug("Tool '{}' execution completed successfully", toolName);
return buildSuccessResult(spec, exchange, result);
} catch (Exception e) {
- AiToolResult denied = authorizationDenied(toolName, e);
+ AiToolResult denied = authorizationDenied(toolName, e, exchange);
if (denied != null) {
return denied;
}
@@ -176,17 +177,44 @@ public final class AiToolExecutor {
* present in its cause chain (the route's {@link
org.apache.camel.spi.AuthorizationPolicy} rejected the call).
* Returns a caller-safe {@link AiToolResult.AuthorizationDenied} refusal
that does not leak the policy's internal
* message, or {@code null} when the error is not an authorization denial.
+ * <p>
+ * On a denial it also logs at {@code WARN} and emits an {@link
AiToolAuthorizationDeniedEvent} so operators can
+ * observe and alert on denials; neither affects the refusal returned to
the model.
*/
- private static AiToolResult authorizationDenied(String toolName, Throwable
error) {
+ private static AiToolResult authorizationDenied(String toolName, Throwable
error, Exchange exchange) {
CamelAuthorizationException denial = findAuthorizationException(error);
if (denial == null) {
return null;
}
LOG.warn("Tool '{}' call denied by authorization policy: {}",
toolName, denial.getMessage());
+ fireAuthorizationDeniedEvent(exchange, toolName, denial);
return new AiToolResult.AuthorizationDenied(
String.format("Access denied: not authorized to call tool
'%s'", toolName), denial);
}
+ /**
+ * Emits an {@link AiToolAuthorizationDeniedEvent} for the denied tool
call. The policy guards the route's outer
+ * processor, so a denial never runs the route's unit of work and fires no
exchange-lifecycle event or route span;
+ * this event is the observable signal. Best-effort: when no {@code
EventNotifier} is registered nothing is emitted,
+ * and a failure to notify is swallowed so it never affects the refusal
returned to the model.
+ */
+ private static void fireAuthorizationDeniedEvent(Exchange exchange, String
toolName, CamelAuthorizationException denial) {
+ ManagementStrategy management =
exchange.getContext().getManagementStrategy();
+ if (management.getEventNotifiers().isEmpty()) {
+ return;
+ }
+ try {
+ management.notify(new AiToolAuthorizationDeniedEvent(exchange,
toolName, denial));
+ } catch (Throwable e) {
+ // ManagementStrategy.notify() does not isolate a failing notifier
(unlike EventHelper.doNotifyEvent), so a
+ // notifier throwing anything - including an Error such as
AssertionError - would otherwise escape and turn
+ // the denial into a propagating failure. Swallow it here (as
EventHelper does) so a broken notifier never
+ // changes the AuthorizationDenied refusal returned to the model.
+ LOG.warn("Notifying {} for tool '{}' failed and was ignored; the
authorization denial is unaffected",
+ AiToolAuthorizationDeniedEvent.class.getSimpleName(),
toolName, e);
+ }
+ }
+
private static CamelAuthorizationException
findAuthorizationException(Throwable error) {
for (Throwable t = error; t != null; t = t.getCause()) {
if (t instanceof CamelAuthorizationException cae) {
diff --git
a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEventTest.java
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEventTest.java
new file mode 100644
index 000000000000..888555f93849
--- /dev/null
+++
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolAuthorizationDeniedEventTest.java
@@ -0,0 +1,210 @@
+/*
+ * 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.ai.tool;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.camel.CamelAuthorizationException;
+import org.apache.camel.Exchange;
+import org.apache.camel.NamedNode;
+import org.apache.camel.Processor;
+import org.apache.camel.Route;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.AuthorizationPolicy;
+import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.EventNotifierSupport;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25404: an {@code authorizationPolicy} denial on an {@code ai-tool}
route is caught in front of the route, so it
+ * fires no exchange-lifecycle event or route span. This verifies the
component instead emits an
+ * {@link AiToolAuthorizationDeniedEvent} on every denial (so operators can
observe and alert), while the model-facing
+ * behaviour is unchanged (still an {@link AiToolResult.AuthorizationDenied}
refusal), and that the event is
+ * <em>not</em> emitted for a normal route error or a successful call.
+ */
+class AiToolAuthorizationDeniedEventTest extends CamelTestSupport {
+
+ private final List<AiToolAuthorizationDeniedEvent> events = new
CopyOnWriteArrayList<>();
+
+ /** Allows the call only when {@code subject == alice}; otherwise throws,
as a real policy does. */
+ static final class SubjectPolicy implements AuthorizationPolicy {
+ @Override
+ public void beforeWrap(Route route, NamedNode definition) {
+ }
+
+ @Override
+ public Processor wrap(Route route, Processor processor) {
+ return exchange -> {
+ if (!"alice".equals(exchange.getProperty("subject",
String.class))) {
+ throw new CamelAuthorizationException("caller is not
authorized", exchange);
+ }
+ processor.process(exchange);
+ };
+ }
+ }
+
+ /** Denies by setting the exception on the exchange rather than throwing
(as Spring Security's policy does). */
+ static final class ExceptionDeny implements AuthorizationPolicy {
+ @Override
+ public void beforeWrap(Route route, NamedNode definition) {
+ }
+
+ @Override
+ public Processor wrap(Route route, Processor processor) {
+ return exchange -> exchange.setException(new
CamelAuthorizationException("denied via exchange", exchange));
+ }
+ }
+
+ /** Allows every call (delegates straight to the route). */
+ static final class AllowAll implements AuthorizationPolicy {
+ @Override
+ public void beforeWrap(Route route, NamedNode definition) {
+ }
+
+ @Override
+ public Processor wrap(Route route, Processor processor) {
+ return processor::process;
+ }
+ }
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ registry.bind("subjectPolicy", new SubjectPolicy());
+ registry.bind("exceptionDeny", new ExceptionDeny());
+ registry.bind("allowAll", new AllowAll());
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ public void configure() {
+ from("ai-tool:guarded?tags=test&description=A guarded
tool&authorizationPolicy=#subjectPolicy")
+ .setBody(constant("ok"));
+
+ from("ai-tool:exceptionGuarded?tags=test&description=A tool
whose policy sets the exception"
+ + "&authorizationPolicy=#exceptionDeny")
+ .setBody(constant("ok"));
+
+ from("ai-tool:allowedButFails?tags=test&description=A tool
allowed but whose route fails"
+ + "&authorizationPolicy=#allowAll")
+ .throwException(new RuntimeException("route boom"));
+ }
+ };
+ }
+
+ @BeforeEach
+ void registerEventNotifier() {
+ events.clear();
+ context.getManagementStrategy().addEventNotifier(new
EventNotifierSupport() {
+ @Override
+ public void notify(CamelEvent event) {
+ if (event instanceof AiToolAuthorizationDeniedEvent denied) {
+ events.add(denied);
+ }
+ }
+ });
+ }
+
+ @Test
+ void firesAnEventWhenThePolicyThrows() {
+ AiToolSpec spec = findSpec("guarded");
+ Exchange exchange = new DefaultExchange(context);
+ exchange.setProperty("subject", "mallory");
+
+ AiToolResult result = AiToolExecutor.execute(spec, Map.of(), exchange);
+
+ // model-facing behaviour is unchanged: still a refusal
+
assertThat(result).isInstanceOf(AiToolResult.AuthorizationDenied.class);
+ // ...and the denial is now observable as a single event
+ assertThat(events).hasSize(1);
+ AiToolAuthorizationDeniedEvent event = events.get(0);
+ assertThat(event.getType()).isEqualTo(CamelEvent.Type.Custom);
+ assertThat(event.getToolName()).isEqualTo("guarded");
+ assertThat(event.getExchange()).isSameAs(exchange);
+
assertThat(event.getCause()).isInstanceOf(CamelAuthorizationException.class);
+ }
+
+ @Test
+ void firesAnEventWhenThePolicySetsTheExceptionInsteadOfThrowing() {
+ AiToolSpec spec = findSpec("exceptionGuarded");
+
+ AiToolResult result = AiToolExecutor.execute(spec, Map.of(), new
DefaultExchange(context));
+
+
assertThat(result).isInstanceOf(AiToolResult.AuthorizationDenied.class);
+ assertThat(events).hasSize(1);
+ assertThat(events.get(0).getToolName()).isEqualTo("exceptionGuarded");
+ }
+
+ @Test
+ void doesNotFireForANonAuthorizationRouteError() {
+ AiToolSpec spec = findSpec("allowedButFails");
+
+ AiToolResult result = AiToolExecutor.execute(spec, Map.of(), new
DefaultExchange(context));
+
+ assertThat(result).isInstanceOf(AiToolResult.ExecutionError.class);
+ assertThat(events).isEmpty();
+ }
+
+ @Test
+ void doesNotFireWhenThePolicyAllows() {
+ AiToolSpec spec = findSpec("guarded");
+ Exchange exchange = new DefaultExchange(context);
+ exchange.setProperty("subject", "alice");
+
+ AiToolResult result = AiToolExecutor.execute(spec, Map.of(), exchange);
+
+ assertThat(result).isInstanceOf(AiToolResult.Success.class);
+ assertThat(events).isEmpty();
+ }
+
+ @Test
+ void aNotifierThrowingAnErrorDoesNotBreakTheRefusal() {
+ // ManagementStrategy.notify does not isolate a failing notifier, so
without a Throwable guard a notifier that
+ // throws an Error would escape and turn the denial into a propagating
failure. The refusal must survive.
+ context.getManagementStrategy().addEventNotifier(new
EventNotifierSupport() {
+ @Override
+ public void notify(CamelEvent event) {
+ if (event instanceof AiToolAuthorizationDeniedEvent) {
+ throw new AssertionError("broken notifier");
+ }
+ }
+ });
+
+ AiToolSpec spec = findSpec("guarded");
+ Exchange exchange = new DefaultExchange(context);
+ exchange.setProperty("subject", "mallory");
+
+ AiToolResult result = AiToolExecutor.execute(spec, Map.of(), exchange);
+
+
assertThat(result).isInstanceOf(AiToolResult.AuthorizationDenied.class);
+ }
+
+ private AiToolSpec findSpec(String toolName) {
+ return
AiToolRegistry.getOrCreate(context).getToolsByTag("test").stream()
+ .filter(s -> toolName.equals(s.getName()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Tool not found: " +
toolName));
+ }
+}