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();
+ }
+}