` in the configuration, and `AuditTrail` keeps the last decisions of the policy, which
+the backend exposes on `/api/audit`.
+
+=== The Camel routes
+
+* The backend (`BackendRoutes`) serves the orders, and makes the second hop with its own identity:
+`fetchJwtSvid` with the inventory as audience. The caller it is serving travels along in a header, for the audit
+trail of the inventory, which trusts that header only because the backend itself is authenticated and allowed to
+call it. Nothing of the second hop is sent back to the caller.
++
+[source,java]
+----
+from("platform-http:/api/orders?httpMethodRestrict=GET").routeId("orders")
+ .routeConfigurationId(WorkloadIdentityPolicy.ID)
+ // the policy has authenticated and authorized the caller by the time the route starts
+ .bean(OrderService.class, "listOrders")
+ .setProperty("orders", body())
+ .setProperty("onBehalfOf", header(SpiffeConstants.SPIFFE_ID))
+ .to("direct:stockLevels")
+ .bean(OrderService.class, "withStock")
+ .marshal().json()
+ .removeHeaders("CamelSpiffe*");
+
+from("direct:stockLevels").routeId("stock-levels")
+ .to("spiffe:backend?operation=fetchJwtSvid&audience={{inventory.audience}}")
+ .setHeader("Authorization", simple("Bearer ${body}"))
+ .setHeader("X-On-Behalf-Of", exchangeProperty("onBehalfOf"))
+ .setBody(simple("${null}"))
+ .removeHeaders("CamelHttp*")
+ .removeHeaders("CamelSpiffe*")
+ .to("http://{{inventory.host}}:{{inventory.port}}/api/stock?httpMethod=GET")
+ .removeHeader("Authorization")
+ .removeHeader("X-On-Behalf-Of")
+ .unmarshal().json(Map.class);
+----
+
+* The inventory (`InventoryRoutes`) is the same policy applied to another service:
++
+[source,java]
+----
+from("platform-http:/api/stock?httpMethodRestrict=GET").routeId("stock")
+ .routeConfigurationId(WorkloadIdentityPolicy.ID)
+ .log("Serving the stock levels to ${header.CamelSpiffeSpiffeId} on behalf of ${header.X-On-Behalf-Of}")
+ .bean(StockService.class, "levels")
+ .marshal().json()
+ .removeHeaders("CamelSpiffe*");
+----
+
+* The frontend (`FrontendRoutes`) gets its tokens with `fetchJwtSvid`: the message body becomes the token, the
+SPIFFE ID and the expiry of the token are set as the `CamelSpiffeSpiffeId` and `CamelSpiffeExpiry` headers. The
+timers only set the path to call; the `wrong-audience` route also sets the `CamelSpiffeAudience` header, which
+overrides the audience of the endpoint for that message.
++
+[source,java]
+----
+from("timer:orders?period={{frontend.period}}").routeId("orders")
+ .setHeader(Exchange.HTTP_PATH, constant("/api/orders"))
+ .to("direct:callBackend");
+
+from("direct:callBackend").routeId("call-backend")
+ .to("spiffe:frontend?operation=fetchJwtSvid&audience={{backend.audience}}")
+ .log("Fetched a JWT-SVID for ${header.CamelSpiffeSpiffeId} (valid until ${header.CamelSpiffeExpiry})")
+ .setHeader("Authorization", simple("Bearer ${body}"))
+ .setBody(simple("${null}"))
+ .removeHeaders("CamelSpiffe*")
+ .to("http://{{backend.host}}:{{backend.port}}?httpMethod=GET&throwExceptionOnFailure=false")
+ .log("GET ${header.CamelHttpPath} answered HTTP ${header.CamelHttpResponseCode}: ${body}");
+----
+
+* All applications run `IdentityRoutes`, where `fetchX509Svid` makes the message body an
+ `io.spiffe.svid.x509svid.X509Svid` with the certificate chain, the private key and the SPIFFE ID of the
+ workload. `X509SvidSummary` describes the leaf certificate, and only the certificate: the private key is never
+ logged.
+
+The component finds the Workload API through the standard `SPIFFE_ENDPOINT_SOCKET` environment variable, which
+`compose.yaml` sets for each application. The `camel.component.spiffe.spiffe-socket-path` option in
+`application.properties` (commented out) does the same from the configuration.
+
+=== Build
+
+The example is built with Maven:
+
+[source,sh]
+----
+$ mvn package
+----
+
+This also runs the unit tests, which do not need SPIRE (see below), and copies the runtime dependencies to
+`target/lib`, from where `src/main/docker/Dockerfile` picks them up.
+
+=== How to run
+
+You need Docker with Docker Compose. Build the images and start everything with:
+
+[source,sh]
+----
+$ docker compose up --build
+----
+
+The `spire` container starts a SPIRE server, registers the four workloads, then starts a SPIRE agent that joins
+the server with a one-time token. Once the agent serves the Workload API, the inventory and the backend start, then
+the frontend and the auditor. Within a few seconds the logs show the frontend getting the orders with their stock
+levels, the inventory serving the backend on behalf of the frontend, the auditor being turned away from the orders
+but reading the audit trail and, now and then, the frontend being rejected when it presents a token minted for
+another audience:
+
+----
+spire-1 | Registering spiffe://example.org/frontend for the workload running with uid 1001
+spire-1 | Registering spiffe://example.org/backend for the workload running with uid 1002
+spire-1 | Registering spiffe://example.org/auditor for the workload running with uid 1003
+spire-1 | Registering spiffe://example.org/inventory for the workload running with uid 1004
+spire-1 | time="..." level=info msg="Node attestation was successful" ...
+frontend-1 | 17:27:23.6 [timer://orders] call-backend INFO Fetched a JWT-SVID for spiffe://example.org/frontend (valid until ...)
+backend-1 | 17:27:23.7 [worker-thread-1] orders INFO Authenticated caller spiffe://example.org/frontend, allowed to call orders
+inventory-1 | 17:27:23.9 [worker-thread-0] stock INFO Authenticated caller spiffe://example.org/backend, allowed to call stock
+inventory-1 | 17:27:23.9 [worker-thread-0] stock INFO Serving the stock levels to spiffe://example.org/backend on behalf of spiffe://example.org/frontend
+frontend-1 | 17:27:24.0 [timer://orders] call-backend INFO GET /api/orders answered HTTP 200: {"caller":"spiffe://example.org/frontend","orders":[{"id":1001,"item":"Camel in Action, 2nd edition","quantity":2,"inStock":true},{"id":1002,"item":"Enterprise Integration Patterns","quantity":1,"inStock":false},...
+backend-1 | 17:27:23.7 [worker-thread-0] orders WARN Authenticated caller spiffe://example.org/auditor is not allowed to call orders
+auditor-1 | 17:27:23.7 [timer://orders] call-backend INFO GET /api/orders answered HTTP 403: 403 Forbidden: spiffe://example.org/auditor is not allowed to call orders
+auditor-1 | 17:27:52.3 [timer://audit] call-backend INFO GET /api/audit answered HTTP 200: {"service":"backend","decisions":[{"time":"2026-09-03T17:27:23Z","route":"orders","caller":"spiffe://example.org/auditor","outcome":"denied"},{"time":"2026-09-03T17:27:23Z","route":"orders","caller":"spiffe://example.org/frontend","outcome":"allowed"},...
+frontend-1 | 17:27:52.3 [timer://audit] call-backend INFO GET /api/audit answered HTTP 403: 403 Forbidden: spiffe://example.org/frontend is not allowed to call audit
+frontend-1 | 17:28:07.3 [timer://wrongAudience] wrong-audience INFO Asking for a JWT-SVID with the wrong audience, the backend should reject it
+backend-1 | 17:28:07.3 [worker-thread-10] orders WARN Rejected request to orders: Error validating JWT SVID: INVALID_ARGUMENT: expected audience in ["spiffe://example.org/backend"] (audience=["spiffe://example.org/some-other-service"])
+frontend-1 | 17:28:07.3 [timer://wrongAudience] call-backend INFO GET /api/orders answered HTTP 401: 401 Unauthorized: Error validating JWT SVID: INVALID_ARGUMENT: expected audience in ["spiffe://example.org/backend"] (audience=["spiffe://example.org/some-other-service"])
+inventory-1 | 17:28:22.3 [timer://identity] identity INFO X.509-SVID of spiffe://example.org/inventory
+inventory-1 | serial number : 1b9d0f4c7d0e6d2a5f7c3b1e9a8d6c4f
+inventory-1 | subject : O=SPIRE, C=US
+inventory-1 | issuer : SERIALNUMBER=..., CN=example.org, O=Apache Camel, C=US
+inventory-1 | valid from : 2026-09-03T17:27:12Z
+inventory-1 | valid until : 2026-09-03T17:37:22Z
+inventory-1 | URI SANs : [spiffe://example.org/inventory]
+inventory-1 | chain length : 1 certificate(s)
+----
+
+A few things to try while it runs:
+
+* Call the services yourself. You have no SPIFFE identity and no token, so both turn you away (the inventory is
+ published on port 8081):
++
+[source,sh]
+----
+$ curl -i http://localhost:8080/api/orders
+HTTP/1.1 401 Unauthorized
+...
+401 Unauthorized: no bearer token in the Authorization header
+
+$ curl -i http://localhost:8081/api/stock
+HTTP/1.1 401 Unauthorized
+...
+----
+
+* Look at the registration entries, that is, at who is who in the trust domain:
++
+[source,sh]
+----
+$ docker compose exec spire /opt/spire/bin/spire-server entry show
+----
+
+* Watch the X.509-SVID of an application being rotated. The SPIRE agent renews a certificate halfway through its
+ lifetime, so about every five minutes the serial number and the validity period in the summary change, while the
+ SPIFFE ID stays the same:
++
+[source,sh]
+----
+$ docker compose logs -f frontend | grep -A 8 "X.509-SVID of"
+----
+
+* Read why a token was rejected from the point of view of the SPIRE agent, which is what actually validates it:
++
+[source,sh]
+----
+$ docker compose logs spire | grep "Failed to validate JWT"
+----
+
+* Change who may do what: add `spiffe://example.org/auditor` to `backend.allow.orders` in
+ `src/main/resources/application.properties`, then rebuild and restart the backend with
+ `mvn package -DskipTests && docker compose up --build -d backend`. Nothing changes in the auditor, yet its next
+ call gets the orders back. Or add `spiffe://example.org/frontend` to `inventory.allow.stock` and see that the
+ frontend still cannot call the inventory: its tokens are minted for the backend, not for the inventory.
+
+Stop everything, and remove the containers and the volume with the Workload API socket, with:
+
+[source,sh]
+----
+$ docker compose down -v
+----
+
+NOTE: The `spire` container is a shortcut for the purpose of this example: it runs the SPIRE server and the SPIRE
+agent side by side and as root, and the Camel applications share its PID namespace so that the agent can attest
+them with the `unix` workload attestor. A real deployment runs the server on its own, one agent per node and,
+on Kubernetes, attests the workloads by their pod and service account instead of their Unix user.
+
+=== Running the tests
+
+[source,sh]
+----
+$ mvn test
+----
+
+The tests do not need SPIRE. Each test class binds a Mockito mock of `io.spiffe.workloadapi.WorkloadApiClient` to
+the Camel registry with `@BindToRegistry`, and the SPIFFE component autowires the single client it finds there (its
+`workloadApiClient` option). The routes under test are therefore exactly the ones that run in the containers, only
+the SPIRE agent is replaced by a fake that mints, validates or refuses SVIDs as the test needs. The backend and the
+inventory are tested over HTTP, on the embedded server of Camel Main, so the policy runs exactly as in the
+containers; the backend test also stubs the inventory on that server to check the second hop.
+
+=== Running the applications outside Docker
+
+The applications can also run directly on your machine with `mvn camel:run`, as long as a SPIRE agent (or any other
+SPIFFE Workload API) is reachable and has a registration entry for the process that runs them:
+
+[source,sh]
+----
+$ export SPIFFE_ENDPOINT_SOCKET=unix:///tmp/spire-agent/public/api.sock
+$ mvn camel:run -Dcamel.server.port=8081 -Dcamel.mainClass=org.apache.camel.example.spiffe.inventory.InventoryApplication
+$ mvn camel:run -Dinventory.host=localhost -Dinventory.port=8081
+$ mvn camel:run -Dbackend.host=localhost -Dcamel.mainClass=org.apache.camel.example.spiffe.frontend.FrontendApplication
+----
+
+Keep in mind that all the applications then run as the same Unix user, hence with the same SPIFFE ID, unless the
+Workload API you use attests them differently.
+
+The Workload API is a gRPC service on a Unix domain socket, for which the SPIFFE Java library needs a native
+transport. The `pom.xml` of the example includes the Linux one (`io.spiffe:grpc-netty-linux`), which is what the
+containers need. On macOS replace it with `io.spiffe:grpc-netty-macos` or, on Apple silicon,
+`io.spiffe:grpc-netty-macos-aarch64`.
+
+=== Help and contributions
+
+If you hit any problem using Camel or have some feedback, then please
+https://camel.apache.org/community/support/[let us know].
+
+We also love contributors, so
+https://camel.apache.org/community/contributing/[get involved] :-)
+
+The Camel riders!
diff --git a/spiffe/compose.yaml b/spiffe/compose.yaml
new file mode 100644
index 000000000..c5b250d64
--- /dev/null
+++ b/spiffe/compose.yaml
@@ -0,0 +1,107 @@
+## 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.
+
+# everything the three Camel applications have in common: the same image, the SPIFFE Workload API socket of the
+# SPIRE agent, and the PID namespace of the SPIRE container (the agent attests a workload by looking up the process
+# that connects to the Workload API, so it must be able to see it)
+x-camel-application: &camel-application
+ build:
+ context: .
+ dockerfile: src/main/docker/Dockerfile
+ image: camel-example-spiffe
+ pid: "service:spire"
+ volumes:
+ - spire-sockets:/run/spire/sockets
+ depends_on:
+ spire:
+ condition: service_healthy
+
+services:
+
+ # SPIRE server and agent in one container (see spire/entrypoint.sh): the server issues the identities,
+ # the agent attests the workloads and hands them their SVIDs through the SPIFFE Workload API
+ spire:
+ build: ./spire
+ volumes:
+ - spire-sockets:/run/spire/sockets
+ healthcheck:
+ test: ["CMD", "/opt/spire/bin/spire-agent", "healthcheck", "-socketPath", "/run/spire/sockets/agent.sock"]
+ interval: 5s
+ timeout: 3s
+ retries: 30
+ start_period: 5s
+
+ # the second hop: an HTTP API that only the backend may call. uid 1004 is registered as
+ # spiffe://example.org/inventory
+ inventory:
+ <<: *camel-application
+ command: ["org.apache.camel.example.spiffe.inventory.InventoryApplication"]
+ user: "1004:1004"
+ environment:
+ CAMEL_MAIN_NAME: inventory
+ SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock
+ ports:
+ - "8081:8080"
+
+ # the HTTP API the clients talk to, which calls the inventory with its own identity.
+ # uid 1002 is registered as spiffe://example.org/backend
+ backend:
+ <<: *camel-application
+ command: ["org.apache.camel.example.spiffe.backend.BackendApplication"]
+ user: "1002:1002"
+ environment:
+ CAMEL_MAIN_NAME: backend
+ SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock
+ ports:
+ - "8080:8080"
+ depends_on:
+ spire:
+ condition: service_healthy
+ inventory:
+ condition: service_started
+
+ # the client that is allowed to read the orders (but not the audit trail): uid 1001 is registered as
+ # spiffe://example.org/frontend
+ frontend:
+ <<: *camel-application
+ command: ["org.apache.camel.example.spiffe.frontend.FrontendApplication"]
+ user: "1001:1001"
+ environment:
+ CAMEL_MAIN_NAME: frontend
+ SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock
+ depends_on:
+ spire:
+ condition: service_healthy
+ backend:
+ condition: service_started
+
+ # the very same code and image as the frontend, but another Unix user, hence another identity: uid 1003 is
+ # registered as spiffe://example.org/auditor, which may read the audit trail but not the orders
+ auditor:
+ <<: *camel-application
+ command: ["org.apache.camel.example.spiffe.frontend.FrontendApplication"]
+ user: "1003:1003"
+ environment:
+ CAMEL_MAIN_NAME: auditor
+ SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock
+ depends_on:
+ spire:
+ condition: service_healthy
+ backend:
+ condition: service_started
+
+volumes:
+ # the Unix domain socket of the SPIFFE Workload API, shared between the SPIRE agent and the workloads
+ spire-sockets:
diff --git a/spiffe/pom.xml b/spiffe/pom.xml
new file mode 100644
index 000000000..0d5a136c9
--- /dev/null
+++ b/spiffe/pom.xml
@@ -0,0 +1,160 @@
+
+
+
+
+ 4.0.0
+
+
+ org.apache.camel.example
+ camel-examples
+ 4.23.0-SNAPSHOT
+
+
+ camel-example-spiffe
+ jar
+ Camel :: Example :: SPIFFE
+ An example for showing workload identity with the Camel SPIFFE component (JWT-SVID and X.509-SVID issued by SPIRE)
+
+
+ Security
+ SPIFFE
+ 3.8.1
+
+
+
+
+
+
+ org.apache.camel
+ camel-bom
+ ${camel.version}
+ pom
+ import
+
+
+
+
+
+
+
+ org.apache.camel
+ camel-core
+
+
+ org.apache.camel
+ camel-main
+
+
+
+ org.apache.camel
+ camel-spiffe
+
+
+
+ io.spiffe
+ grpc-netty-linux
+ ${java-spiffe-version}
+ runtime
+
+
+
+ org.apache.camel
+ camel-platform-http-main
+
+
+
+ org.apache.camel
+ camel-http
+
+
+ org.apache.camel
+ camel-timer
+
+
+ org.apache.camel
+ camel-jackson
+
+
+
+
+ org.apache.logging.log4j
+ log4j-core
+ ${log4j2-version}
+ runtime
+
+
+ org.apache.logging.log4j
+ log4j-slf4j2-impl
+ ${log4j2-version}
+ runtime
+
+
+
+
+ org.apache.camel
+ camel-test-main-junit6
+ test
+
+
+ org.mockito
+ mockito-core
+ ${mockito-version}
+ test
+
+
+
+
+
+
+
+ org.apache.camel
+ camel-maven-plugin
+ ${camel.version}
+
+ false
+ org.apache.camel.example.spiffe.backend.BackendApplication
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+ ${maven-dependency-plugin-version}
+
+
+ copy-dependencies
+ package
+
+ copy-dependencies
+
+
+ ${project.build.directory}/lib
+ runtime
+
+
+
+
+
+
+
+
diff --git a/spiffe/spire/Dockerfile b/spiffe/spire/Dockerfile
new file mode 100644
index 000000000..abc6e99ad
--- /dev/null
+++ b/spiffe/spire/Dockerfile
@@ -0,0 +1,32 @@
+# 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.
+
+# The official SPIRE images are built from scratch and have no shell. This image copies their static binaries
+# into a small Alpine image, so that entrypoint.sh can bootstrap a complete SPIRE deployment for the example:
+# a server, an agent and the registration entries of the workloads.
+FROM ghcr.io/spiffe/spire-server:1.15.3 AS spire-server
+FROM ghcr.io/spiffe/spire-agent:1.15.3 AS spire-agent
+
+FROM alpine:3.22
+
+COPY --from=spire-server /opt/spire/bin/spire-server /opt/spire/bin/spire-server
+COPY --from=spire-agent /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent
+COPY server.conf agent.conf /opt/spire/conf/
+COPY entrypoint.sh /opt/spire/entrypoint.sh
+
+RUN chmod 0755 /opt/spire/entrypoint.sh \
+ && mkdir -p /opt/spire/data/server /opt/spire/data/agent /run/spire/sockets /tmp/spire-server/private
+
+ENTRYPOINT ["/opt/spire/entrypoint.sh"]
diff --git a/spiffe/spire/agent.conf b/spiffe/spire/agent.conf
new file mode 100644
index 000000000..a87d2c942
--- /dev/null
+++ b/spiffe/spire/agent.conf
@@ -0,0 +1,43 @@
+# 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.
+
+# SPIRE agent configuration, see https://github.com/spiffe/spire/blob/main/doc/spire_agent.md
+agent {
+ data_dir = "/opt/spire/data/agent"
+ log_level = "INFO"
+ server_address = "127.0.0.1"
+ server_port = "8081"
+ # the SPIFFE Workload API: the Camel applications connect here (SPIFFE_ENDPOINT_SOCKET in compose.yaml)
+ socket_path = "/run/spire/sockets/agent.sock"
+ # the CA bundle of the server, exported by entrypoint.sh before the agent starts
+ trust_bundle_path = "/opt/spire/data/bootstrap.crt"
+ trust_domain = "example.org"
+}
+
+plugins {
+ KeyManager "memory" {
+ plugin_data {}
+ }
+
+ NodeAttestor "join_token" {
+ plugin_data {}
+ }
+
+ # attests the workloads that connect to the Workload API by their Unix user id, group id, and so on.
+ # The registration entries of this example use the unix:uid selector (see entrypoint.sh)
+ WorkloadAttestor "unix" {
+ plugin_data {}
+ }
+}
diff --git a/spiffe/spire/entrypoint.sh b/spiffe/spire/entrypoint.sh
new file mode 100755
index 000000000..35a82ac34
--- /dev/null
+++ b/spiffe/spire/entrypoint.sh
@@ -0,0 +1,58 @@
+#!/bin/sh
+# 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.
+
+# Bootstraps a complete (single node) SPIRE deployment for the example:
+# 1. starts the SPIRE server
+# 2. registers the workloads, mapping the Unix user id of each Camel application to its SPIFFE ID
+# 3. generates a join token and starts the SPIRE agent with it, which then serves the SPIFFE Workload API
+set -e
+
+SPIRE_BIN=/opt/spire/bin
+SERVER_SOCKET=/tmp/spire-server/private/api.sock
+TRUST_DOMAIN=example.org
+AGENT_ID="spiffe://${TRUST_DOMAIN}/spire-agent"
+
+# 1. the server issues all the identities of the trust domain
+"${SPIRE_BIN}/spire-server" run -config /opt/spire/conf/server.conf &
+
+echo "Waiting for the SPIRE server to be ready..."
+until "${SPIRE_BIN}/spire-server" healthcheck -socketPath "${SERVER_SOCKET}" > /dev/null 2>&1; do
+ sleep 1
+done
+
+# the agent verifies the server with the CA bundle of the trust domain
+"${SPIRE_BIN}/spire-server" bundle show -socketPath "${SERVER_SOCKET}" > /opt/spire/data/bootstrap.crt
+
+# 2. a registration entry tells SPIRE which workload (selectors) gets which identity (SPIFFE ID).
+# The Camel applications of this example each run as a different Unix user, so the uid is the selector.
+register() {
+ echo "Registering spiffe://${TRUST_DOMAIN}/$1 for the workload running with uid $2"
+ "${SPIRE_BIN}/spire-server" entry create -socketPath "${SERVER_SOCKET}" \
+ -parentID "${AGENT_ID}" \
+ -spiffeID "spiffe://${TRUST_DOMAIN}/$1" \
+ -selector "unix:uid:$2"
+}
+register frontend 1001
+register backend 1002
+register auditor 1003
+register inventory 1004
+
+# 3. the agent attests to the server with a one-time join token (the -spiffeID option also gives the agent
+# the alias AGENT_ID, which the entries above use as parent ID)
+TOKEN=$("${SPIRE_BIN}/spire-server" token generate -socketPath "${SERVER_SOCKET}" -spiffeID "${AGENT_ID}" \
+ | awk '/^Token:/ { print $2 }')
+
+exec "${SPIRE_BIN}/spire-agent" run -config /opt/spire/conf/agent.conf -joinToken "${TOKEN}"
diff --git a/spiffe/spire/server.conf b/spiffe/spire/server.conf
new file mode 100644
index 000000000..065e0d56c
--- /dev/null
+++ b/spiffe/spire/server.conf
@@ -0,0 +1,55 @@
+# 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.
+
+# SPIRE server configuration, see https://github.com/spiffe/spire/blob/main/doc/spire_server.md
+server {
+ bind_address = "127.0.0.1"
+ bind_port = "8081"
+ socket_path = "/tmp/spire-server/private/api.sock"
+ trust_domain = "example.org"
+ data_dir = "/opt/spire/data/server"
+ log_level = "INFO"
+
+ # short lifetimes, so that the example shows the rotation of the SVIDs while you watch the logs
+ ca_ttl = "24h"
+ default_x509_svid_ttl = "10m"
+ default_jwt_svid_ttl = "5m"
+
+ ca_subject {
+ country = ["US"]
+ organization = ["Apache Camel"]
+ common_name = "example.org"
+ }
+}
+
+plugins {
+ DataStore "sql" {
+ plugin_data {
+ database_type = "sqlite3"
+ connection_string = "/opt/spire/data/server/datastore.sqlite3"
+ }
+ }
+
+ KeyManager "disk" {
+ plugin_data {
+ keys_path = "/opt/spire/data/server/keys.json"
+ }
+ }
+
+ # the agent joins with a one-time token generated by entrypoint.sh
+ NodeAttestor "join_token" {
+ plugin_data {}
+ }
+}
diff --git a/spiffe/src/main/docker/Dockerfile b/spiffe/src/main/docker/Dockerfile
new file mode 100644
index 000000000..9e3f95858
--- /dev/null
+++ b/spiffe/src/main/docker/Dockerfile
@@ -0,0 +1,30 @@
+# 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.
+
+FROM eclipse-temurin:21-jre
+
+# each application of the example runs as its own Unix user: the SPIRE agent maps the uid to a SPIFFE ID
+RUN useradd --uid 1001 --user-group --no-create-home frontend \
+ && useradd --uid 1002 --user-group --no-create-home backend \
+ && useradd --uid 1003 --user-group --no-create-home auditor \
+ && useradd --uid 1004 --user-group --no-create-home inventory
+
+COPY target/lib /deployments/lib
+COPY target/camel-example-spiffe-*.jar /deployments/lib/
+
+WORKDIR /deployments
+ENTRYPOINT ["java", "-cp", "/deployments/lib/*"]
+# the main class to run: compose.yaml overrides it for the frontend and the auditor
+CMD ["org.apache.camel.example.spiffe.backend.BackendApplication"]
diff --git a/spiffe/src/main/java/org/apache/camel/example/spiffe/IdentityRoutes.java b/spiffe/src/main/java/org/apache/camel/example/spiffe/IdentityRoutes.java
new file mode 100644
index 000000000..c419d7649
--- /dev/null
+++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/IdentityRoutes.java
@@ -0,0 +1,49 @@
+/*
+ * 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.example.spiffe;
+
+import org.apache.camel.LoggingLevel;
+import org.apache.camel.builder.RouteBuilder;
+
+/**
+ * Fetches the X.509-SVID of this workload from the SPIFFE Workload API at regular intervals and logs a summary of it.
+ *
+ * Both applications of this example run this very same route, yet each of them is issued a different identity: the
+ * SPIRE agent attests the process that connects to the Workload API (in this example by its Unix user id) and looks up
+ * the registration entry that matches it. Identity comes from the platform, not from the code or its configuration.
+ *
+ * The X.509-SVID is short-lived and the SPIRE agent rotates it before it expires, so the serial number and the validity
+ * period in the log change over time without the application doing anything about it.
+ */
+public class IdentityRoutes extends RouteBuilder {
+
+ @Override
+ public void configure() {
+ // the SPIRE agent may not have attested this workload yet: log the problem and try again on the next tick
+ onException(Exception.class)
+ .handled(true)
+ .log(LoggingLevel.WARN, "Could not fetch the X.509-SVID: ${exception.message}");
+
+ from("timer:identity?period={{identity.period}}").routeId("identity")
+ // fetchX509Svid is also the default operation of the component. The message body becomes an
+ // io.spiffe.svid.x509svid.X509Svid and the SPIFFE ID is set as the CamelSpiffeSpiffeId header
+ .to("spiffe:identity?operation=fetchX509Svid")
+ // the X509Svid also carries the private key of the workload, so never log the body as-is
+ .bean(X509SvidSummary.class, "describe")
+ .log("${body}");
+ }
+}
diff --git a/spiffe/src/main/java/org/apache/camel/example/spiffe/X509SvidSummary.java b/spiffe/src/main/java/org/apache/camel/example/spiffe/X509SvidSummary.java
new file mode 100644
index 000000000..125446226
--- /dev/null
+++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/X509SvidSummary.java
@@ -0,0 +1,73 @@
+/*
+ * 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.example.spiffe;
+
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import io.spiffe.svid.x509svid.X509Svid;
+
+/**
+ * Turns an {@link X509Svid} into a human-readable summary of its leaf certificate. Only the certificate is described:
+ * the private key that comes with the SVID is never printed.
+ */
+public class X509SvidSummary {
+
+ /**
+ * The GeneralName type of a uniformResourceIdentifier subject alternative name, which is where a SPIFFE ID is
+ * encoded in an X.509-SVID.
+ */
+ private static final int URI_NAME = 6;
+
+ public String describe(X509Svid svid) throws CertificateParsingException {
+ X509Certificate leaf = svid.getLeaf();
+ return String.format("""
+ X.509-SVID of %s
+ serial number : %s
+ subject : %s
+ issuer : %s
+ valid from : %s
+ valid until : %s
+ URI SANs : %s
+ chain length : %d certificate(s)""",
+ svid.getSpiffeId(),
+ leaf.getSerialNumber().toString(16),
+ leaf.getSubjectX500Principal(),
+ leaf.getIssuerX500Principal(),
+ leaf.getNotBefore().toInstant(),
+ leaf.getNotAfter().toInstant(),
+ uriSubjectAlternativeNames(leaf),
+ svid.getChain().size());
+ }
+
+ private static List uriSubjectAlternativeNames(X509Certificate certificate)
+ throws CertificateParsingException {
+ List uris = new ArrayList<>();
+ Collection> names = certificate.getSubjectAlternativeNames();
+ if (names != null) {
+ for (List> name : names) {
+ if (Integer.valueOf(URI_NAME).equals(name.get(0))) {
+ uris.add(String.valueOf(name.get(1)));
+ }
+ }
+ }
+ return uris;
+ }
+}
diff --git a/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendApplication.java b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendApplication.java
new file mode 100644
index 000000000..4d93e5970
--- /dev/null
+++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendApplication.java
@@ -0,0 +1,44 @@
+/*
+ * 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.example.spiffe.backend;
+
+import org.apache.camel.example.spiffe.IdentityRoutes;
+import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy;
+import org.apache.camel.main.Main;
+
+/**
+ * Boots the backend: an HTTP API for orders that only serves callers with a valid JWT-SVID, and that calls the
+ * inventory service with its own identity.
+ */
+public final class BackendApplication {
+
+ private BackendApplication() {
+ }
+
+ public static void main(String[] args) throws Exception {
+ Main main = new Main();
+ // the embedded HTTP server
+ main.configure().httpServer().withEnabled(true).withPort(8080);
+ // the workload identity policy of this service, and the routes that use it
+ WorkloadIdentityPolicy policy = new WorkloadIdentityPolicy("backend");
+ main.configure().addRoutesBuilder(policy);
+ main.configure().addRoutesBuilder(new BackendRoutes(policy.getAuditTrail()));
+ main.configure().addRoutesBuilder(IdentityRoutes.class);
+ // now keep the application running until the JVM is terminated (ctrl + c or sigterm)
+ main.run(args);
+ }
+}
diff --git a/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendRoutes.java b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendRoutes.java
new file mode 100644
index 000000000..8890b1f3d
--- /dev/null
+++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendRoutes.java
@@ -0,0 +1,85 @@
+/*
+ * 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.example.spiffe.backend;
+
+import java.util.Map;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.LoggingLevel;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+import org.apache.camel.example.spiffe.policy.AuditTrail;
+import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy;
+
+/**
+ * The backend exposes two HTTP routes, both guarded by the {@link WorkloadIdentityPolicy}: the orders, which are
+ * completed with the stock levels of the inventory service (the second hop), and the audit trail of the policy.
+ */
+public class BackendRoutes extends RouteBuilder {
+
+ private final AuditTrail auditTrail;
+
+ public BackendRoutes(AuditTrail auditTrail) {
+ this.auditTrail = auditTrail;
+ }
+
+ @Override
+ public void configure() {
+ // the inventory could not be reached, or refused our identity: the orders cannot be served
+ onException(Exception.class)
+ .handled(true)
+ .log(LoggingLevel.ERROR, "Could not get the stock levels from the inventory: ${exception.message}")
+ .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(502))
+ .setHeader(Exchange.CONTENT_TYPE, constant("text/plain"))
+ .setBody(simple("502 Bad Gateway: ${exception.message}"))
+ .removeHeaders("CamelSpiffe*");
+
+ from("platform-http:/api/orders?httpMethodRestrict=GET").routeId("orders")
+ .routeConfigurationId(WorkloadIdentityPolicy.ID)
+ // the policy has authenticated and authorized the caller by the time the route starts
+ .bean(OrderService.class, "listOrders")
+ .setProperty("orders", body())
+ .setProperty("onBehalfOf", header(SpiffeConstants.SPIFFE_ID))
+ .to("direct:stockLevels")
+ .bean(OrderService.class, "withStock")
+ .marshal().json()
+ .removeHeaders("CamelSpiffe*");
+
+ // the second hop: ask the inventory service for the stock levels, with our own identity. The JWT-SVID is
+ // minted for the inventory (its audience), and the caller we are serving travels along in a header for the
+ // audit trail of the inventory, which trusts it because we are authenticated and allowed to call it
+ from("direct:stockLevels").routeId("stock-levels")
+ .to("spiffe:backend?operation=fetchJwtSvid&audience={{inventory.audience}}")
+ .setHeader("Authorization", simple("Bearer ${body}"))
+ .setHeader("X-On-Behalf-Of", exchangeProperty("onBehalfOf"))
+ .setBody(simple("${null}"))
+ // the headers of the request we are serving must not shape the request we are making
+ .removeHeaders("CamelHttp*")
+ .removeHeaders("CamelSpiffe*")
+ .to("http://{{inventory.host}}:{{inventory.port}}/api/stock?httpMethod=GET")
+ // our token is not for the caller to see
+ .removeHeader("Authorization")
+ .removeHeader("X-On-Behalf-Of")
+ .unmarshal().json(Map.class);
+
+ from("platform-http:/api/audit?httpMethodRestrict=GET").routeId("audit")
+ .routeConfigurationId(WorkloadIdentityPolicy.ID)
+ .bean(auditTrail, "report")
+ .marshal().json()
+ .removeHeaders("CamelSpiffe*");
+ }
+}
diff --git a/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/OrderService.java b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/OrderService.java
new file mode 100644
index 000000000..4d2851d24
--- /dev/null
+++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/OrderService.java
@@ -0,0 +1,63 @@
+/*
+ * 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.example.spiffe.backend;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.ExchangeProperty;
+import org.apache.camel.Header;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+
+/**
+ * Stands in for the real business logic: returns a few orders, together with the identity of the caller they are
+ * served to, and completes them with the stock levels of the inventory.
+ */
+public class OrderService {
+
+ public Map listOrders(@Header(SpiffeConstants.SPIFFE_ID) String caller) {
+ Map response = new LinkedHashMap<>();
+ response.put("caller", caller);
+ response.put("orders", List.of(
+ order(1001, "Camel in Action, 2nd edition", 2),
+ order(1002, "Enterprise Integration Patterns", 1),
+ order(1003, "Zero Trust Networks", 3)));
+ return response;
+ }
+
+ /**
+ * Marks each order as in stock or not, from the stock levels answered by the inventory (item to quantity).
+ */
+ @SuppressWarnings("unchecked")
+ public Map withStock(
+ @ExchangeProperty("orders") Map response, Map stockLevels) {
+ for (Map order : (List