This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-examples.git
The following commit(s) were added to refs/heads/main by this push:
new bd58c2b4 CAMEL-23305: Add camel-spiffe example (workload identity with
SPIFFE and SPIRE) (#260)
bd58c2b4 is described below
commit bd58c2b49fb5f483f5b2520552d630fcb1b89f54
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Sep 7 16:18:43 2026 +0200
CAMEL-23305: Add camel-spiffe example (workload identity with SPIFFE and
SPIRE) (#260)
A Docker Compose example for the camel-spiffe component: a SPIRE server
and agent issue identities to four Camel Main applications, which
authenticate each other with JWT-SVIDs (fetchJwtSvid / validateJwtSvid),
authorize by SPIFFE ID with per-route allow-lists in a shared route
configuration, make a second hop with their own identity, and log their
rotating X.509-SVIDs (fetchX509Svid).
Co-authored-by: Claude Fable 5.1 <[email protected]>
---
README.adoc | 4 +-
pom.xml | 1 +
spiffe/README.adoc | 373 +++++++++++++++++++++
spiffe/compose.yaml | 107 ++++++
spiffe/pom.xml | 160 +++++++++
spiffe/spire/Dockerfile | 32 ++
spiffe/spire/agent.conf | 43 +++
spiffe/spire/entrypoint.sh | 58 ++++
spiffe/spire/server.conf | 55 +++
spiffe/src/main/docker/Dockerfile | 30 ++
.../camel/example/spiffe/IdentityRoutes.java | 49 +++
.../camel/example/spiffe/X509SvidSummary.java | 73 ++++
.../example/spiffe/backend/BackendApplication.java | 44 +++
.../example/spiffe/backend/BackendRoutes.java | 85 +++++
.../camel/example/spiffe/backend/OrderService.java | 63 ++++
.../spiffe/frontend/FrontendApplication.java | 41 +++
.../example/spiffe/frontend/FrontendRoutes.java | 70 ++++
.../spiffe/inventory/InventoryApplication.java | 42 +++
.../example/spiffe/inventory/InventoryRoutes.java | 38 +++
.../example/spiffe/inventory/StockService.java | 34 ++
.../camel/example/spiffe/policy/AllowList.java | 49 +++
.../camel/example/spiffe/policy/AuditTrail.java | 63 ++++
.../camel/example/spiffe/policy/BearerToken.java | 38 +++
.../example/spiffe/policy/RejectionReason.java | 37 ++
.../spiffe/policy/WorkloadIdentityPolicy.java | 101 ++++++
spiffe/src/main/resources/application.properties | 52 +++
spiffe/src/main/resources/log4j2.properties | 23 ++
.../camel/example/spiffe/IdentityRoutesTest.java | 103 ++++++
.../example/spiffe/backend/BackendRoutesTest.java | 222 ++++++++++++
.../spiffe/frontend/FrontendRoutesTest.java | 143 ++++++++
.../spiffe/inventory/InventoryRoutesTest.java | 109 ++++++
spiffe/src/test/resources/frontend-svid.pem | 13 +
32 files changed, 2354 insertions(+), 1 deletion(-)
diff --git a/README.adoc b/README.adoc
index 075bc2b9..e8bab9e0 100644
--- a/README.adoc
+++ b/README.adoc
@@ -28,7 +28,7 @@ readme's instructions.
== Examples
// examples: START
-Number of Examples: 72 (2 deprecated)
+Number of Examples: 71 (0 deprecated)
[width="100%",cols="4,2,4",options="header"]
|===
@@ -162,6 +162,8 @@ Number of Examples: 72 (2 deprecated)
| link:vault/google-secret-manager-reloading/README.adoc[Google Secret Manager
Reloading] (google-secret-manager-reloading) | Security | An example for
showing Google Secret Manager Camel component with reloading
+| link:spiffe/README.adoc[SPIFFE] (spiffe) | Security | An example for showing
workload identity with the Camel SPIFFE component (JWT-SVID and X.509-SVID
issued by SPIRE)
+
| link:salesforce-consumer/README.adoc[Salesforce Consumer]
(salesforce-consumer) | Social | An example that uses Salesforce Rest Streaming
API
| link:telegram/README.adoc[Telegram] (telegram) | Social | An example that
uses Telegram API
diff --git a/pom.xml b/pom.xml
index 025bc3ee..e27ab5be 100644
--- a/pom.xml
+++ b/pom.xml
@@ -127,6 +127,7 @@
<module>routetemplate-xml</module>
<module>routes-configuration</module>
<module>salesforce-consumer</module>
+ <module>spiffe</module>
<module>spring</module>
<module>spring-pulsar</module>
<module>spring-xquery</module>
diff --git a/spiffe/README.adoc b/spiffe/README.adoc
new file mode 100644
index 00000000..23f5fb15
--- /dev/null
+++ b/spiffe/README.adoc
@@ -0,0 +1,373 @@
+== Camel Example SPIFFE
+
+This example shows how to give Camel applications a cryptographic workload
identity with the
+https://camel.apache.org/components/next/spiffe-component.html[Camel SPIFFE
component] (`camel-spiffe`),
+and how a small chain of services uses that identity to authenticate and
authorize their calls without any shared
+secret, password or API key in the code, in the configuration or on disk.
+
+https://spiffe.io/[SPIFFE] (Secure Production Identity Framework For Everyone)
names a workload with a SPIFFE ID
+such as `spiffe://example.org/frontend` and proves that name with two kinds of
SPIFFE Verifiable Identity Documents
+(SVIDs): an X.509-SVID (a certificate) and a JWT-SVID (a token).
https://spiffe.io/docs/latest/spire-about/[SPIRE]
+is the reference implementation: a SPIRE server issues the documents and a
SPIRE agent hands them out to the
+workloads through the SPIFFE Workload API, once it has _attested_ them, that
is, once it has checked who they are.
+In this example the agent attests a workload by the Unix user it runs as.
+
+=== What the example does
+
+Four Camel applications and a SPIRE deployment run with Docker Compose, all in
the trust domain `example.org`:
+
+----
+
+---------------------------------------------------------------------+
+ | spire: SPIRE server + SPIRE agent
|
+ |
|
+ | registration entries
|
+ | unix:uid:1001 -> spiffe://example.org/frontend
|
+ | unix:uid:1002 -> spiffe://example.org/backend
|
+ | unix:uid:1003 -> spiffe://example.org/auditor
|
+ | unix:uid:1004 -> spiffe://example.org/inventory
|
+
+----------------------------------+----------------------------------+
+ | SPIFFE Workload API
(Unix socket)
+
+-------------------+----------------------+-+-------------------------+
+ | | |
|
++---------+--------+ +--------+---------+ +----------+---------+
+---------------+--------+
+| frontend | | auditor | | backend | | inventory
|
+| uid 1001 | | uid 1003 | | uid 1002 | | uid 1004
|
+| | | | | | |
|
+| fetchJwtSvid | | fetchJwtSvid | | validateJwtSvid | |
validateJwtSvid |
+| fetchX509Svid | | fetchX509Svid | | fetchJwtSvid | |
fetchX509Svid |
+| | | | | fetchX509Svid | |
|
++---------+--------+ +--------+---------+ +----+----------+----+
+---------------+--------+
+ | | ^ |
^
+ | GET /api/orders | | | GET /api/stock
|
+ | GET /api/audit | | | Authorization:
Bearer JWT (backend)
+ | Authorization: Bearer JWT | | X-On-Behalf-Of:
<caller>
+ +-------------------+---------------+
+-----------------------+
+ frontend: orders 200, audit 403
+ auditor: orders 403, audit 200
+----
+
+* The *backend* exposes `GET /api/orders` and `GET /api/audit`. Both routes
use the same
+ <<the-workload-identity-policy,workload identity policy>>: every request
must carry a JWT-SVID as bearer token,
+ which the backend hands to the Workload API (`validateJwtSvid`) to check its
signature, its expiry and that it was
+ minted for the backend (the _audience_ of the token). The SPIFFE ID of the
caller comes back in the
+ `CamelSpiffeSpiffeId` header, and the allow-list of the route decides
whether that caller may use it.
+* The *inventory* is the second hop. To serve the orders, the backend asks it
for the stock levels with a JWT-SVID
+ of its own (`fetchJwtSvid`, this time with the inventory as audience) and
tells it on whose behalf it asks. The
+ inventory uses the very same policy: it accepts the backend, and nobody else.
+* The *frontend* asks the Workload API every 10 seconds for a JWT-SVID with
the backend as audience
+ (`fetchJwtSvid`) and reads the orders with it, completed with the stock
levels (HTTP 200). Every 30 seconds it
+ also tries to read the audit trail, which it is not allowed to (HTTP 403).
Every 45 seconds it asks for a token
+ minted for some other service and presents that one instead, which the
backend rejects (HTTP 401).
+* The *auditor* is the very same code and image as the frontend, but it runs
as another Unix user. The SPIRE agent
+ therefore gives it another identity, `spiffe://example.org/auditor`, with
the opposite permissions: it may read
+ the audit trail of the backend (HTTP 200), that is, who called what and with
which outcome, but not the orders
+ (HTTP 403).
+* All four applications also fetch their X.509-SVID once a minute
(`fetchX509Svid`) and log a summary of the
+ certificate: the SPIFFE ID (a URI subject alternative name), the serial
number and the validity period. The
+ certificates are short-lived (10 minutes) and the SPIRE agent rotates them
before they expire, so the summary
+ changes over time without the applications doing anything about it.
+
+The identities are not configured anywhere in the applications: they come from
the `spire` container, which
+registers the workloads at startup (see `spire/entrypoint.sh`). What each
identity may do is a few lines of
+`application.properties`:
+
+[source,properties]
+----
+backend.allow.orders = spiffe://example.org/frontend
+backend.allow.audit = spiffe://example.org/auditor
+inventory.allow.stock = spiffe://example.org/backend
+----
+
+[width="100%",cols="1,1,3,5",options="header"]
+|===
+| Container | Unix uid | SPIFFE ID | What it may do
+
+| `frontend` | 1001 | `spiffe://example.org/frontend` | `GET /api/orders` on
the backend (200); the audit trail is denied (403)
+| `auditor` | 1003 | `spiffe://example.org/auditor` | `GET /api/audit` on the
backend (200); the orders are denied (403)
+| `backend` | 1002 | `spiffe://example.org/backend` | serves the orders and
the audit trail, `GET /api/stock` on the inventory (200)
+| `inventory` | 1004 | `spiffe://example.org/inventory` | serves the stock
levels to the backend
+|===
+
+[#the-workload-identity-policy]
+=== The workload identity policy
+
+`WorkloadIdentityPolicy` is a Camel
+https://camel.apache.org/manual/route-configuration.html[route configuration]:
the checks it contains run before
+the first step of every route that opts in with
`routeConfigurationId(WorkloadIdentityPolicy.ID)`, so the routes
+of the backend and of the inventory contain business logic only. It is
instantiated once per service
+(`new WorkloadIdentityPolicy("backend")`), which is how it finds the audience
of the service and the allow-lists of
+its routes in the configuration.
+
+[source,java]
+----
+// whatever goes wrong while checking the token means that the caller is not
authenticated: HTTP 401
+policy.onException(JwtSvidException.class, IllegalArgumentException.class)
+ .handled(true)
+ .setBody(method(RejectionReason.class, "of"))
+ .bean(auditTrail, "record(${routeId}, null, 'rejected', ${body})")
+ .log(LoggingLevel.WARN, "Rejected request to ${routeId}: ${body}")
+ .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(401))
+ .setBody(simple("401 Unauthorized: ${body}"))
+ .removeHeaders("CamelSpiffe*");
+
+// runs before the first step of every route that uses this policy
+policy.interceptFrom()
+ // authentication: the bearer token must be a JWT-SVID minted for this
service
+ .setHeader(SpiffeConstants.TOKEN).method(BearerToken.class, "extract")
+ .removeHeader("Authorization")
+ .to("spiffe:" + service + "?operation=validateJwtSvid&audience={{" +
service + ".audience}}")
+ // authorization: the caller must be on the allow-list of the route
+ .choice()
+ .when(method(allowList, "isAllowed(${routeId},
${header.CamelSpiffeSpiffeId})"))
+ .bean(auditTrail, "record(${routeId},
${header.CamelSpiffeSpiffeId}, 'allowed', null)")
+ .log("Authenticated caller ${header.CamelSpiffeSpiffeId},
allowed to call ${routeId}")
+ .otherwise()
+ .bean(auditTrail, "record(${routeId},
${header.CamelSpiffeSpiffeId}, 'denied', null)")
+ .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(403))
+ .setBody(simple("403 Forbidden: ${header.CamelSpiffeSpiffeId}
is not allowed to call ${routeId}"))
+ .removeHeaders("CamelSpiffe*")
+ // the route itself does not run
+ .stop()
+ .end();
+----
+
+`validateJwtSvid` takes the token from the `CamelSpiffeToken` header. The
message body becomes the validated
+`io.spiffe.svid.jwtsvid.JwtSvid` and the SPIFFE ID of the caller is set as the
`CamelSpiffeSpiffeId` header. A
+failed validation throws an `io.spiffe.exception.JwtSvidException`, whose
cause says why (expired, wrong audience,
+unknown key, ...); the policy puts that reason in the HTTP 401 response.
`AllowList` looks up
+`<service>.allow.<route id>` 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 00000000..c5b250d6
--- /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 00000000..0d5a136c
--- /dev/null
+++ b/spiffe/pom.xml
@@ -0,0 +1,160 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ 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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/maven-v4_0_0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.camel.example</groupId>
+ <artifactId>camel-examples</artifactId>
+ <version>4.23.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>camel-example-spiffe</artifactId>
+ <packaging>jar</packaging>
+ <name>Camel :: Example :: SPIFFE</name>
+ <description>An example for showing workload identity with the Camel
SPIFFE component (JWT-SVID and X.509-SVID issued by SPIRE)</description>
+
+ <properties>
+ <category>Security</category>
+ <title>SPIFFE</title>
+
<maven-dependency-plugin-version>3.8.1</maven-dependency-plugin-version>
+ </properties>
+
+ <dependencyManagement>
+ <dependencies>
+ <!-- Add Camel BOM -->
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-bom</artifactId>
+ <version>${camel.version}</version>
+ <type>pom</type>
+ <scope>import</scope>
+ </dependency>
+ </dependencies>
+ </dependencyManagement>
+
+ <dependencies>
+
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-main</artifactId>
+ </dependency>
+ <!-- talks to the SPIFFE Workload API (SPIRE agent) -->
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-spiffe</artifactId>
+ </dependency>
+ <!-- the Workload API is a gRPC service on a Unix domain socket;
java-spiffe needs a native transport for that.
+ This is the Linux one, which is what runs inside the containers
of this example. When running the
+ applications directly on macOS use io.spiffe:grpc-netty-macos or
io.spiffe:grpc-netty-macos-aarch64 instead. -->
+ <dependency>
+ <groupId>io.spiffe</groupId>
+ <artifactId>grpc-netty-linux</artifactId>
+ <version>${java-spiffe-version}</version>
+ <scope>runtime</scope>
+ </dependency>
+ <!-- embedded HTTP server for the backend -->
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-platform-http-main</artifactId>
+ </dependency>
+ <!-- HTTP client for the frontend -->
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-http</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-timer</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-jackson</artifactId>
+ </dependency>
+
+ <!-- logging -->
+ <dependency>
+ <groupId>org.apache.logging.log4j</groupId>
+ <artifactId>log4j-core</artifactId>
+ <version>${log4j2-version}</version>
+ <scope>runtime</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.logging.log4j</groupId>
+ <artifactId>log4j-slf4j2-impl</artifactId>
+ <version>${log4j2-version}</version>
+ <scope>runtime</scope>
+ </dependency>
+
+ <!-- for testing -->
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-test-main-junit6</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <version>${mockito-version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <!-- to run via mvn camel:run (the backend by default, use
-Dcamel.mainClass=... for the frontend) -->
+ <plugin>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-maven-plugin</artifactId>
+ <version>${camel.version}</version>
+ <configuration>
+ <logClasspath>false</logClasspath>
+
<mainClass>org.apache.camel.example.spiffe.backend.BackendApplication</mainClass>
+ </configuration>
+ </plugin>
+
+ <!-- copy the runtime dependencies to target/lib so the container
image can pick them up -->
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <version>${maven-dependency-plugin-version}</version>
+ <executions>
+ <execution>
+ <id>copy-dependencies</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy-dependencies</goal>
+ </goals>
+ <configuration>
+
<outputDirectory>${project.build.directory}/lib</outputDirectory>
+ <includeScope>runtime</includeScope>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
diff --git a/spiffe/spire/Dockerfile b/spiffe/spire/Dockerfile
new file mode 100644
index 00000000..abc6e99a
--- /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 00000000..a87d2c94
--- /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 00000000..35a82ac3
--- /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 00000000..065e0d56
--- /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 00000000..9e3f9585
--- /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 00000000..c419d764
--- /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.
+ * <p>
+ * 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.
+ * <p>
+ * 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 00000000..12544622
--- /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<String> uriSubjectAlternativeNames(X509Certificate
certificate)
+ throws CertificateParsingException {
+ List<String> uris = new ArrayList<>();
+ Collection<List<?>> 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 00000000..4d93e597
--- /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 00000000..8890b1f3
--- /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 00000000..4d2851d2
--- /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<String, Object> listOrders(@Header(SpiffeConstants.SPIFFE_ID)
String caller) {
+ Map<String, Object> 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<String, Object> withStock(
+ @ExchangeProperty("orders") Map<String, Object> response,
Map<String, Object> stockLevels) {
+ for (Map<String, Object> order : (List<Map<String, Object>>)
response.get("orders")) {
+ int available = ((Number)
stockLevels.getOrDefault(order.get("item"), 0)).intValue();
+ order.put("inStock", available >= (Integer) order.get("quantity"));
+ }
+ return response;
+ }
+
+ private static Map<String, Object> order(int id, String item, int
quantity) {
+ Map<String, Object> order = new LinkedHashMap<>();
+ order.put("id", id);
+ order.put("item", item);
+ order.put("quantity", quantity);
+ return order;
+ }
+}
diff --git
a/spiffe/src/main/java/org/apache/camel/example/spiffe/frontend/FrontendApplication.java
b/spiffe/src/main/java/org/apache/camel/example/spiffe/frontend/FrontendApplication.java
new file mode 100644
index 00000000..be157cbc
--- /dev/null
+++
b/spiffe/src/main/java/org/apache/camel/example/spiffe/frontend/FrontendApplication.java
@@ -0,0 +1,41 @@
+/*
+ * 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.frontend;
+
+import org.apache.camel.example.spiffe.IdentityRoutes;
+import org.apache.camel.main.Main;
+
+/**
+ * Boots the frontend: a client that authenticates to the backend with the
JWT-SVIDs it gets from the SPIFFE Workload
+ * API.
+ * <p>
+ * The auditor of this example runs this very same class. It ends up with
another SPIFFE ID because it runs as another
+ * Unix user, which the SPIRE agent maps to another registration entry.
+ */
+public final class FrontendApplication {
+
+ private FrontendApplication() {
+ }
+
+ public static void main(String[] args) throws Exception {
+ Main main = new Main();
+ main.configure().addRoutesBuilder(FrontendRoutes.class);
+ 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/frontend/FrontendRoutes.java
b/spiffe/src/main/java/org/apache/camel/example/spiffe/frontend/FrontendRoutes.java
new file mode 100644
index 00000000..104fc44b
--- /dev/null
+++
b/spiffe/src/main/java/org/apache/camel/example/spiffe/frontend/FrontendRoutes.java
@@ -0,0 +1,70 @@
+/*
+ * 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.frontend;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.LoggingLevel;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+
+/**
+ * The frontend proves who it is to the backend with a JWT-SVID minted by the
SPIFFE Workload API for the backend (the
+ * audience of the token). There is no shared secret, password or API key
anywhere: the SPIRE agent attests the process
+ * and issues short-lived tokens for the identity that was registered for it.
+ */
+public class FrontendRoutes extends RouteBuilder {
+
+ @Override
+ public void configure() {
+ // the backend may still be starting, or the SPIRE agent may not have
attested this workload yet:
+ // log the problem and try again on the next timer tick
+ onException(Exception.class)
+ .handled(true)
+ .log(LoggingLevel.WARN, "Could not call the backend:
${exception.message}");
+
+ // every few seconds: read the orders
+ from("timer:orders?period={{frontend.period}}").routeId("orders")
+ .setHeader(Exchange.HTTP_PATH, constant("/api/orders"))
+ .to("direct:callBackend");
+
+ // less often: read the audit trail of the backend
+
from("timer:audit?period={{frontend.audit.period}}&delay={{frontend.audit.period}}").routeId("audit")
+ .setHeader(Exchange.HTTP_PATH, constant("/api/audit"))
+ .to("direct:callBackend");
+
+ // now and then: ask for a token minted for some other service (the
CamelSpiffeAudience header overrides the
+ // audience of the endpoint) and present that one instead. The backend
must reject it (HTTP 401): the token
+ // is genuine, but its audience is not the backend
+
from("timer:wrongAudience?period={{frontend.wrongAudience.period}}&delay={{frontend.wrongAudience.period}}")
+ .routeId("wrong-audience")
+ .setHeader(Exchange.HTTP_PATH, constant("/api/orders"))
+ .setHeader(SpiffeConstants.AUDIENCE,
simple("{{frontend.wrongAudience}}"))
+ .log("Asking for a JWT-SVID with the wrong audience, the
backend should reject it")
+ .to("direct:callBackend");
+
+ // get a JWT-SVID for the backend and present it as a bearer token, on
the path set by the caller
+ 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}"))
+ // the token travels in the header only, and the CamelSpiffe*
headers are of no use to the backend
+ .setBody(simple("${null}"))
+ .removeHeaders("CamelSpiffe*")
+
.to("http://{{backend.host}}:{{backend.port}}?httpMethod=GET&throwExceptionOnFailure=false")
+ .log("GET ${header.CamelHttpPath} answered HTTP
${header.CamelHttpResponseCode}: ${body}");
+ }
+}
diff --git
a/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/InventoryApplication.java
b/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/InventoryApplication.java
new file mode 100644
index 00000000..26c887f2
--- /dev/null
+++
b/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/InventoryApplication.java
@@ -0,0 +1,42 @@
+/*
+ * 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.inventory;
+
+import org.apache.camel.example.spiffe.IdentityRoutes;
+import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy;
+import org.apache.camel.main.Main;
+
+/**
+ * Boots the inventory: the second hop of the example, an HTTP API that only
the backend may call.
+ */
+public final class InventoryApplication {
+
+ private InventoryApplication() {
+ }
+
+ public static void main(String[] args) throws Exception {
+ Main main = new Main();
+ // the embedded HTTP server
+ main.configure().httpServer().withEnabled(true).withPort(8080);
+ // the very same policy as the backend, configured for this service
+ main.configure().addRoutesBuilder(new
WorkloadIdentityPolicy("inventory"));
+ main.configure().addRoutesBuilder(InventoryRoutes.class);
+ 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/inventory/InventoryRoutes.java
b/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/InventoryRoutes.java
new file mode 100644
index 00000000..b7b9320a
--- /dev/null
+++
b/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/InventoryRoutes.java
@@ -0,0 +1,38 @@
+/*
+ * 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.inventory;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy;
+
+/**
+ * The inventory answers the stock levels to the backend, and to nobody else.
+ */
+public class InventoryRoutes extends RouteBuilder {
+
+ @Override
+ public void configure() {
+
from("platform-http:/api/stock?httpMethodRestrict=GET").routeId("stock")
+ .routeConfigurationId(WorkloadIdentityPolicy.ID)
+ // the policy has checked that the caller is the backend. The
backend says on whose behalf it asks,
+ // which can be trusted because the backend itself is
authenticated and allowed to call this route
+ .log("Serving the stock levels to
${header.CamelSpiffeSpiffeId} on behalf of ${header.X-On-Behalf-Of}")
+ .bean(StockService.class, "levels")
+ .marshal().json()
+ .removeHeaders("CamelSpiffe*");
+ }
+}
diff --git
a/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/StockService.java
b/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/StockService.java
new file mode 100644
index 00000000..7776d1b2
--- /dev/null
+++
b/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/StockService.java
@@ -0,0 +1,34 @@
+/*
+ * 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.inventory;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Stands in for the real business logic: the stock level of each item.
+ */
+public class StockService {
+
+ public Map<String, Integer> levels() {
+ Map<String, Integer> levels = new LinkedHashMap<>();
+ levels.put("Camel in Action, 2nd edition", 12);
+ levels.put("Enterprise Integration Patterns", 0);
+ levels.put("Zero Trust Networks", 5);
+ return levels;
+ }
+}
diff --git
a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AllowList.java
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AllowList.java
new file mode 100644
index 00000000..e1c5cd95
--- /dev/null
+++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AllowList.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.policy;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+import org.apache.camel.CamelContext;
+
+/**
+ * Decides which callers may use which route. Authentication is left to
SPIFFE, so this is all the authorization logic
+ * the services need: an allow-list of SPIFFE IDs per route, read from the
configuration as
+ * {@code <service>.allow.<route id>}. A route without an allow-list accepts
nobody.
+ */
+public class AllowList {
+
+ private final CamelContext camelContext;
+ private final String prefix;
+
+ public AllowList(CamelContext camelContext, String service) {
+ this.camelContext = camelContext;
+ this.prefix = service + ".allow.";
+ }
+
+ public boolean isAllowed(String routeId, String spiffeId) {
+ if (routeId == null || spiffeId == null) {
+ return false;
+ }
+ Optional<String> allowedCallers =
camelContext.getPropertiesComponent().resolveProperty(prefix + routeId);
+ return allowedCallers.stream()
+ .flatMap(callers -> Arrays.stream(callers.split(",")))
+ .map(String::trim)
+ .anyMatch(spiffeId::equals);
+ }
+}
diff --git
a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AuditTrail.java
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AuditTrail.java
new file mode 100644
index 00000000..0e76e3a6
--- /dev/null
+++
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AuditTrail.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.policy;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Remembers the last decisions taken by the {@link WorkloadIdentityPolicy} of
a service: who called which route, and
+ * whether the request was allowed, denied (authenticated, but not on the
allow-list) or rejected (not authenticated).
+ */
+public class AuditTrail {
+
+ private static final int CAPACITY = 20;
+
+ private final String service;
+ private final Deque<Map<String, Object>> decisions = new ArrayDeque<>();
+
+ public AuditTrail(String service) {
+ this.service = service;
+ }
+
+ public synchronized void record(String route, String caller, String
outcome, String detail) {
+ Map<String, Object> decision = new LinkedHashMap<>();
+ decision.put("time",
Instant.now().truncatedTo(ChronoUnit.SECONDS).toString());
+ decision.put("route", route);
+ decision.put("caller", caller == null ? "anonymous" : caller);
+ decision.put("outcome", outcome);
+ if (detail != null) {
+ decision.put("detail", detail);
+ }
+ if (decisions.size() == CAPACITY) {
+ decisions.removeFirst();
+ }
+ decisions.addLast(decision);
+ }
+
+ public synchronized Map<String, Object> report() {
+ Map<String, Object> report = new LinkedHashMap<>();
+ report.put("service", service);
+ report.put("decisions", List.copyOf(decisions));
+ return report;
+ }
+}
diff --git
a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/BearerToken.java
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/BearerToken.java
new file mode 100644
index 00000000..67d5de2e
--- /dev/null
+++
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/BearerToken.java
@@ -0,0 +1,38 @@
+/*
+ * 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.policy;
+
+import org.apache.camel.Header;
+
+/**
+ * Extracts the bearer token from the {@code Authorization} header of an HTTP
request.
+ */
+public class BearerToken {
+
+ private static final String SCHEME = "Bearer ";
+
+ public String extract(@Header("Authorization") String authorization) {
+ if (authorization == null || !authorization.regionMatches(true, 0,
SCHEME, 0, SCHEME.length())) {
+ throw new IllegalArgumentException("no bearer token in the
Authorization header");
+ }
+ String token = authorization.substring(SCHEME.length()).trim();
+ if (token.isEmpty()) {
+ throw new IllegalArgumentException("empty bearer token in the
Authorization header");
+ }
+ return token;
+ }
+}
diff --git
a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/RejectionReason.java
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/RejectionReason.java
new file mode 100644
index 00000000..1c89c789
--- /dev/null
+++
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/RejectionReason.java
@@ -0,0 +1,37 @@
+/*
+ * 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.policy;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.ExchangeProperty;
+
+/**
+ * Explains why a request was rejected, from the exception that stopped it.
When the SPIFFE Workload API refuses a
+ * token, the java-spiffe library reports a generic "Error validating JWT
SVID" and keeps the actual reason (expired,
+ * wrong audience, unknown key, ...) in the cause, so that one is added to the
explanation.
+ */
+public class RejectionReason {
+
+ public String of(@ExchangeProperty(Exchange.EXCEPTION_CAUGHT) Exception
exception) {
+ StringBuilder reason = new StringBuilder(exception.getMessage());
+ Throwable cause = exception.getCause();
+ if (cause != null && cause.getMessage() != null) {
+ reason.append(": ").append(cause.getMessage());
+ }
+ return reason.toString();
+ }
+}
diff --git
a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/WorkloadIdentityPolicy.java
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/WorkloadIdentityPolicy.java
new file mode 100644
index 00000000..a951a705
--- /dev/null
+++
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/WorkloadIdentityPolicy.java
@@ -0,0 +1,101 @@
+/*
+ * 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.policy;
+
+import io.spiffe.exception.JwtSvidException;
+import org.apache.camel.Exchange;
+import org.apache.camel.LoggingLevel;
+import org.apache.camel.builder.RouteConfigurationBuilder;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+import org.apache.camel.model.RouteConfigurationDefinition;
+
+/**
+ * The workload identity policy shared by the services that expose an HTTP API
(the backend and the inventory). It is
+ * a route configuration: every route that opts in with {@code
routeConfigurationId(WorkloadIdentityPolicy.ID)} gets
+ * these checks before its own steps run, so the routes contain business logic
only.
+ * <ul>
+ * <li>Authentication: the request must carry a JWT-SVID as bearer token,
minted for this service (the audience). The
+ * SPIFFE Workload API checks the signature, the expiry and the audience; a
failure is answered with HTTP 401.</li>
+ * <li>Authorization: the SPIFFE ID of the caller must be on the allow-list of
the route (see {@link AllowList}), or
+ * the request is answered with HTTP 403.</li>
+ * <li>Audit: every decision is recorded in the {@link AuditTrail} of the
service.</li>
+ * </ul>
+ */
+public class WorkloadIdentityPolicy extends RouteConfigurationBuilder {
+
+ /** The id with which the HTTP routes opt in to this policy. */
+ public static final String ID = "workload-identity";
+
+ private final String service;
+ private final AuditTrail auditTrail;
+
+ /**
+ * @param service the name of the service, used to look up its audience
({@code <service>.audience}) and its
+ * allow-lists ({@code <service>.allow.<route id>}) in the
configuration
+ */
+ public WorkloadIdentityPolicy(String service) {
+ this.service = service;
+ this.auditTrail = new AuditTrail(service);
+ }
+
+ public AuditTrail getAuditTrail() {
+ return auditTrail;
+ }
+
+ @Override
+ public void configuration() {
+ AllowList allowList = new AllowList(getContext(), service);
+ RouteConfigurationDefinition policy = routeConfiguration(ID);
+
+ // whatever goes wrong while checking the token (missing, expired,
wrong audience, bad signature, ...)
+ // means that the caller is not authenticated
+ policy.onException(JwtSvidException.class,
IllegalArgumentException.class)
+ .handled(true)
+ .setBody(method(RejectionReason.class, "of"))
+ .bean(auditTrail, "record(${routeId}, null, 'rejected',
${body})")
+ .log(LoggingLevel.WARN, "Rejected request to ${routeId}:
${body}")
+ .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(401))
+ .setHeader(Exchange.CONTENT_TYPE, constant("text/plain"))
+ .setBody(simple("401 Unauthorized: ${body}"))
+ .removeHeaders("CamelSpiffe*");
+
+ // runs before the first step of every route that uses this policy
+ policy.interceptFrom()
+ // 1. authentication: the token must be a JWT-SVID minted for
this service (the audience), signed by
+ // the trust domain and still valid. The SPIRE agent checks
all of that: the message body becomes
+ // the validated io.spiffe.svid.jwtsvid.JwtSvid and the
SPIFFE ID of the caller is set as header
+ .setHeader(SpiffeConstants.TOKEN).method(BearerToken.class,
"extract")
+ .removeHeader("Authorization")
+ .to("spiffe:" + service +
"?operation=validateJwtSvid&audience={{" + service + ".audience}}")
+ // 2. authorization: the caller must be on the allow-list of
the route
+ .choice()
+ .when(method(allowList, "isAllowed(${routeId},
${header.CamelSpiffeSpiffeId})"))
+ .bean(auditTrail, "record(${routeId},
${header.CamelSpiffeSpiffeId}, 'allowed', null)")
+ .log("Authenticated caller
${header.CamelSpiffeSpiffeId}, allowed to call ${routeId}")
+ .otherwise()
+ .bean(auditTrail, "record(${routeId},
${header.CamelSpiffeSpiffeId}, 'denied', null)")
+ .log(LoggingLevel.WARN,
+ "Authenticated caller
${header.CamelSpiffeSpiffeId} is not allowed to call ${routeId}")
+ .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(403))
+ .setHeader(Exchange.CONTENT_TYPE,
constant("text/plain"))
+ .setBody(simple("403 Forbidden:
${header.CamelSpiffeSpiffeId} is not allowed to call ${routeId}"))
+ .removeHeaders("CamelSpiffe*")
+ // the route itself does not run
+ .stop()
+ .end();
+ }
+}
diff --git a/spiffe/src/main/resources/application.properties
b/spiffe/src/main/resources/application.properties
new file mode 100644
index 00000000..776b1c07
--- /dev/null
+++ b/spiffe/src/main/resources/application.properties
@@ -0,0 +1,52 @@
+## ---------------------------------------------------------------------------
+## 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.
+## ---------------------------------------------------------------------------
+
+# here you can configure options on camel main
+# https://camel.apache.org/components/next/others/main.html
+# compose.yaml overrides the name of each application with the CAMEL_MAIN_NAME
environment variable
+camel.main.name = camel-spiffe
+
+# The SPIFFE component connects to the SPIFFE Workload API of the local SPIRE
agent. When the option below is not
+# set, the address is taken from the SPIFFE_ENDPOINT_SOCKET environment
variable (which is what compose.yaml does).
+#camel.component.spiffe.spiffe-socket-path =
unix:///run/spire/sockets/agent.sock
+
+# The SPIFFE IDs of the services that expose an HTTP API. A caller asks for a
JWT-SVID with the service as
+# audience, and the service only accepts tokens that were minted for it
+backend.audience = spiffe://example.org/backend
+inventory.audience = spiffe://example.org/inventory
+
+# Who may call what: the SPIFFE IDs (comma separated) allowed on each route,
as <service>.allow.<route id>.
+# A route without an allow-list accepts nobody
+backend.allow.orders = spiffe://example.org/frontend
+backend.allow.audit = spiffe://example.org/auditor
+inventory.allow.stock = spiffe://example.org/backend
+
+# Where the services are found
+backend.host = backend
+backend.port = 8080
+inventory.host = inventory
+inventory.port = 8080
+
+# How often the frontend reads the orders and the audit trail
+frontend.period = 10s
+frontend.audit.period = 30s
+# How often the frontend presents a token minted for another service, to show
that the backend rejects it
+frontend.wrongAudience.period = 45s
+frontend.wrongAudience = spiffe://example.org/some-other-service
+
+# How often the applications log their X.509-SVID
+identity.period = 60s
diff --git a/spiffe/src/main/resources/log4j2.properties
b/spiffe/src/main/resources/log4j2.properties
new file mode 100644
index 00000000..fd23b980
--- /dev/null
+++ b/spiffe/src/main/resources/log4j2.properties
@@ -0,0 +1,23 @@
+## ---------------------------------------------------------------------------
+## 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.
+## ---------------------------------------------------------------------------
+
+appender.out.type = Console
+appender.out.name = out
+appender.out.layout.type = PatternLayout
+appender.out.layout.pattern = %d{HH:mm:ss.SSS} [%-20.20t] %-28.28c{1} %-5p %m%n
+rootLogger.level = INFO
+rootLogger.appenderRef.out.ref = out
diff --git
a/spiffe/src/test/java/org/apache/camel/example/spiffe/IdentityRoutesTest.java
b/spiffe/src/test/java/org/apache/camel/example/spiffe/IdentityRoutesTest.java
new file mode 100644
index 00000000..74d6cad6
--- /dev/null
+++
b/spiffe/src/test/java/org/apache/camel/example/spiffe/IdentityRoutesTest.java
@@ -0,0 +1,103 @@
+/*
+ * 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.io.InputStream;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.List;
+
+import io.spiffe.exception.X509ContextException;
+import io.spiffe.spiffeid.SpiffeId;
+import io.spiffe.svid.x509svid.X509Svid;
+import io.spiffe.workloadapi.WorkloadApiClient;
+import io.spiffe.workloadapi.X509Context;
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+import org.apache.camel.main.MainConfigurationProperties;
+import org.apache.camel.test.main.junit6.CamelMainTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests the identity route against a fake SPIFFE Workload API that hands out
a self-signed certificate with the SPIFFE
+ * ID of the frontend as URI subject alternative name (see
src/test/resources/frontend-svid.pem).
+ */
+class IdentityRoutesTest extends CamelMainTestSupport {
+
+ @BindToRegistry
+ private final WorkloadApiClient workloadApiClient =
mock(WorkloadApiClient.class);
+
+ @Override
+ protected void configure(MainConfigurationProperties configuration) {
+ configuration.addRoutesBuilder(IdentityRoutes.class);
+ }
+
+ @Override
+ public void setupResources() throws Exception {
+ // trigger the route by hand instead of waiting for the timer
+ camelContextConfiguration().replaceRouteFromWith("identity",
"direct:identity");
+ super.setupResources();
+ }
+
+ @Test
+ void describesTheX509SvidWithoutRevealingTheKey() throws Exception {
+ X509Certificate leaf = loadCertificate("/frontend-svid.pem");
+ X509Svid svid = mock(X509Svid.class);
+
when(svid.getSpiffeId()).thenReturn(SpiffeId.parse("spiffe://example.org/frontend"));
+ when(svid.getLeaf()).thenReturn(leaf);
+ when(svid.getChain()).thenReturn(List.of(leaf));
+ X509Context x509Context = mock(X509Context.class);
+ when(x509Context.getDefaultSvid()).thenReturn(svid);
+ when(workloadApiClient.fetchX509Context()).thenReturn(x509Context);
+
+ Exchange out = template.request("direct:identity", exchange -> {
+ });
+
+ String summary = out.getMessage().getBody(String.class);
+ assertTrue(summary.startsWith("X.509-SVID of
spiffe://example.org/frontend"), summary);
+ assertTrue(summary.contains("subject : CN=frontend, O=Apache
Camel, C=US"), summary);
+ assertTrue(summary.contains("URI SANs :
[spiffe://example.org/frontend]"), summary);
+ assertTrue(summary.contains("chain length : 1 certificate(s)"),
summary);
+ assertEquals("spiffe://example.org/frontend",
out.getMessage().getHeader(SpiffeConstants.SPIFFE_ID));
+ verify(svid, never()).getPrivateKey();
+ }
+
+ @Test
+ void failuresAreLoggedAndDoNotStopTheRoute() throws Exception {
+ when(workloadApiClient.fetchX509Context()).thenThrow(new
X509ContextException("no identity issued"));
+
+ Exchange out = template.request("direct:identity", exchange -> {
+ });
+
+ assertFalse(out.isFailed(), "the failure is handled by the route");
+ }
+
+ private static X509Certificate loadCertificate(String resource) throws
Exception {
+ try (InputStream in =
IdentityRoutesTest.class.getResourceAsStream(resource)) {
+ return (X509Certificate)
CertificateFactory.getInstance("X.509").generateCertificate(in);
+ }
+ }
+}
diff --git
a/spiffe/src/test/java/org/apache/camel/example/spiffe/backend/BackendRoutesTest.java
b/spiffe/src/test/java/org/apache/camel/example/spiffe/backend/BackendRoutesTest.java
new file mode 100644
index 00000000..3a869839
--- /dev/null
+++
b/spiffe/src/test/java/org/apache/camel/example/spiffe/backend/BackendRoutesTest.java
@@ -0,0 +1,222 @@
+/*
+ * 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.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Date;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.ConcurrentHashMap;
+
+import io.spiffe.exception.JwtSvidException;
+import io.spiffe.spiffeid.SpiffeId;
+import io.spiffe.svid.jwtsvid.JwtSvid;
+import io.spiffe.workloadapi.WorkloadApiClient;
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy;
+import org.apache.camel.main.MainConfigurationProperties;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.junit6.CamelContextConfiguration;
+import org.apache.camel.test.main.junit6.CamelMainTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.camel.util.PropertiesHelper.asProperties;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests the backend over HTTP, on the embedded server of Camel Main, against
a fake SPIFFE Workload API and a stub of
+ * the inventory service. The spiffe component autowires the single {@link
WorkloadApiClient} it finds in the
+ * registry, so the routes and the policy under test are exactly the ones used
at runtime: only the SPIRE agent is
+ * replaced.
+ */
+class BackendRoutesTest extends CamelMainTestSupport {
+
+ private static final String BACKEND = "spiffe://example.org/backend";
+ private static final String INVENTORY = "spiffe://example.org/inventory";
+ private static final String FRONTEND = "spiffe://example.org/frontend";
+ private static final String AUDITOR = "spiffe://example.org/auditor";
+ private static final String STOCK_LEVELS
+ = "{\"Camel in Action, 2nd edition\":12,\"Enterprise Integration
Patterns\":0,\"Zero Trust Networks\":5}";
+
+ // static, because configureContext() runs in the constructor of
CamelTestSupport, before the instance
+ // fields are initialized
+ private static final int PORT = AvailablePortFinder.getNextAvailable();
+ private static final HttpClient HTTP = HttpClient.newHttpClient();
+ private final WorkloadIdentityPolicy policy = new
WorkloadIdentityPolicy("backend");
+ /** The headers of the last request received by the stub inventory. */
+ private final Map<String, Object> inventoryRequestHeaders = new
ConcurrentHashMap<>();
+
+ @BindToRegistry
+ private final WorkloadApiClient workloadApiClient =
mock(WorkloadApiClient.class);
+
+ @Override
+ protected void configure(MainConfigurationProperties configuration) {
+ configuration.httpServer().withEnabled(true).withPort(PORT);
+ configuration.addRoutesBuilder(policy);
+ configuration.addRoutesBuilder(new
BackendRoutes(policy.getAuditTrail()));
+ // a stub of the inventory service, on the same embedded server
+ configuration.addRoutesBuilder(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("platform-http:/api/stock").routeId("stub-inventory")
+ .process(exchange -> {
+ inventoryRequestHeaders.clear();
+ exchange.getMessage().getHeaders().forEach((name,
value) -> {
+ if (value != null) {
+ inventoryRequestHeaders.put(name, value);
+ }
+ });
+ })
+ .setHeader(Exchange.CONTENT_TYPE,
constant("application/json"))
+ .setBody(constant(STOCK_LEVELS));
+ }
+ });
+ }
+
+ @Override
+ public void configureContext(CamelContextConfiguration
camelContextConfiguration) {
+ super.configureContext(camelContextConfiguration);
+ Properties overrides = asProperties("inventory.host", "localhost",
"inventory.port", Integer.toString(PORT));
+
camelContextConfiguration.withUseOverridePropertiesWithPropertiesComponent(overrides);
+ }
+
+ @Test
+ void frontendGetsTheOrdersWithTheStockLevels() throws Exception {
+ JwtSvid frontend = jwtSvid(FRONTEND, null);
+ when(workloadApiClient.validateJwtSvid("frontend-token",
BACKEND)).thenReturn(frontend);
+ JwtSvid backend = jwtSvid(BACKEND, "backend-token");
+ when(workloadApiClient.fetchJwtSvid(INVENTORY)).thenReturn(backend);
+
+ HttpResponse<String> response = get("/api/orders", "Bearer
frontend-token");
+
+ assertEquals(200, response.statusCode());
+ String body = response.body();
+
assertTrue(body.contains("\"caller\":\"spiffe://example.org/frontend\""), body);
+ assertTrue(body.contains("\"item\":\"Camel in Action, 2nd
edition\",\"quantity\":2,\"inStock\":true"), body);
+ assertTrue(body.contains("\"item\":\"Enterprise Integration
Patterns\",\"quantity\":1,\"inStock\":false"), body);
+
+ // the second hop was made with the identity of the backend, on behalf
of the frontend
+ assertEquals("Bearer backend-token",
inventoryRequestHeaders.get("Authorization"));
+ assertEquals(FRONTEND, inventoryRequestHeaders.get("X-On-Behalf-Of"));
+
+ // and nothing of it leaks back to the caller
+ for (String header : new String[] {
+ "Authorization", "X-On-Behalf-Of", SpiffeConstants.TOKEN,
SpiffeConstants.SPIFFE_ID }) {
+ assertTrue(response.headers().firstValue(header).isEmpty(), header
+ " must not be in the response");
+ }
+ }
+
+ @Test
+ void auditorMayNotReadTheOrders() throws Exception {
+ JwtSvid auditor = jwtSvid(AUDITOR, null);
+ when(workloadApiClient.validateJwtSvid("auditor-token",
BACKEND)).thenReturn(auditor);
+
+ HttpResponse<String> response = get("/api/orders", "Bearer
auditor-token");
+
+ assertEquals(403, response.statusCode());
+ assertEquals("403 Forbidden: spiffe://example.org/auditor is not
allowed to call orders",
+ response.body());
+ }
+
+ @Test
+ void auditorReadsTheAuditTrail() throws Exception {
+ JwtSvid auditor = jwtSvid(AUDITOR, null);
+ when(workloadApiClient.validateJwtSvid("auditor-token",
BACKEND)).thenReturn(auditor);
+
+ // a denied call first, so that there is something to audit
+ get("/api/orders", "Bearer auditor-token");
+ HttpResponse<String> response = get("/api/audit", "Bearer
auditor-token");
+
+ assertEquals(200, response.statusCode());
+ String body = response.body();
+
assertTrue(body.startsWith("{\"service\":\"backend\",\"decisions\":["), body);
+
assertTrue(body.contains("\"route\":\"orders\",\"caller\":\"spiffe://example.org/auditor\",\"outcome\":\"denied\""),
+ body);
+
assertTrue(body.contains("\"route\":\"audit\",\"caller\":\"spiffe://example.org/auditor\",\"outcome\":\"allowed\""),
+ body);
+ }
+
+ @Test
+ void frontendMayNotReadTheAuditTrail() throws Exception {
+ JwtSvid frontend = jwtSvid(FRONTEND, null);
+ when(workloadApiClient.validateJwtSvid("frontend-token",
BACKEND)).thenReturn(frontend);
+
+ HttpResponse<String> response = get("/api/audit", "Bearer
frontend-token");
+
+ assertEquals(403, response.statusCode());
+ }
+
+ @Test
+ void invalidTokenIsUnauthorized() throws Exception {
+ // this is how the java-spiffe library reports a token that the
Workload API refused
+ when(workloadApiClient.validateJwtSvid("token-for-another-service",
BACKEND))
+ .thenThrow(new JwtSvidException("Error validating JWT SVID",
+ new IllegalStateException("expected audience in
[spiffe://example.org/backend]")));
+
+ HttpResponse<String> response = get("/api/orders", "Bearer
token-for-another-service");
+
+ assertEquals(401, response.statusCode());
+ assertEquals("401 Unauthorized: Error validating JWT SVID: expected
audience in [spiffe://example.org/backend]",
+ response.body());
+ }
+
+ @Test
+ void missingTokenIsUnauthorized() throws Exception {
+ HttpResponse<String> response = get("/api/orders", null);
+
+ assertEquals(401, response.statusCode());
+ assertEquals("401 Unauthorized: no bearer token in the Authorization
header",
+ response.body());
+ }
+
+ @Test
+ void unreachableInventoryIsABadGateway() throws Exception {
+ JwtSvid frontend = jwtSvid(FRONTEND, null);
+ when(workloadApiClient.validateJwtSvid("frontend-token",
BACKEND)).thenReturn(frontend);
+ when(workloadApiClient.fetchJwtSvid(INVENTORY)).thenThrow(new
JwtSvidException("no identity issued"));
+
+ HttpResponse<String> response = get("/api/orders", "Bearer
frontend-token");
+
+ assertEquals(502, response.statusCode());
+ assertEquals("502 Bad Gateway: no identity issued", response.body());
+ }
+
+ private static HttpResponse<String> get(String path, String authorization)
throws Exception {
+ HttpRequest.Builder request =
HttpRequest.newBuilder(URI.create("http://localhost:" + PORT + path)).GET();
+ if (authorization != null) {
+ request.header("Authorization", authorization);
+ }
+ return HTTP.send(request.build(),
HttpResponse.BodyHandlers.ofString());
+ }
+
+ private static JwtSvid jwtSvid(String spiffeId, String token) {
+ JwtSvid svid = mock(JwtSvid.class);
+ when(svid.getSpiffeId()).thenReturn(SpiffeId.parse(spiffeId));
+ when(svid.getToken()).thenReturn(token);
+ when(svid.getExpiry()).thenReturn(new Date(System.currentTimeMillis()
+ 300_000));
+ return svid;
+ }
+}
diff --git
a/spiffe/src/test/java/org/apache/camel/example/spiffe/frontend/FrontendRoutesTest.java
b/spiffe/src/test/java/org/apache/camel/example/spiffe/frontend/FrontendRoutesTest.java
new file mode 100644
index 00000000..d3174250
--- /dev/null
+++
b/spiffe/src/test/java/org/apache/camel/example/spiffe/frontend/FrontendRoutesTest.java
@@ -0,0 +1,143 @@
+/*
+ * 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.frontend;
+
+import java.util.Date;
+
+import io.spiffe.exception.JwtSvidException;
+import io.spiffe.spiffeid.SpiffeId;
+import io.spiffe.svid.jwtsvid.JwtSvid;
+import io.spiffe.workloadapi.WorkloadApiClient;
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.AdviceWith;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+import org.apache.camel.main.MainConfigurationProperties;
+import org.apache.camel.test.main.junit6.CamelMainTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests the frontend routes against a fake SPIFFE Workload API (a mocked
{@link WorkloadApiClient} that the spiffe
+ * component autowires from the registry) and a mock endpoint in place of the
backend.
+ */
+class FrontendRoutesTest extends CamelMainTestSupport {
+
+ private static final String BACKEND = "spiffe://example.org/backend";
+
+ @BindToRegistry
+ private final WorkloadApiClient workloadApiClient =
mock(WorkloadApiClient.class);
+
+ @Override
+ protected void configure(MainConfigurationProperties configuration) {
+ configuration.addRoutesBuilder(FrontendRoutes.class);
+ }
+
+ @Override
+ public void setupResources() throws Exception {
+ // trigger the routes by hand instead of waiting for the timers
+ camelContextConfiguration().replaceRouteFromWith("orders",
"direct:orders");
+ camelContextConfiguration().replaceRouteFromWith("audit",
"direct:audit");
+ camelContextConfiguration().replaceRouteFromWith("wrong-audience",
"direct:wrongAudience");
+ super.setupResources();
+ }
+
+ @Override
+ public boolean isUseAdviceWith() {
+ // the routes are advised before the context is started, to swap the
real backend for a mock endpoint
+ return true;
+ }
+
+ @Test
+ void readsTheOrdersWithAJwtSvidAsBearerToken() throws Exception {
+ MockEndpoint backend = mockTheBackend();
+ JwtSvid svid = jwtSvid("frontend-token");
+ when(workloadApiClient.fetchJwtSvid(BACKEND)).thenReturn(svid);
+
+ backend.expectedMessageCount(1);
+ backend.expectedHeaderReceived(Exchange.HTTP_PATH, "/api/orders");
+ backend.expectedHeaderReceived("Authorization", "Bearer
frontend-token");
+ backend.message(0).body().isNull();
+ backend.message(0).header(SpiffeConstants.SPIFFE_ID).isNull();
+ backend.message(0).header(SpiffeConstants.EXPIRY).isNull();
+
+ template.sendBody("direct:orders", null);
+
+ backend.assertIsSatisfied();
+ }
+
+ @Test
+ void readsTheAuditTrail() throws Exception {
+ MockEndpoint backend = mockTheBackend();
+ JwtSvid svid = jwtSvid("frontend-token");
+ when(workloadApiClient.fetchJwtSvid(BACKEND)).thenReturn(svid);
+
+ backend.expectedMessageCount(1);
+ backend.expectedHeaderReceived(Exchange.HTTP_PATH, "/api/audit");
+ backend.expectedHeaderReceived("Authorization", "Bearer
frontend-token");
+
+ template.sendBody("direct:audit", null);
+
+ backend.assertIsSatisfied();
+ }
+
+ @Test
+ void asksForATokenWithTheWrongAudienceOnPurpose() throws Exception {
+ MockEndpoint backend = mockTheBackend();
+ JwtSvid svid = jwtSvid("token-for-another-service");
+
when(workloadApiClient.fetchJwtSvid("spiffe://example.org/some-other-service")).thenReturn(svid);
+
+ backend.expectedMessageCount(1);
+ backend.expectedHeaderReceived("Authorization", "Bearer
token-for-another-service");
+ backend.message(0).header(SpiffeConstants.AUDIENCE).isNull();
+
+ template.sendBody("direct:wrongAudience", null);
+
+ backend.assertIsSatisfied();
+ }
+
+ @Test
+ void failuresAreLoggedAndDoNotStopTheRoute() throws Exception {
+ MockEndpoint backend = mockTheBackend();
+ when(workloadApiClient.fetchJwtSvid(BACKEND)).thenThrow(new
JwtSvidException("no identity issued"));
+
+ backend.expectedMessageCount(0);
+
+ // the failure is handled by the route, so it does not propagate to
the caller
+ template.sendBody("direct:orders", null);
+
+ backend.assertIsSatisfied();
+ }
+
+ private MockEndpoint mockTheBackend() throws Exception {
+ AdviceWith.adviceWith(context, "call-backend",
+ advice ->
advice.weaveByToUri("http:*").replace().to("mock:backend"));
+ context.start();
+ return getMockEndpoint("mock:backend");
+ }
+
+ private static JwtSvid jwtSvid(String token) {
+ JwtSvid svid = mock(JwtSvid.class);
+ when(svid.getToken()).thenReturn(token);
+
when(svid.getSpiffeId()).thenReturn(SpiffeId.parse("spiffe://example.org/frontend"));
+ when(svid.getExpiry()).thenReturn(new Date(System.currentTimeMillis()
+ 300_000));
+ return svid;
+ }
+}
diff --git
a/spiffe/src/test/java/org/apache/camel/example/spiffe/inventory/InventoryRoutesTest.java
b/spiffe/src/test/java/org/apache/camel/example/spiffe/inventory/InventoryRoutesTest.java
new file mode 100644
index 00000000..9eaa4280
--- /dev/null
+++
b/spiffe/src/test/java/org/apache/camel/example/spiffe/inventory/InventoryRoutesTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.inventory;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Date;
+
+import io.spiffe.spiffeid.SpiffeId;
+import io.spiffe.svid.jwtsvid.JwtSvid;
+import io.spiffe.workloadapi.WorkloadApiClient;
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy;
+import org.apache.camel.main.MainConfigurationProperties;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.main.junit6.CamelMainTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests the inventory over HTTP, on the embedded server of Camel Main,
against a fake SPIFFE Workload API.
+ */
+class InventoryRoutesTest extends CamelMainTestSupport {
+
+ private static final String INVENTORY = "spiffe://example.org/inventory";
+
+ // static, because configureContext() runs in the constructor of
CamelTestSupport, before the instance
+ // fields are initialized
+ private static final int PORT = AvailablePortFinder.getNextAvailable();
+ private static final HttpClient HTTP = HttpClient.newHttpClient();
+
+ @BindToRegistry
+ private final WorkloadApiClient workloadApiClient =
mock(WorkloadApiClient.class);
+
+ @Override
+ protected void configure(MainConfigurationProperties configuration) {
+ configuration.httpServer().withEnabled(true).withPort(PORT);
+ configuration.addRoutesBuilder(new
WorkloadIdentityPolicy("inventory"));
+ configuration.addRoutesBuilder(InventoryRoutes.class);
+ }
+
+ @Test
+ void backendGetsTheStockLevels() throws Exception {
+ JwtSvid backend = jwtSvid("spiffe://example.org/backend");
+ when(workloadApiClient.validateJwtSvid("backend-token",
INVENTORY)).thenReturn(backend);
+
+ HttpResponse<String> response = get("Bearer backend-token",
"spiffe://example.org/frontend");
+
+ assertEquals(200, response.statusCode());
+ assertEquals("{\"Camel in Action, 2nd edition\":12,\"Enterprise
Integration Patterns\":0,\"Zero Trust Networks\":5}",
+ response.body());
+ }
+
+ @Test
+ void frontendMayNotAskTheInventoryDirectly() throws Exception {
+ JwtSvid frontend = jwtSvid("spiffe://example.org/frontend");
+ when(workloadApiClient.validateJwtSvid("frontend-token",
INVENTORY)).thenReturn(frontend);
+
+ HttpResponse<String> response = get("Bearer frontend-token", null);
+
+ assertEquals(403, response.statusCode());
+ assertEquals("403 Forbidden: spiffe://example.org/frontend is not
allowed to call stock",
+ response.body());
+ }
+
+ @Test
+ void missingTokenIsUnauthorized() throws Exception {
+ HttpResponse<String> response = get(null, null);
+
+ assertEquals(401, response.statusCode());
+ }
+
+ private static HttpResponse<String> get(String authorization, String
onBehalfOf) throws Exception {
+ HttpRequest.Builder request =
HttpRequest.newBuilder(URI.create("http://localhost:" + PORT +
"/api/stock")).GET();
+ if (authorization != null) {
+ request.header("Authorization", authorization);
+ }
+ if (onBehalfOf != null) {
+ request.header("X-On-Behalf-Of", onBehalfOf);
+ }
+ return HTTP.send(request.build(),
HttpResponse.BodyHandlers.ofString());
+ }
+
+ private static JwtSvid jwtSvid(String spiffeId) {
+ JwtSvid svid = mock(JwtSvid.class);
+ when(svid.getSpiffeId()).thenReturn(SpiffeId.parse(spiffeId));
+ when(svid.getExpiry()).thenReturn(new Date(System.currentTimeMillis()
+ 300_000));
+ return svid;
+ }
+}
diff --git a/spiffe/src/test/resources/frontend-svid.pem
b/spiffe/src/test/resources/frontend-svid.pem
new file mode 100644
index 00000000..63f00190
--- /dev/null
+++ b/spiffe/src/test/resources/frontend-svid.pem
@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIIB8DCCAZWgAwIBAgIUUEZwu9rNbF2gLwWYkEzaS6w2Uw0wCgYIKoZIzj0EAwIw
+NzELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEFwYWNoZSBDYW1lbDERMA8GA1UEAwwI
+ZnJvbnRlbmQwIBcNMjYwOTAzMTAxNDI5WhgPMjEyNjA4MTAxMDE0MjlaMDcxCzAJ
+BgNVBAYTAlVTMRUwEwYDVQQKDAxBcGFjaGUgQ2FtZWwxETAPBgNVBAMMCGZyb250
+ZW5kMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE+8BNBh69yOHdjyBxMc7yR0Xj
+YtkN+EM8rKJg0b4zJzVyeFaFv7fzzWdb1abTt3bxrQq0GzVCW3Jpp9fmYyeulKN9
+MHswHQYDVR0OBBYEFOdgdZQ/sgnlVO1ZJfOxn+YP5w3bMB8GA1UdIwQYMBaAFOdg
+dZQ/sgnlVO1ZJfOxn+YP5w3bMA8GA1UdEwEB/wQFMAMBAf8wKAYDVR0RBCEwH4Yd
+c3BpZmZlOi8vZXhhbXBsZS5vcmcvZnJvbnRlbmQwCgYIKoZIzj0EAwIDSQAwRgIh
+ALEaw2LsIBOjDCRb7z0bSqe8eui/yC7gRsK3Xbf0XvbyAiEA6aJRod4tvQ8lzlfv
+KM21qw/vVWfvj7fFtE44mcBOcuA=
+-----END CERTIFICATE-----