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 63a38b21d741 CAMEL-24735: camel-spiffe - add a SpiffeSecurityPolicy to 
authorize on the peer SPIFFE ID (#27385)
63a38b21d741 is described below

commit 63a38b21d74127f976612adaf4b8f71389e55ad4
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Oct 5 20:35:32 2026 +0200

    CAMEL-24735: camel-spiffe - add a SpiffeSecurityPolicy to authorize on the 
peer SPIFFE ID (#27385)
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
 .../camel/catalog/docs/spiffe-component.adoc       |  52 ++++++
 .../src/main/docs/spiffe-component.adoc            |  52 ++++++
 .../camel/component/spiffe/SpiffeConstants.java    |   7 +
 .../camel/component/spiffe/SpiffePeerIdentity.java | 123 ++++++++++++++
 .../component/spiffe/SpiffeSecurityPolicy.java     | 160 +++++++++++++++++++
 .../component/spiffe/SpiffeSecurityProcessor.java  |  74 +++++++++
 .../spiffe/SpiffeSecurityPolicyAcceptAnyTest.java  |  88 +++++++++++
 .../spiffe/SpiffeSecurityPolicyConfigTest.java     |  78 +++++++++
 .../component/spiffe/SpiffeSecurityPolicyTest.java | 176 +++++++++++++++++++++
 9 files changed, 810 insertions(+)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spiffe-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spiffe-component.adoc
index ae86c550660e..9fdd30362d45 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spiffe-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spiffe-component.adoc
@@ -146,6 +146,53 @@ from("direct:start")
     .to("https://backend.example.org/api?sslContextParameters=#spiffeSsl";);
 ------------------------------------------------------------
 
+== Authorizing a route on the peer SPIFFE ID (SpiffeSecurityPolicy)
+
+`SpiffeSSLContextParameters` decides *which peers may connect*; 
`org.apache.camel.component.spiffe.SpiffeSecurityPolicy`
+decides *what a given peer may reach*. It is an `AuthorizationPolicy` - the 
SPIFFE sibling of `ShiroSecurityPolicy`,
+`KeycloakSecurityPolicy` and `OpaSecurityPolicy` - that wraps a route segment 
and authorizes it on the caller's
+verified SPIFFE ID:
+
+[source,java]
+------------------------------------------------------------
+SpiffeSecurityPolicy spiffePolicy = new 
SpiffeSecurityPolicy("spiffe://example.org/frontend");
+getCamelContext().getRegistry().bind("spiffePolicy", spiffePolicy);
+
+from("netty-http:https://0.0.0.0:8443?sslContextParameters=#spiffeSsl";)
+    .policy(spiffePolicy)
+    .to("direct:handleOrder");
+------------------------------------------------------------
+
+The peer SPIFFE ID is taken from the *verified* TLS peer certificate the 
consumer put on the exchange - the
+`CamelNettySSLSession` header for camel-netty-http - and never from a message 
header a sender could set (see the
+Security notes below). A peer that is not on the `acceptedSpiffeIds` 
allow-list, or that presents no verified SVID at
+all, is denied with a `CamelAuthorizationException`, so the route stops and 
the regular `onException` machinery applies.
+
+Set exactly one of:
+
+* `acceptedSpiffeIds` - a comma-separated allow-list of the peer SPIFFE IDs 
this segment authorizes;
+* `acceptAnySpiffeId=true` - authorize any peer that presents a verified SVID, 
leaving the which-peer decision to a
+  downstream policy. It still fails closed when no verified peer certificate 
is present.
+
+Setting both is rejected and setting neither fails closed, mirroring 
`SpiffeSSLContextParameters`.
+
+The policy answers "is the peer who they claim to be"; it is designed to 
*compose* with `camel-opa` ("may they do
+this") rather than duplicate it. On a successful authorization the verified 
SPIFFE ID is stored as the
+`CamelSpiffePeerId` exchange property, so an OPA policy downstream can forward 
it to a Rego rule through its
+`includeProperties` option:
+
+[source,java]
+------------------------------------------------------------
+from("netty-http:https://0.0.0.0:8443?sslContextParameters=#spiffeSsl";)
+    .policy(spiffePolicy)   // authenticates the peer, sets CamelSpiffePeerId
+    .policy(opaPolicy)      // 
opaPolicy.setIncludeProperties("CamelSpiffePeerId")
+    .to("direct:handleOrder");
+------------------------------------------------------------
+
+For a TLS consumer that exposes the `SSLSession` under a different header 
name, set `sslSessionHeader` accordingly. The
+identity is always read from the verified `SSLSession`, never from a 
certificate object placed on a message header -
+nothing would prove the TLS layer had verified such a certificate.
+
 include::spring-boot:partial$starter.adoc[]
 
 == Security notes
@@ -166,3 +213,8 @@ include::spring-boot:partial$starter.adoc[]
   `acceptAnySpiffeId=true`; it fails closed when neither is given. Prefer the 
allow-list -
   `acceptAnySpiffeId` accepts any SVID that chains to the trust bundle, which 
authenticates the trust domain but not
   the peer.
+* *The authorization decision is made on the certificate, not a header.* 
`SpiffeSecurityPolicy` reads the peer SPIFFE
+  ID from the verified TLS peer certificate (the `CamelNettySSLSession` header 
for camel-netty-http), so a sender
+  cannot assert an identity by setting a header - and it requires mutual TLS, 
failing closed when the peer presented
+  no verified certificate. The verified id it publishes is the 
`CamelSpiffePeerId` exchange *property*, never a header,
+  so it likewise cannot be injected by an inbound message.
diff --git a/components/camel-spiffe/src/main/docs/spiffe-component.adoc 
b/components/camel-spiffe/src/main/docs/spiffe-component.adoc
index ae86c550660e..9fdd30362d45 100644
--- a/components/camel-spiffe/src/main/docs/spiffe-component.adoc
+++ b/components/camel-spiffe/src/main/docs/spiffe-component.adoc
@@ -146,6 +146,53 @@ from("direct:start")
     .to("https://backend.example.org/api?sslContextParameters=#spiffeSsl";);
 ------------------------------------------------------------
 
+== Authorizing a route on the peer SPIFFE ID (SpiffeSecurityPolicy)
+
+`SpiffeSSLContextParameters` decides *which peers may connect*; 
`org.apache.camel.component.spiffe.SpiffeSecurityPolicy`
+decides *what a given peer may reach*. It is an `AuthorizationPolicy` - the 
SPIFFE sibling of `ShiroSecurityPolicy`,
+`KeycloakSecurityPolicy` and `OpaSecurityPolicy` - that wraps a route segment 
and authorizes it on the caller's
+verified SPIFFE ID:
+
+[source,java]
+------------------------------------------------------------
+SpiffeSecurityPolicy spiffePolicy = new 
SpiffeSecurityPolicy("spiffe://example.org/frontend");
+getCamelContext().getRegistry().bind("spiffePolicy", spiffePolicy);
+
+from("netty-http:https://0.0.0.0:8443?sslContextParameters=#spiffeSsl";)
+    .policy(spiffePolicy)
+    .to("direct:handleOrder");
+------------------------------------------------------------
+
+The peer SPIFFE ID is taken from the *verified* TLS peer certificate the 
consumer put on the exchange - the
+`CamelNettySSLSession` header for camel-netty-http - and never from a message 
header a sender could set (see the
+Security notes below). A peer that is not on the `acceptedSpiffeIds` 
allow-list, or that presents no verified SVID at
+all, is denied with a `CamelAuthorizationException`, so the route stops and 
the regular `onException` machinery applies.
+
+Set exactly one of:
+
+* `acceptedSpiffeIds` - a comma-separated allow-list of the peer SPIFFE IDs 
this segment authorizes;
+* `acceptAnySpiffeId=true` - authorize any peer that presents a verified SVID, 
leaving the which-peer decision to a
+  downstream policy. It still fails closed when no verified peer certificate 
is present.
+
+Setting both is rejected and setting neither fails closed, mirroring 
`SpiffeSSLContextParameters`.
+
+The policy answers "is the peer who they claim to be"; it is designed to 
*compose* with `camel-opa` ("may they do
+this") rather than duplicate it. On a successful authorization the verified 
SPIFFE ID is stored as the
+`CamelSpiffePeerId` exchange property, so an OPA policy downstream can forward 
it to a Rego rule through its
+`includeProperties` option:
+
+[source,java]
+------------------------------------------------------------
+from("netty-http:https://0.0.0.0:8443?sslContextParameters=#spiffeSsl";)
+    .policy(spiffePolicy)   // authenticates the peer, sets CamelSpiffePeerId
+    .policy(opaPolicy)      // 
opaPolicy.setIncludeProperties("CamelSpiffePeerId")
+    .to("direct:handleOrder");
+------------------------------------------------------------
+
+For a TLS consumer that exposes the `SSLSession` under a different header 
name, set `sslSessionHeader` accordingly. The
+identity is always read from the verified `SSLSession`, never from a 
certificate object placed on a message header -
+nothing would prove the TLS layer had verified such a certificate.
+
 include::spring-boot:partial$starter.adoc[]
 
 == Security notes
@@ -166,3 +213,8 @@ include::spring-boot:partial$starter.adoc[]
   `acceptAnySpiffeId=true`; it fails closed when neither is given. Prefer the 
allow-list -
   `acceptAnySpiffeId` accepts any SVID that chains to the trust bundle, which 
authenticates the trust domain but not
   the peer.
+* *The authorization decision is made on the certificate, not a header.* 
`SpiffeSecurityPolicy` reads the peer SPIFFE
+  ID from the verified TLS peer certificate (the `CamelNettySSLSession` header 
for camel-netty-http), so a sender
+  cannot assert an identity by setting a header - and it requires mutual TLS, 
failing closed when the peer presented
+  no verified certificate. The verified id it publishes is the 
`CamelSpiffePeerId` exchange *property*, never a header,
+  so it likewise cannot be injected by an inbound message.
diff --git 
a/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeConstants.java
 
b/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeConstants.java
index 996c472c36ef..48bc80e91412 100644
--- 
a/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeConstants.java
+++ 
b/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeConstants.java
@@ -48,6 +48,13 @@ public final class SpiffeConstants {
               javaType = "java.util.Date")
     public static final String EXPIRY = HEADER_PREFIX + "Expiry";
 
+    /**
+     * Exchange property carrying the verified peer SPIFFE ID, set by {@link 
SpiffeSecurityPolicy} once a caller has
+     * been authorized. It is an exchange property rather than a message 
header so it cannot be supplied by an inbound
+     * message, and so a downstream policy can forward it - for example the 
OPA policy's {@code includeProperties}.
+     */
+    public static final String PEER_SPIFFE_ID = HEADER_PREFIX + "PeerId";
+
     private SpiffeConstants() {
     }
 }
diff --git 
a/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffePeerIdentity.java
 
b/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffePeerIdentity.java
new file mode 100644
index 000000000000..6c21b949fdb7
--- /dev/null
+++ 
b/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffePeerIdentity.java
@@ -0,0 +1,123 @@
+/*
+ * 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.spiffe;
+
+import java.security.cert.Certificate;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.Collection;
+import java.util.List;
+
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+
+import io.spiffe.spiffeid.SpiffeId;
+import org.apache.camel.Exchange;
+import org.apache.camel.Message;
+
+/**
+ * Extracts the peer SPIFFE ID from the <em>verified</em> TLS peer certificate 
carried on an exchange.
+ * <p>
+ * The identity is taken from the certificate the TLS layer authenticated, 
never from a message header a sender could
+ * set (see CAMEL-24730 for why that distinction matters in this component). 
The source is the {@link SSLSession} a TLS
+ * consumer places on the message - camel-netty-http stores it under {@code 
CamelNettySSLSession}:
+ * {@link SSLSession#getPeerCertificates()} returns only the peer certificates 
the handshake verified, and throws when
+ * the peer presented none. A certificate object read from a message header is 
deliberately not trusted, because nothing
+ * proves the TLS layer verified it.
+ */
+final class SpiffePeerIdentity {
+
+    static final String SPIFFE_URI_SCHEME = "spiffe://";
+
+    // RFC 5280 GeneralName tag for uniformResourceIdentifier, as returned by 
X509Certificate#getSubjectAlternativeNames
+    private static final int SAN_TYPE_URI = 6;
+
+    private SpiffePeerIdentity() {
+    }
+
+    /**
+     * Returns the SPIFFE ID of the verified TLS peer, or {@code null} when 
the exchange carries no verified peer
+     * certificate or the certificate is not a valid SPIFFE X509-SVID.
+     */
+    static SpiffeId fromExchange(Exchange exchange, String sslSessionHeader) {
+        X509Certificate peer = peerCertificate(exchange.getMessage(), 
sslSessionHeader);
+        return peer != null ? fromCertificate(peer) : null;
+    }
+
+    private static X509Certificate peerCertificate(Message message, String 
sslSessionHeader) {
+        SSLSession session = message.getHeader(sslSessionHeader, 
SSLSession.class);
+        return session != null ? leafOf(peerCertificates(session)) : null;
+    }
+
+    private static Certificate[] peerCertificates(SSLSession session) {
+        try {
+            return session.getPeerCertificates();
+        } catch (SSLPeerUnverifiedException e) {
+            // the peer did not present a verified certificate - there is no 
identity to authorize on
+            return null;
+        }
+    }
+
+    private static X509Certificate leafOf(Certificate[] chain) {
+        if (chain == null || chain.length == 0) {
+            return null;
+        }
+        // the peer's own (leaf) certificate is first in the chain returned by 
the JSSE
+        return chain[0] instanceof X509Certificate x509 ? x509 : null;
+    }
+
+    /**
+     * Extracts the SPIFFE ID from a certificate's URI SAN. A SPIFFE X509-SVID 
carries exactly one URI SAN <em>in
+     * total</em>, and it is the SPIFFE ID. A certificate with no URI SAN, 
with more than one URI SAN (even if only one
+     * is a {@code spiffe://} URI), or whose single URI SAN is not a {@code 
spiffe://} URI, is not a valid SVID and
+     * yields {@code null} rather than an arbitrary choice of identity.
+     */
+    static SpiffeId fromCertificate(X509Certificate cert) {
+        Collection<List<?>> sans;
+        try {
+            sans = cert.getSubjectAlternativeNames();
+        } catch (CertificateParsingException e) {
+            return null;
+        }
+        if (sans == null) {
+            return null;
+        }
+        String uri = null;
+        for (List<?> san : sans) {
+            if (san == null || san.size() < 2) {
+                continue;
+            }
+            if (san.get(0) instanceof Integer type && type == SAN_TYPE_URI && 
san.get(1) instanceof String value) {
+                if (uri != null) {
+                    // a valid X509-SVID has exactly one URI SAN in total; 
more than one is not an SVID, so refuse to
+                    // single out the spiffe:// one and trust it
+                    return null;
+                }
+                uri = value;
+            }
+        }
+        if (uri == null || !uri.startsWith(SPIFFE_URI_SCHEME)) {
+            return null;
+        }
+        try {
+            return SpiffeId.parse(uri);
+        } catch (RuntimeException e) {
+            // malformed SPIFFE ID in the SAN
+            return null;
+        }
+    }
+}
diff --git 
a/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSecurityPolicy.java
 
b/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSecurityPolicy.java
new file mode 100644
index 000000000000..150ad4fe3449
--- /dev/null
+++ 
b/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSecurityPolicy.java
@@ -0,0 +1,160 @@
+/*
+ * 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.spiffe;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import io.spiffe.spiffeid.SpiffeId;
+import org.apache.camel.NamedNode;
+import org.apache.camel.Processor;
+import org.apache.camel.Route;
+import org.apache.camel.spi.AuthorizationPolicy;
+import org.apache.camel.util.ObjectHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An {@link AuthorizationPolicy} that authorizes a route segment on the 
caller's verified SPIFFE ID.
+ * <p>
+ * Wrap a part of a route with it to make a routing decision on the peer 
identity that SPIFFE mutual TLS established,
+ * without writing the check into the route by hand:
+ *
+ * <pre>
+ * from("netty-http:https://0.0.0.0:8443?sslContextParameters=#spiffeSsl";)
+ *         .policy(spiffePolicy) // acceptedSpiffeIds = 
spiffe://example.org/frontend
+ *         .to("direct:handleOrder");
+ * </pre>
+ *
+ * The peer SPIFFE ID is read from the <em>verified</em> TLS peer certificate 
the consumer put on the exchange, never
+ * from a message header (see CAMEL-24730). A peer that is not on the {@code 
acceptedSpiffeIds} allow-list - or presents
+ * no verified SVID at all - is denied with a {@link 
org.apache.camel.CamelAuthorizationException}, matching the sibling
+ * {@code ShiroSecurityPolicy} / {@code KeycloakSecurityPolicy} / {@code 
OpaSecurityPolicy}.
+ * <p>
+ * The policy answers "is the peer who they claim to be"; it is designed to 
compose with {@code camel-opa} ("may they do
+ * this") rather than duplicate it. On a successful authorization the verified 
SPIFFE ID is stored as the
+ * {@link SpiffeConstants#PEER_SPIFFE_ID} exchange property, so an OPA policy 
downstream can forward it to a Rego rule
+ * through its {@code includeProperties} option (CAMEL-24643).
+ */
+public class SpiffeSecurityPolicy implements AuthorizationPolicy {
+
+    /**
+     * Default header carrying the verified {@link javax.net.ssl.SSLSession}; 
the one camel-netty-http populates.
+     */
+    public static final String DEFAULT_SSL_SESSION_HEADER = 
"CamelNettySSLSession";
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(SpiffeSecurityPolicy.class);
+
+    private String acceptedSpiffeIds;
+    private boolean acceptAnySpiffeId;
+    private String sslSessionHeader = DEFAULT_SSL_SESSION_HEADER;
+
+    private volatile Set<SpiffeId> acceptedSet = Set.of();
+
+    public SpiffeSecurityPolicy() {
+    }
+
+    public SpiffeSecurityPolicy(String acceptedSpiffeIds) {
+        this.acceptedSpiffeIds = acceptedSpiffeIds;
+    }
+
+    @Override
+    public void beforeWrap(Route route, NamedNode definition) {
+        boolean haveAllowList = ObjectHelper.isNotEmpty(acceptedSpiffeIds);
+        if (acceptAnySpiffeId && haveAllowList) {
+            throw new IllegalStateException(
+                    "acceptAnySpiffeId and acceptedSpiffeIds are mutually 
exclusive; set only one");
+        }
+        if (!acceptAnySpiffeId && !haveAllowList) {
+            throw new IllegalStateException(
+                    "SpiffeSecurityPolicy requires either 
acceptAnySpiffeId=true or a non-empty acceptedSpiffeIds"
+                                            + " allow-list");
+        }
+        Set<SpiffeId> parsed = haveAllowList ? 
parseSpiffeIds(acceptedSpiffeIds) : Set.of();
+        if (!acceptAnySpiffeId && parsed.isEmpty()) {
+            throw new IllegalStateException("acceptedSpiffeIds did not contain 
any SPIFFE ID after trimming");
+        }
+        this.acceptedSet = parsed;
+    }
+
+    @Override
+    public Processor wrap(Route route, Processor processor) {
+        if (LOG.isDebugEnabled()) {
+            LOG.debug("Securing route {} with SPIFFE policy 
(acceptAnySpiffeId={}, acceptedSpiffeIds={})",
+                    route.getRouteId(), acceptAnySpiffeId, acceptedSet);
+        }
+        return new SpiffeSecurityProcessor(processor, this);
+    }
+
+    /**
+     * Whether the given verified peer SPIFFE ID is authorized by this policy.
+     */
+    boolean isAuthorized(SpiffeId peer) {
+        return acceptAnySpiffeId || acceptedSet.contains(peer);
+    }
+
+    static Set<SpiffeId> parseSpiffeIds(String csv) {
+        Set<SpiffeId> ids = new LinkedHashSet<>();
+        for (String part : csv.split(",")) {
+            String trimmed = part.trim();
+            if (!trimmed.isEmpty()) {
+                ids.add(SpiffeId.parse(trimmed));
+            }
+        }
+        return ids;
+    }
+
+    public String getAcceptedSpiffeIds() {
+        return acceptedSpiffeIds;
+    }
+
+    /**
+     * Comma-separated allow-list of peer SPIFFE IDs this route segment 
authorizes (for example
+     * {@code spiffe://example.org/frontend}). Mutually exclusive with {@code 
acceptAnySpiffeId}.
+     */
+    public void setAcceptedSpiffeIds(String acceptedSpiffeIds) {
+        this.acceptedSpiffeIds = acceptedSpiffeIds;
+    }
+
+    public boolean isAcceptAnySpiffeId() {
+        return acceptAnySpiffeId;
+    }
+
+    /**
+     * Authorize any peer that presents a verified SPIFFE SVID, instead of an 
explicit {@code acceptedSpiffeIds}
+     * allow-list. The handshake still has to have authenticated the peer 
against the trust bundle, so this asserts only
+     * "a verified SPIFFE peer exists" and leaves the which-peer decision to a 
downstream policy (for example OPA).
+     * Still fails closed when no verified peer certificate is present. 
Mutually exclusive with
+     * {@code acceptedSpiffeIds}; when neither is set the policy refuses to 
start.
+     */
+    public void setAcceptAnySpiffeId(boolean acceptAnySpiffeId) {
+        this.acceptAnySpiffeId = acceptAnySpiffeId;
+    }
+
+    public String getSslSessionHeader() {
+        return sslSessionHeader;
+    }
+
+    /**
+     * Name of the message header that carries the verified {@link 
javax.net.ssl.SSLSession} from which the peer
+     * certificate is read. Defaults to {@code CamelNettySSLSession}, the 
header camel-netty-http populates; override it
+     * for another TLS consumer that exposes the session under a different 
name.
+     */
+    public void setSslSessionHeader(String sslSessionHeader) {
+        this.sslSessionHeader = sslSessionHeader;
+    }
+}
diff --git 
a/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSecurityProcessor.java
 
b/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSecurityProcessor.java
new file mode 100644
index 000000000000..fce3ba6526dd
--- /dev/null
+++ 
b/components/camel-spiffe/src/main/java/org/apache/camel/component/spiffe/SpiffeSecurityProcessor.java
@@ -0,0 +1,74 @@
+/*
+ * 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.spiffe;
+
+import io.spiffe.spiffeid.SpiffeId;
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.CamelAuthorizationException;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.support.processor.DelegateAsyncProcessor;
+
+/**
+ * Enforces a {@link SpiffeSecurityPolicy} before the wrapped processor runs.
+ * <p>
+ * It extends {@link DelegateAsyncProcessor} (as the {@link 
org.apache.camel.spi.Policy} SPI advises) so the guarded
+ * part of the route keeps running on the asynchronous routing engine; only 
the authorization check itself runs
+ * synchronously on the calling thread, which is a cheap in-memory comparison 
with no I/O.
+ */
+public class SpiffeSecurityProcessor extends DelegateAsyncProcessor {
+
+    private final SpiffeSecurityPolicy policy;
+
+    public SpiffeSecurityProcessor(Processor processor, SpiffeSecurityPolicy 
policy) {
+        super(processor);
+        this.policy = policy;
+    }
+
+    @Override
+    public boolean process(Exchange exchange, AsyncCallback callback) {
+        try {
+            authorize(exchange);
+        } catch (Exception e) {
+            exchange.setException(e);
+            callback.done(true);
+            return true;
+        }
+        return super.process(exchange, callback);
+    }
+
+    protected void authorize(Exchange exchange) throws 
CamelAuthorizationException {
+        SpiffeId peer = SpiffePeerIdentity.fromExchange(exchange, 
policy.getSslSessionHeader());
+        if (peer == null) {
+            markFailure(exchange);
+            throw new CamelAuthorizationException(
+                    "No verified SPIFFE peer identity on the exchange (is 
mutual TLS configured and required?)",
+                    exchange);
+        }
+        if (!policy.isAuthorized(peer)) {
+            markFailure(exchange);
+            throw new CamelAuthorizationException("Peer SPIFFE ID " + peer + " 
is not authorized", exchange);
+        }
+        // hand the verified identity downstream (e.g. to the 
includeProperties of an OPA policy) as an exchange
+        // property, never a header: it must stay a value the route 
established, not one a sender can put on the message
+        exchange.setProperty(SpiffeConstants.PEER_SPIFFE_ID, peer.toString());
+    }
+
+    private void markFailure(Exchange exchange) {
+        
exchange.getMessage().setHeader(Exchange.AUTHENTICATION_FAILURE_POLICY_ID, 
policy.getClass().getSimpleName());
+    }
+}
diff --git 
a/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSecurityPolicyAcceptAnyTest.java
 
b/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSecurityPolicyAcceptAnyTest.java
new file mode 100644
index 000000000000..f77730b8ea23
--- /dev/null
+++ 
b/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSecurityPolicyAcceptAnyTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.spiffe;
+
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.util.List;
+
+import javax.net.ssl.SSLSession;
+
+import org.apache.camel.CamelAuthorizationException;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class SpiffeSecurityPolicyAcceptAnyTest extends CamelTestSupport {
+
+    private final SpiffeSecurityPolicy policy = new SpiffeSecurityPolicy();
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        policy.setAcceptAnySpiffeId(true);
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:start")
+                        .policy(policy)
+                        .to("mock:authorized");
+            }
+        };
+    }
+
+    private static SSLSession sessionPresenting(String spiffeId) throws 
Exception {
+        X509Certificate cert = mock(X509Certificate.class);
+        when(cert.getSubjectAlternativeNames()).thenReturn(List.of(List.of(6, 
spiffeId)));
+        SSLSession session = mock(SSLSession.class);
+        when(session.getPeerCertificates()).thenReturn(new Certificate[] { 
cert });
+        return session;
+    }
+
+    @Test
+    void authorizesAnyVerifiedPeerAndExposesItsId() throws Exception {
+        MockEndpoint authorized = getMockEndpoint("mock:authorized");
+        authorized.expectedMessageCount(1);
+
+        Exchange out = template.request("direct:start", e -> {
+            
e.getMessage().setHeader(SpiffeSecurityPolicy.DEFAULT_SSL_SESSION_HEADER,
+                    sessionPresenting("spiffe://other.org/some-workload"));
+            e.getMessage().setBody("payload");
+        });
+
+        assertThat(out.getException()).isNull();
+        authorized.assertIsSatisfied();
+        
assertThat(out.getProperty(SpiffeConstants.PEER_SPIFFE_ID)).isEqualTo("spiffe://other.org/some-workload");
+    }
+
+    @Test
+    void stillFailsClosedWhenNoVerifiedPeerIsPresent() throws Exception {
+        MockEndpoint authorized = getMockEndpoint("mock:authorized");
+        authorized.expectedMessageCount(0);
+
+        Exchange out = template.request("direct:start", e -> 
e.getMessage().setBody("payload"));
+
+        
assertThat(out.getException()).isInstanceOf(CamelAuthorizationException.class)
+                .hasMessageContaining("No verified SPIFFE peer identity");
+        authorized.assertIsSatisfied();
+    }
+}
diff --git 
a/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSecurityPolicyConfigTest.java
 
b/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSecurityPolicyConfigTest.java
new file mode 100644
index 000000000000..616896a18742
--- /dev/null
+++ 
b/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSecurityPolicyConfigTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.spiffe;
+
+import io.spiffe.spiffeid.SpiffeId;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Configuration-time (beforeWrap) validation and the authorization decision, 
exercised without a running route.
+ */
+class SpiffeSecurityPolicyConfigTest {
+
+    @Test
+    void rejectsBothAcceptAnyAndAnAllowList() {
+        SpiffeSecurityPolicy policy = new 
SpiffeSecurityPolicy("spiffe://example.org/frontend");
+        policy.setAcceptAnySpiffeId(true);
+
+        assertThatThrownBy(() -> policy.beforeWrap(null, null))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("mutually exclusive");
+    }
+
+    @Test
+    void failsClosedWhenNeitherIsConfigured() {
+        SpiffeSecurityPolicy policy = new SpiffeSecurityPolicy();
+
+        assertThatThrownBy(() -> policy.beforeWrap(null, null))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("requires either");
+    }
+
+    @Test
+    void rejectsAnAllowListThatIsAllWhitespace() {
+        SpiffeSecurityPolicy policy = new SpiffeSecurityPolicy(" , ,  ");
+
+        assertThatThrownBy(() -> policy.beforeWrap(null, null))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("did not contain any SPIFFE ID");
+    }
+
+    @Test
+    void allowListAuthorizesOnlyTheListedPeers() {
+        SpiffeSecurityPolicy policy = new 
SpiffeSecurityPolicy("spiffe://example.org/frontend, spiffe://example.org/ops");
+        policy.beforeWrap(null, null);
+
+        
assertThat(policy.isAuthorized(SpiffeId.parse("spiffe://example.org/frontend"))).isTrue();
+        
assertThat(policy.isAuthorized(SpiffeId.parse("spiffe://example.org/ops"))).isTrue();
+        
assertThat(policy.isAuthorized(SpiffeId.parse("spiffe://example.org/backend"))).isFalse();
+        
assertThat(policy.isAuthorized(SpiffeId.parse("spiffe://other.org/frontend"))).isFalse();
+    }
+
+    @Test
+    void acceptAnyAuthorizesEveryParsedPeer() {
+        SpiffeSecurityPolicy policy = new SpiffeSecurityPolicy();
+        policy.setAcceptAnySpiffeId(true);
+        policy.beforeWrap(null, null);
+
+        
assertThat(policy.isAuthorized(SpiffeId.parse("spiffe://example.org/frontend"))).isTrue();
+        
assertThat(policy.isAuthorized(SpiffeId.parse("spiffe://any.other/workload"))).isTrue();
+    }
+}
diff --git 
a/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSecurityPolicyTest.java
 
b/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSecurityPolicyTest.java
new file mode 100644
index 000000000000..f61605cdf1a6
--- /dev/null
+++ 
b/components/camel-spiffe/src/test/java/org/apache/camel/component/spiffe/SpiffeSecurityPolicyTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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.spiffe;
+
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+
+import org.apache.camel.CamelAuthorizationException;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class SpiffeSecurityPolicyTest extends CamelTestSupport {
+
+    private static final String FRONTEND = "spiffe://example.org/frontend";
+    private static final String BACKEND = "spiffe://example.org/backend";
+
+    private final SpiffeSecurityPolicy policy = new SpiffeSecurityPolicy();
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        policy.setAcceptedSpiffeIds(FRONTEND);
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:start")
+                        .policy(policy)
+                        .to("mock:authorized");
+            }
+        };
+    }
+
+    private static X509Certificate certWithUriSans(String... uris) throws 
Exception {
+        Collection<List<?>> sans = new ArrayList<>();
+        for (String uri : uris) {
+            // 6 == uniformResourceIdentifier GeneralName tag
+            sans.add(List.of(6, uri));
+        }
+        X509Certificate cert = mock(X509Certificate.class);
+        when(cert.getSubjectAlternativeNames()).thenReturn(sans);
+        return cert;
+    }
+
+    private static SSLSession sessionPresenting(Certificate... chain) throws 
Exception {
+        SSLSession session = mock(SSLSession.class);
+        when(session.getPeerCertificates()).thenReturn(chain);
+        return session;
+    }
+
+    private Exchange sendWithSession(SSLSession session) {
+        return template.request("direct:start", e -> {
+            if (session != null) {
+                
e.getMessage().setHeader(SpiffeSecurityPolicy.DEFAULT_SSL_SESSION_HEADER, 
session);
+            }
+            e.getMessage().setBody("payload");
+        });
+    }
+
+    @Test
+    void authorizesAListedPeerAndExposesTheIdAsAnExchangeProperty() throws 
Exception {
+        MockEndpoint authorized = getMockEndpoint("mock:authorized");
+        authorized.expectedMessageCount(1);
+
+        Exchange out = 
sendWithSession(sessionPresenting(certWithUriSans(FRONTEND)));
+
+        assertThat(out.getException()).isNull();
+        authorized.assertIsSatisfied();
+        
assertThat(out.getProperty(SpiffeConstants.PEER_SPIFFE_ID)).isEqualTo(FRONTEND);
+    }
+
+    @Test
+    void deniesAPeerThatIsNotOnTheAllowList() throws Exception {
+        MockEndpoint authorized = getMockEndpoint("mock:authorized");
+        authorized.expectedMessageCount(0);
+
+        Exchange out = 
sendWithSession(sessionPresenting(certWithUriSans(BACKEND)));
+
+        
assertThat(out.getException()).isInstanceOf(CamelAuthorizationException.class)
+                .hasMessageContaining(BACKEND);
+        
assertThat(out.getMessage().getHeader(Exchange.AUTHENTICATION_FAILURE_POLICY_ID))
+                .isEqualTo("SpiffeSecurityPolicy");
+        assertThat(out.getProperty(SpiffeConstants.PEER_SPIFFE_ID)).isNull();
+        authorized.assertIsSatisfied();
+    }
+
+    @Test
+    void deniesWhenThereIsNoTlsSessionAtAll() throws Exception {
+        MockEndpoint authorized = getMockEndpoint("mock:authorized");
+        authorized.expectedMessageCount(0);
+
+        Exchange out = sendWithSession(null);
+
+        
assertThat(out.getException()).isInstanceOf(CamelAuthorizationException.class)
+                .hasMessageContaining("No verified SPIFFE peer identity");
+        authorized.assertIsSatisfied();
+    }
+
+    @Test
+    void deniesWhenThePeerPresentedNoVerifiedCertificate() throws Exception {
+        MockEndpoint authorized = getMockEndpoint("mock:authorized");
+        authorized.expectedMessageCount(0);
+
+        SSLSession session = mock(SSLSession.class);
+        when(session.getPeerCertificates()).thenThrow(new 
SSLPeerUnverifiedException("no peer certificate"));
+
+        Exchange out = sendWithSession(session);
+
+        
assertThat(out.getException()).isInstanceOf(CamelAuthorizationException.class);
+        authorized.assertIsSatisfied();
+    }
+
+    @Test
+    void deniesACertificateThatCarriesNoSpiffeId() throws Exception {
+        MockEndpoint authorized = getMockEndpoint("mock:authorized");
+        authorized.expectedMessageCount(0);
+
+        Exchange out = 
sendWithSession(sessionPresenting(certWithUriSans("https://example.org/not-a-spiffe-id";)));
+
+        
assertThat(out.getException()).isInstanceOf(CamelAuthorizationException.class)
+                .hasMessageContaining("No verified SPIFFE peer identity");
+        authorized.assertIsSatisfied();
+    }
+
+    @Test
+    void deniesACertificateWithMoreThanOneSpiffeId() throws Exception {
+        MockEndpoint authorized = getMockEndpoint("mock:authorized");
+        authorized.expectedMessageCount(0);
+
+        // a certificate with two SPIFFE URI SANs is not a valid SVID; the 
policy must not guess which identity to trust
+        Exchange out = 
sendWithSession(sessionPresenting(certWithUriSans(FRONTEND, BACKEND)));
+
+        
assertThat(out.getException()).isInstanceOf(CamelAuthorizationException.class)
+                .hasMessageContaining("No verified SPIFFE peer identity");
+        authorized.assertIsSatisfied();
+    }
+
+    @Test
+    void deniesACertificateWithASpiffeAndANonSpiffeUriSan() throws Exception {
+        MockEndpoint authorized = getMockEndpoint("mock:authorized");
+        authorized.expectedMessageCount(0);
+
+        // a valid X509-SVID has exactly one URI SAN in total; a second, 
non-spiffe URI means it is not an SVID, even
+        // though one of the two URIs is the expected SPIFFE ID
+        Exchange out = 
sendWithSession(sessionPresenting(certWithUriSans("https://example.org/web";, 
FRONTEND)));
+
+        
assertThat(out.getException()).isInstanceOf(CamelAuthorizationException.class)
+                .hasMessageContaining("No verified SPIFFE peer identity");
+        authorized.assertIsSatisfied();
+    }
+}

Reply via email to