diff --git a/README.adoc b/README.adoc index 075bc2b94..e8bab9e0b 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 3c943585c..8a66ae1d4 100644 --- a/pom.xml +++ b/pom.xml @@ -127,6 +127,7 @@ routetemplate-xml routes-configuration salesforce-consumer + spiffe spring spring-pulsar spring-xquery diff --git a/spiffe/README.adoc b/spiffe/README.adoc new file mode 100644 index 000000000..23f5fb153 --- /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: + +-------------------+---------------+ +-----------------------+ + 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 + <>: 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 +`.allow.` in the configuration, and `AuditTrail` keeps the last decisions of the policy, which +the backend exposes on `/api/audit`. + +=== The Camel routes + +* The backend (`BackendRoutes`) serves the orders, and makes the second hop with its own identity: +`fetchJwtSvid` with the inventory as audience. The caller it is serving travels along in a header, for the audit +trail of the inventory, which trusts that header only because the backend itself is authenticated and allowed to +call it. Nothing of the second hop is sent back to the caller. ++ +[source,java] +---- +from("platform-http:/api/orders?httpMethodRestrict=GET").routeId("orders") + .routeConfigurationId(WorkloadIdentityPolicy.ID) + // the policy has authenticated and authorized the caller by the time the route starts + .bean(OrderService.class, "listOrders") + .setProperty("orders", body()) + .setProperty("onBehalfOf", header(SpiffeConstants.SPIFFE_ID)) + .to("direct:stockLevels") + .bean(OrderService.class, "withStock") + .marshal().json() + .removeHeaders("CamelSpiffe*"); + +from("direct:stockLevels").routeId("stock-levels") + .to("spiffe:backend?operation=fetchJwtSvid&audience={{inventory.audience}}") + .setHeader("Authorization", simple("Bearer ${body}")) + .setHeader("X-On-Behalf-Of", exchangeProperty("onBehalfOf")) + .setBody(simple("${null}")) + .removeHeaders("CamelHttp*") + .removeHeaders("CamelSpiffe*") + .to("http://{{inventory.host}}:{{inventory.port}}/api/stock?httpMethod=GET") + .removeHeader("Authorization") + .removeHeader("X-On-Behalf-Of") + .unmarshal().json(Map.class); +---- + +* The inventory (`InventoryRoutes`) is the same policy applied to another service: ++ +[source,java] +---- +from("platform-http:/api/stock?httpMethodRestrict=GET").routeId("stock") + .routeConfigurationId(WorkloadIdentityPolicy.ID) + .log("Serving the stock levels to ${header.CamelSpiffeSpiffeId} on behalf of ${header.X-On-Behalf-Of}") + .bean(StockService.class, "levels") + .marshal().json() + .removeHeaders("CamelSpiffe*"); +---- + +* The frontend (`FrontendRoutes`) gets its tokens with `fetchJwtSvid`: the message body becomes the token, the +SPIFFE ID and the expiry of the token are set as the `CamelSpiffeSpiffeId` and `CamelSpiffeExpiry` headers. The +timers only set the path to call; the `wrong-audience` route also sets the `CamelSpiffeAudience` header, which +overrides the audience of the endpoint for that message. ++ +[source,java] +---- +from("timer:orders?period={{frontend.period}}").routeId("orders") + .setHeader(Exchange.HTTP_PATH, constant("/api/orders")) + .to("direct:callBackend"); + +from("direct:callBackend").routeId("call-backend") + .to("spiffe:frontend?operation=fetchJwtSvid&audience={{backend.audience}}") + .log("Fetched a JWT-SVID for ${header.CamelSpiffeSpiffeId} (valid until ${header.CamelSpiffeExpiry})") + .setHeader("Authorization", simple("Bearer ${body}")) + .setBody(simple("${null}")) + .removeHeaders("CamelSpiffe*") + .to("http://{{backend.host}}:{{backend.port}}?httpMethod=GET&throwExceptionOnFailure=false") + .log("GET ${header.CamelHttpPath} answered HTTP ${header.CamelHttpResponseCode}: ${body}"); +---- + +* All applications run `IdentityRoutes`, where `fetchX509Svid` makes the message body an + `io.spiffe.svid.x509svid.X509Svid` with the certificate chain, the private key and the SPIFFE ID of the + workload. `X509SvidSummary` describes the leaf certificate, and only the certificate: the private key is never + logged. + +The component finds the Workload API through the standard `SPIFFE_ENDPOINT_SOCKET` environment variable, which +`compose.yaml` sets for each application. The `camel.component.spiffe.spiffe-socket-path` option in +`application.properties` (commented out) does the same from the configuration. + +=== Build + +The example is built with Maven: + +[source,sh] +---- +$ mvn package +---- + +This also runs the unit tests, which do not need SPIRE (see below), and copies the runtime dependencies to +`target/lib`, from where `src/main/docker/Dockerfile` picks them up. + +=== How to run + +You need Docker with Docker Compose. Build the images and start everything with: + +[source,sh] +---- +$ docker compose up --build +---- + +The `spire` container starts a SPIRE server, registers the four workloads, then starts a SPIRE agent that joins +the server with a one-time token. Once the agent serves the Workload API, the inventory and the backend start, then +the frontend and the auditor. Within a few seconds the logs show the frontend getting the orders with their stock +levels, the inventory serving the backend on behalf of the frontend, the auditor being turned away from the orders +but reading the audit trail and, now and then, the frontend being rejected when it presents a token minted for +another audience: + +---- +spire-1 | Registering spiffe://example.org/frontend for the workload running with uid 1001 +spire-1 | Registering spiffe://example.org/backend for the workload running with uid 1002 +spire-1 | Registering spiffe://example.org/auditor for the workload running with uid 1003 +spire-1 | Registering spiffe://example.org/inventory for the workload running with uid 1004 +spire-1 | time="..." level=info msg="Node attestation was successful" ... +frontend-1 | 17:27:23.6 [timer://orders] call-backend INFO Fetched a JWT-SVID for spiffe://example.org/frontend (valid until ...) +backend-1 | 17:27:23.7 [worker-thread-1] orders INFO Authenticated caller spiffe://example.org/frontend, allowed to call orders +inventory-1 | 17:27:23.9 [worker-thread-0] stock INFO Authenticated caller spiffe://example.org/backend, allowed to call stock +inventory-1 | 17:27:23.9 [worker-thread-0] stock INFO Serving the stock levels to spiffe://example.org/backend on behalf of spiffe://example.org/frontend +frontend-1 | 17:27:24.0 [timer://orders] call-backend INFO GET /api/orders answered HTTP 200: {"caller":"spiffe://example.org/frontend","orders":[{"id":1001,"item":"Camel in Action, 2nd edition","quantity":2,"inStock":true},{"id":1002,"item":"Enterprise Integration Patterns","quantity":1,"inStock":false},... +backend-1 | 17:27:23.7 [worker-thread-0] orders WARN Authenticated caller spiffe://example.org/auditor is not allowed to call orders +auditor-1 | 17:27:23.7 [timer://orders] call-backend INFO GET /api/orders answered HTTP 403: 403 Forbidden: spiffe://example.org/auditor is not allowed to call orders +auditor-1 | 17:27:52.3 [timer://audit] call-backend INFO GET /api/audit answered HTTP 200: {"service":"backend","decisions":[{"time":"2026-09-03T17:27:23Z","route":"orders","caller":"spiffe://example.org/auditor","outcome":"denied"},{"time":"2026-09-03T17:27:23Z","route":"orders","caller":"spiffe://example.org/frontend","outcome":"allowed"},... +frontend-1 | 17:27:52.3 [timer://audit] call-backend INFO GET /api/audit answered HTTP 403: 403 Forbidden: spiffe://example.org/frontend is not allowed to call audit +frontend-1 | 17:28:07.3 [timer://wrongAudience] wrong-audience INFO Asking for a JWT-SVID with the wrong audience, the backend should reject it +backend-1 | 17:28:07.3 [worker-thread-10] orders WARN Rejected request to orders: Error validating JWT SVID: INVALID_ARGUMENT: expected audience in ["spiffe://example.org/backend"] (audience=["spiffe://example.org/some-other-service"]) +frontend-1 | 17:28:07.3 [timer://wrongAudience] call-backend INFO GET /api/orders answered HTTP 401: 401 Unauthorized: Error validating JWT SVID: INVALID_ARGUMENT: expected audience in ["spiffe://example.org/backend"] (audience=["spiffe://example.org/some-other-service"]) +inventory-1 | 17:28:22.3 [timer://identity] identity INFO X.509-SVID of spiffe://example.org/inventory +inventory-1 | serial number : 1b9d0f4c7d0e6d2a5f7c3b1e9a8d6c4f +inventory-1 | subject : O=SPIRE, C=US +inventory-1 | issuer : SERIALNUMBER=..., CN=example.org, O=Apache Camel, C=US +inventory-1 | valid from : 2026-09-03T17:27:12Z +inventory-1 | valid until : 2026-09-03T17:37:22Z +inventory-1 | URI SANs : [spiffe://example.org/inventory] +inventory-1 | chain length : 1 certificate(s) +---- + +A few things to try while it runs: + +* Call the services yourself. You have no SPIFFE identity and no token, so both turn you away (the inventory is + published on port 8081): ++ +[source,sh] +---- +$ curl -i http://localhost:8080/api/orders +HTTP/1.1 401 Unauthorized +... +401 Unauthorized: no bearer token in the Authorization header + +$ curl -i http://localhost:8081/api/stock +HTTP/1.1 401 Unauthorized +... +---- + +* Look at the registration entries, that is, at who is who in the trust domain: ++ +[source,sh] +---- +$ docker compose exec spire /opt/spire/bin/spire-server entry show +---- + +* Watch the X.509-SVID of an application being rotated. The SPIRE agent renews a certificate halfway through its + lifetime, so about every five minutes the serial number and the validity period in the summary change, while the + SPIFFE ID stays the same: ++ +[source,sh] +---- +$ docker compose logs -f frontend | grep -A 8 "X.509-SVID of" +---- + +* Read why a token was rejected from the point of view of the SPIRE agent, which is what actually validates it: ++ +[source,sh] +---- +$ docker compose logs spire | grep "Failed to validate JWT" +---- + +* Change who may do what: add `spiffe://example.org/auditor` to `backend.allow.orders` in + `src/main/resources/application.properties`, then rebuild and restart the backend with + `mvn package -DskipTests && docker compose up --build -d backend`. Nothing changes in the auditor, yet its next + call gets the orders back. Or add `spiffe://example.org/frontend` to `inventory.allow.stock` and see that the + frontend still cannot call the inventory: its tokens are minted for the backend, not for the inventory. + +Stop everything, and remove the containers and the volume with the Workload API socket, with: + +[source,sh] +---- +$ docker compose down -v +---- + +NOTE: The `spire` container is a shortcut for the purpose of this example: it runs the SPIRE server and the SPIRE +agent side by side and as root, and the Camel applications share its PID namespace so that the agent can attest +them with the `unix` workload attestor. A real deployment runs the server on its own, one agent per node and, +on Kubernetes, attests the workloads by their pod and service account instead of their Unix user. + +=== Running the tests + +[source,sh] +---- +$ mvn test +---- + +The tests do not need SPIRE. Each test class binds a Mockito mock of `io.spiffe.workloadapi.WorkloadApiClient` to +the Camel registry with `@BindToRegistry`, and the SPIFFE component autowires the single client it finds there (its +`workloadApiClient` option). The routes under test are therefore exactly the ones that run in the containers, only +the SPIRE agent is replaced by a fake that mints, validates or refuses SVIDs as the test needs. The backend and the +inventory are tested over HTTP, on the embedded server of Camel Main, so the policy runs exactly as in the +containers; the backend test also stubs the inventory on that server to check the second hop. + +=== Running the applications outside Docker + +The applications can also run directly on your machine with `mvn camel:run`, as long as a SPIRE agent (or any other +SPIFFE Workload API) is reachable and has a registration entry for the process that runs them: + +[source,sh] +---- +$ export SPIFFE_ENDPOINT_SOCKET=unix:///tmp/spire-agent/public/api.sock +$ mvn camel:run -Dcamel.server.port=8081 -Dcamel.mainClass=org.apache.camel.example.spiffe.inventory.InventoryApplication +$ mvn camel:run -Dinventory.host=localhost -Dinventory.port=8081 +$ mvn camel:run -Dbackend.host=localhost -Dcamel.mainClass=org.apache.camel.example.spiffe.frontend.FrontendApplication +---- + +Keep in mind that all the applications then run as the same Unix user, hence with the same SPIFFE ID, unless the +Workload API you use attests them differently. + +The Workload API is a gRPC service on a Unix domain socket, for which the SPIFFE Java library needs a native +transport. The `pom.xml` of the example includes the Linux one (`io.spiffe:grpc-netty-linux`), which is what the +containers need. On macOS replace it with `io.spiffe:grpc-netty-macos` or, on Apple silicon, +`io.spiffe:grpc-netty-macos-aarch64`. + +=== Help and contributions + +If you hit any problem using Camel or have some feedback, then please +https://camel.apache.org/community/support/[let us know]. + +We also love contributors, so +https://camel.apache.org/community/contributing/[get involved] :-) + +The Camel riders! diff --git a/spiffe/compose.yaml b/spiffe/compose.yaml new file mode 100644 index 000000000..c5b250d64 --- /dev/null +++ b/spiffe/compose.yaml @@ -0,0 +1,107 @@ +## Licensed to the Apache Software Foundation (ASF) under one or more +## contributor license agreements. See the NOTICE file distributed with +## this work for additional information regarding copyright ownership. +## The ASF licenses this file to You under the Apache License, Version 2.0 +## (the "License"); you may not use this file except in compliance with +## the License. You may obtain a copy of the License at +## +## http://www.apache.org/licenses/LICENSE-2.0 +## +## Unless required by applicable law or agreed to in writing, software +## distributed under the License is distributed on an "AS IS" BASIS, +## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +## See the License for the specific language governing permissions and +## limitations under the License. + +# everything the three Camel applications have in common: the same image, the SPIFFE Workload API socket of the +# SPIRE agent, and the PID namespace of the SPIRE container (the agent attests a workload by looking up the process +# that connects to the Workload API, so it must be able to see it) +x-camel-application: &camel-application + build: + context: . + dockerfile: src/main/docker/Dockerfile + image: camel-example-spiffe + pid: "service:spire" + volumes: + - spire-sockets:/run/spire/sockets + depends_on: + spire: + condition: service_healthy + +services: + + # SPIRE server and agent in one container (see spire/entrypoint.sh): the server issues the identities, + # the agent attests the workloads and hands them their SVIDs through the SPIFFE Workload API + spire: + build: ./spire + volumes: + - spire-sockets:/run/spire/sockets + healthcheck: + test: ["CMD", "/opt/spire/bin/spire-agent", "healthcheck", "-socketPath", "/run/spire/sockets/agent.sock"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 5s + + # the second hop: an HTTP API that only the backend may call. uid 1004 is registered as + # spiffe://example.org/inventory + inventory: + <<: *camel-application + command: ["org.apache.camel.example.spiffe.inventory.InventoryApplication"] + user: "1004:1004" + environment: + CAMEL_MAIN_NAME: inventory + SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock + ports: + - "8081:8080" + + # the HTTP API the clients talk to, which calls the inventory with its own identity. + # uid 1002 is registered as spiffe://example.org/backend + backend: + <<: *camel-application + command: ["org.apache.camel.example.spiffe.backend.BackendApplication"] + user: "1002:1002" + environment: + CAMEL_MAIN_NAME: backend + SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock + ports: + - "8080:8080" + depends_on: + spire: + condition: service_healthy + inventory: + condition: service_started + + # the client that is allowed to read the orders (but not the audit trail): uid 1001 is registered as + # spiffe://example.org/frontend + frontend: + <<: *camel-application + command: ["org.apache.camel.example.spiffe.frontend.FrontendApplication"] + user: "1001:1001" + environment: + CAMEL_MAIN_NAME: frontend + SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock + depends_on: + spire: + condition: service_healthy + backend: + condition: service_started + + # the very same code and image as the frontend, but another Unix user, hence another identity: uid 1003 is + # registered as spiffe://example.org/auditor, which may read the audit trail but not the orders + auditor: + <<: *camel-application + command: ["org.apache.camel.example.spiffe.frontend.FrontendApplication"] + user: "1003:1003" + environment: + CAMEL_MAIN_NAME: auditor + SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock + depends_on: + spire: + condition: service_healthy + backend: + condition: service_started + +volumes: + # the Unix domain socket of the SPIFFE Workload API, shared between the SPIRE agent and the workloads + spire-sockets: diff --git a/spiffe/pom.xml b/spiffe/pom.xml new file mode 100644 index 000000000..0d5a136c9 --- /dev/null +++ b/spiffe/pom.xml @@ -0,0 +1,160 @@ + + + + + 4.0.0 + + + org.apache.camel.example + camel-examples + 4.23.0-SNAPSHOT + + + camel-example-spiffe + jar + Camel :: Example :: SPIFFE + An example for showing workload identity with the Camel SPIFFE component (JWT-SVID and X.509-SVID issued by SPIRE) + + + Security + SPIFFE + 3.8.1 + + + + + + + org.apache.camel + camel-bom + ${camel.version} + pom + import + + + + + + + + org.apache.camel + camel-core + + + org.apache.camel + camel-main + + + + org.apache.camel + camel-spiffe + + + + io.spiffe + grpc-netty-linux + ${java-spiffe-version} + runtime + + + + org.apache.camel + camel-platform-http-main + + + + org.apache.camel + camel-http + + + org.apache.camel + camel-timer + + + org.apache.camel + camel-jackson + + + + + org.apache.logging.log4j + log4j-core + ${log4j2-version} + runtime + + + org.apache.logging.log4j + log4j-slf4j2-impl + ${log4j2-version} + runtime + + + + + org.apache.camel + camel-test-main-junit6 + test + + + org.mockito + mockito-core + ${mockito-version} + test + + + + + + + + org.apache.camel + camel-maven-plugin + ${camel.version} + + false + org.apache.camel.example.spiffe.backend.BackendApplication + + + + + + org.apache.maven.plugins + maven-dependency-plugin + ${maven-dependency-plugin-version} + + + copy-dependencies + package + + copy-dependencies + + + ${project.build.directory}/lib + runtime + + + + + + + + diff --git a/spiffe/spire/Dockerfile b/spiffe/spire/Dockerfile new file mode 100644 index 000000000..abc6e99ad --- /dev/null +++ b/spiffe/spire/Dockerfile @@ -0,0 +1,32 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# The official SPIRE images are built from scratch and have no shell. This image copies their static binaries +# into a small Alpine image, so that entrypoint.sh can bootstrap a complete SPIRE deployment for the example: +# a server, an agent and the registration entries of the workloads. +FROM ghcr.io/spiffe/spire-server:1.15.3 AS spire-server +FROM ghcr.io/spiffe/spire-agent:1.15.3 AS spire-agent + +FROM alpine:3.22 + +COPY --from=spire-server /opt/spire/bin/spire-server /opt/spire/bin/spire-server +COPY --from=spire-agent /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent +COPY server.conf agent.conf /opt/spire/conf/ +COPY entrypoint.sh /opt/spire/entrypoint.sh + +RUN chmod 0755 /opt/spire/entrypoint.sh \ + && mkdir -p /opt/spire/data/server /opt/spire/data/agent /run/spire/sockets /tmp/spire-server/private + +ENTRYPOINT ["/opt/spire/entrypoint.sh"] diff --git a/spiffe/spire/agent.conf b/spiffe/spire/agent.conf new file mode 100644 index 000000000..a87d2c942 --- /dev/null +++ b/spiffe/spire/agent.conf @@ -0,0 +1,43 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# SPIRE agent configuration, see https://github.com/spiffe/spire/blob/main/doc/spire_agent.md +agent { + data_dir = "/opt/spire/data/agent" + log_level = "INFO" + server_address = "127.0.0.1" + server_port = "8081" + # the SPIFFE Workload API: the Camel applications connect here (SPIFFE_ENDPOINT_SOCKET in compose.yaml) + socket_path = "/run/spire/sockets/agent.sock" + # the CA bundle of the server, exported by entrypoint.sh before the agent starts + trust_bundle_path = "/opt/spire/data/bootstrap.crt" + trust_domain = "example.org" +} + +plugins { + KeyManager "memory" { + plugin_data {} + } + + NodeAttestor "join_token" { + plugin_data {} + } + + # attests the workloads that connect to the Workload API by their Unix user id, group id, and so on. + # The registration entries of this example use the unix:uid selector (see entrypoint.sh) + WorkloadAttestor "unix" { + plugin_data {} + } +} diff --git a/spiffe/spire/entrypoint.sh b/spiffe/spire/entrypoint.sh new file mode 100755 index 000000000..35a82ac34 --- /dev/null +++ b/spiffe/spire/entrypoint.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Bootstraps a complete (single node) SPIRE deployment for the example: +# 1. starts the SPIRE server +# 2. registers the workloads, mapping the Unix user id of each Camel application to its SPIFFE ID +# 3. generates a join token and starts the SPIRE agent with it, which then serves the SPIFFE Workload API +set -e + +SPIRE_BIN=/opt/spire/bin +SERVER_SOCKET=/tmp/spire-server/private/api.sock +TRUST_DOMAIN=example.org +AGENT_ID="spiffe://${TRUST_DOMAIN}/spire-agent" + +# 1. the server issues all the identities of the trust domain +"${SPIRE_BIN}/spire-server" run -config /opt/spire/conf/server.conf & + +echo "Waiting for the SPIRE server to be ready..." +until "${SPIRE_BIN}/spire-server" healthcheck -socketPath "${SERVER_SOCKET}" > /dev/null 2>&1; do + sleep 1 +done + +# the agent verifies the server with the CA bundle of the trust domain +"${SPIRE_BIN}/spire-server" bundle show -socketPath "${SERVER_SOCKET}" > /opt/spire/data/bootstrap.crt + +# 2. a registration entry tells SPIRE which workload (selectors) gets which identity (SPIFFE ID). +# The Camel applications of this example each run as a different Unix user, so the uid is the selector. +register() { + echo "Registering spiffe://${TRUST_DOMAIN}/$1 for the workload running with uid $2" + "${SPIRE_BIN}/spire-server" entry create -socketPath "${SERVER_SOCKET}" \ + -parentID "${AGENT_ID}" \ + -spiffeID "spiffe://${TRUST_DOMAIN}/$1" \ + -selector "unix:uid:$2" +} +register frontend 1001 +register backend 1002 +register auditor 1003 +register inventory 1004 + +# 3. the agent attests to the server with a one-time join token (the -spiffeID option also gives the agent +# the alias AGENT_ID, which the entries above use as parent ID) +TOKEN=$("${SPIRE_BIN}/spire-server" token generate -socketPath "${SERVER_SOCKET}" -spiffeID "${AGENT_ID}" \ + | awk '/^Token:/ { print $2 }') + +exec "${SPIRE_BIN}/spire-agent" run -config /opt/spire/conf/agent.conf -joinToken "${TOKEN}" diff --git a/spiffe/spire/server.conf b/spiffe/spire/server.conf new file mode 100644 index 000000000..065e0d56c --- /dev/null +++ b/spiffe/spire/server.conf @@ -0,0 +1,55 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# SPIRE server configuration, see https://github.com/spiffe/spire/blob/main/doc/spire_server.md +server { + bind_address = "127.0.0.1" + bind_port = "8081" + socket_path = "/tmp/spire-server/private/api.sock" + trust_domain = "example.org" + data_dir = "/opt/spire/data/server" + log_level = "INFO" + + # short lifetimes, so that the example shows the rotation of the SVIDs while you watch the logs + ca_ttl = "24h" + default_x509_svid_ttl = "10m" + default_jwt_svid_ttl = "5m" + + ca_subject { + country = ["US"] + organization = ["Apache Camel"] + common_name = "example.org" + } +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/opt/spire/data/server/datastore.sqlite3" + } + } + + KeyManager "disk" { + plugin_data { + keys_path = "/opt/spire/data/server/keys.json" + } + } + + # the agent joins with a one-time token generated by entrypoint.sh + NodeAttestor "join_token" { + plugin_data {} + } +} diff --git a/spiffe/src/main/docker/Dockerfile b/spiffe/src/main/docker/Dockerfile new file mode 100644 index 000000000..9e3f95858 --- /dev/null +++ b/spiffe/src/main/docker/Dockerfile @@ -0,0 +1,30 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM eclipse-temurin:21-jre + +# each application of the example runs as its own Unix user: the SPIRE agent maps the uid to a SPIFFE ID +RUN useradd --uid 1001 --user-group --no-create-home frontend \ + && useradd --uid 1002 --user-group --no-create-home backend \ + && useradd --uid 1003 --user-group --no-create-home auditor \ + && useradd --uid 1004 --user-group --no-create-home inventory + +COPY target/lib /deployments/lib +COPY target/camel-example-spiffe-*.jar /deployments/lib/ + +WORKDIR /deployments +ENTRYPOINT ["java", "-cp", "/deployments/lib/*"] +# the main class to run: compose.yaml overrides it for the frontend and the auditor +CMD ["org.apache.camel.example.spiffe.backend.BackendApplication"] diff --git a/spiffe/src/main/java/org/apache/camel/example/spiffe/IdentityRoutes.java b/spiffe/src/main/java/org/apache/camel/example/spiffe/IdentityRoutes.java new file mode 100644 index 000000000..c419d7649 --- /dev/null +++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/IdentityRoutes.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.example.spiffe; + +import org.apache.camel.LoggingLevel; +import org.apache.camel.builder.RouteBuilder; + +/** + * Fetches the X.509-SVID of this workload from the SPIFFE Workload API at regular intervals and logs a summary of it. + *

+ * Both applications of this example run this very same route, yet each of them is issued a different identity: the + * SPIRE agent attests the process that connects to the Workload API (in this example by its Unix user id) and looks up + * the registration entry that matches it. Identity comes from the platform, not from the code or its configuration. + *

+ * The X.509-SVID is short-lived and the SPIRE agent rotates it before it expires, so the serial number and the validity + * period in the log change over time without the application doing anything about it. + */ +public class IdentityRoutes extends RouteBuilder { + + @Override + public void configure() { + // the SPIRE agent may not have attested this workload yet: log the problem and try again on the next tick + onException(Exception.class) + .handled(true) + .log(LoggingLevel.WARN, "Could not fetch the X.509-SVID: ${exception.message}"); + + from("timer:identity?period={{identity.period}}").routeId("identity") + // fetchX509Svid is also the default operation of the component. The message body becomes an + // io.spiffe.svid.x509svid.X509Svid and the SPIFFE ID is set as the CamelSpiffeSpiffeId header + .to("spiffe:identity?operation=fetchX509Svid") + // the X509Svid also carries the private key of the workload, so never log the body as-is + .bean(X509SvidSummary.class, "describe") + .log("${body}"); + } +} diff --git a/spiffe/src/main/java/org/apache/camel/example/spiffe/X509SvidSummary.java b/spiffe/src/main/java/org/apache/camel/example/spiffe/X509SvidSummary.java new file mode 100644 index 000000000..125446226 --- /dev/null +++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/X509SvidSummary.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.example.spiffe; + +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import io.spiffe.svid.x509svid.X509Svid; + +/** + * Turns an {@link X509Svid} into a human-readable summary of its leaf certificate. Only the certificate is described: + * the private key that comes with the SVID is never printed. + */ +public class X509SvidSummary { + + /** + * The GeneralName type of a uniformResourceIdentifier subject alternative name, which is where a SPIFFE ID is + * encoded in an X.509-SVID. + */ + private static final int URI_NAME = 6; + + public String describe(X509Svid svid) throws CertificateParsingException { + X509Certificate leaf = svid.getLeaf(); + return String.format(""" + X.509-SVID of %s + serial number : %s + subject : %s + issuer : %s + valid from : %s + valid until : %s + URI SANs : %s + chain length : %d certificate(s)""", + svid.getSpiffeId(), + leaf.getSerialNumber().toString(16), + leaf.getSubjectX500Principal(), + leaf.getIssuerX500Principal(), + leaf.getNotBefore().toInstant(), + leaf.getNotAfter().toInstant(), + uriSubjectAlternativeNames(leaf), + svid.getChain().size()); + } + + private static List uriSubjectAlternativeNames(X509Certificate certificate) + throws CertificateParsingException { + List uris = new ArrayList<>(); + Collection> names = certificate.getSubjectAlternativeNames(); + if (names != null) { + for (List name : names) { + if (Integer.valueOf(URI_NAME).equals(name.get(0))) { + uris.add(String.valueOf(name.get(1))); + } + } + } + return uris; + } +} diff --git a/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendApplication.java b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendApplication.java new file mode 100644 index 000000000..4d93e5970 --- /dev/null +++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendApplication.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.example.spiffe.backend; + +import org.apache.camel.example.spiffe.IdentityRoutes; +import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy; +import org.apache.camel.main.Main; + +/** + * Boots the backend: an HTTP API for orders that only serves callers with a valid JWT-SVID, and that calls the + * inventory service with its own identity. + */ +public final class BackendApplication { + + private BackendApplication() { + } + + public static void main(String[] args) throws Exception { + Main main = new Main(); + // the embedded HTTP server + main.configure().httpServer().withEnabled(true).withPort(8080); + // the workload identity policy of this service, and the routes that use it + WorkloadIdentityPolicy policy = new WorkloadIdentityPolicy("backend"); + main.configure().addRoutesBuilder(policy); + main.configure().addRoutesBuilder(new BackendRoutes(policy.getAuditTrail())); + main.configure().addRoutesBuilder(IdentityRoutes.class); + // now keep the application running until the JVM is terminated (ctrl + c or sigterm) + main.run(args); + } +} diff --git a/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendRoutes.java b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendRoutes.java new file mode 100644 index 000000000..8890b1f3d --- /dev/null +++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendRoutes.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.example.spiffe.backend; + +import java.util.Map; + +import org.apache.camel.Exchange; +import org.apache.camel.LoggingLevel; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.spiffe.SpiffeConstants; +import org.apache.camel.example.spiffe.policy.AuditTrail; +import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy; + +/** + * The backend exposes two HTTP routes, both guarded by the {@link WorkloadIdentityPolicy}: the orders, which are + * completed with the stock levels of the inventory service (the second hop), and the audit trail of the policy. + */ +public class BackendRoutes extends RouteBuilder { + + private final AuditTrail auditTrail; + + public BackendRoutes(AuditTrail auditTrail) { + this.auditTrail = auditTrail; + } + + @Override + public void configure() { + // the inventory could not be reached, or refused our identity: the orders cannot be served + onException(Exception.class) + .handled(true) + .log(LoggingLevel.ERROR, "Could not get the stock levels from the inventory: ${exception.message}") + .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(502)) + .setHeader(Exchange.CONTENT_TYPE, constant("text/plain")) + .setBody(simple("502 Bad Gateway: ${exception.message}")) + .removeHeaders("CamelSpiffe*"); + + from("platform-http:/api/orders?httpMethodRestrict=GET").routeId("orders") + .routeConfigurationId(WorkloadIdentityPolicy.ID) + // the policy has authenticated and authorized the caller by the time the route starts + .bean(OrderService.class, "listOrders") + .setProperty("orders", body()) + .setProperty("onBehalfOf", header(SpiffeConstants.SPIFFE_ID)) + .to("direct:stockLevels") + .bean(OrderService.class, "withStock") + .marshal().json() + .removeHeaders("CamelSpiffe*"); + + // the second hop: ask the inventory service for the stock levels, with our own identity. The JWT-SVID is + // minted for the inventory (its audience), and the caller we are serving travels along in a header for the + // audit trail of the inventory, which trusts it because we are authenticated and allowed to call it + from("direct:stockLevels").routeId("stock-levels") + .to("spiffe:backend?operation=fetchJwtSvid&audience={{inventory.audience}}") + .setHeader("Authorization", simple("Bearer ${body}")) + .setHeader("X-On-Behalf-Of", exchangeProperty("onBehalfOf")) + .setBody(simple("${null}")) + // the headers of the request we are serving must not shape the request we are making + .removeHeaders("CamelHttp*") + .removeHeaders("CamelSpiffe*") + .to("http://{{inventory.host}}:{{inventory.port}}/api/stock?httpMethod=GET") + // our token is not for the caller to see + .removeHeader("Authorization") + .removeHeader("X-On-Behalf-Of") + .unmarshal().json(Map.class); + + from("platform-http:/api/audit?httpMethodRestrict=GET").routeId("audit") + .routeConfigurationId(WorkloadIdentityPolicy.ID) + .bean(auditTrail, "report") + .marshal().json() + .removeHeaders("CamelSpiffe*"); + } +} diff --git a/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/OrderService.java b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/OrderService.java new file mode 100644 index 000000000..4d2851d24 --- /dev/null +++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/OrderService.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.example.spiffe.backend; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.camel.ExchangeProperty; +import org.apache.camel.Header; +import org.apache.camel.component.spiffe.SpiffeConstants; + +/** + * Stands in for the real business logic: returns a few orders, together with the identity of the caller they are + * served to, and completes them with the stock levels of the inventory. + */ +public class OrderService { + + public Map listOrders(@Header(SpiffeConstants.SPIFFE_ID) String caller) { + Map response = new LinkedHashMap<>(); + response.put("caller", caller); + response.put("orders", List.of( + order(1001, "Camel in Action, 2nd edition", 2), + order(1002, "Enterprise Integration Patterns", 1), + order(1003, "Zero Trust Networks", 3))); + return response; + } + + /** + * Marks each order as in stock or not, from the stock levels answered by the inventory (item to quantity). + */ + @SuppressWarnings("unchecked") + public Map withStock( + @ExchangeProperty("orders") Map response, Map stockLevels) { + for (Map order : (List>) 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 order(int id, String item, int quantity) { + Map 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 000000000..be157cbc3 --- /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. + *

+ * 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 000000000..104fc44bf --- /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 000000000..26c887f2c --- /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 000000000..b7b9320ac --- /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 000000000..7776d1b25 --- /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 levels() { + Map 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 000000000..e1c5cd95c --- /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 .allow.}. 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 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 000000000..0e76e3a68 --- /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> decisions = new ArrayDeque<>(); + + public AuditTrail(String service) { + this.service = service; + } + + public synchronized void record(String route, String caller, String outcome, String detail) { + Map 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 report() { + Map 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 000000000..67d5de2e3 --- /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 000000000..1c89c789f --- /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 000000000..a951a7053 --- /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. + *

    + *
  • 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.
  • + *
  • 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.
  • + *
  • Audit: every decision is recorded in the {@link AuditTrail} of the service.
  • + *
+ */ +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 .audience}) and its + * allow-lists ({@code .allow.}) 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 000000000..776b1c079 --- /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 .allow.. +# 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 000000000..fd23b980c --- /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 000000000..74d6cad60 --- /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 000000000..3a869839c --- /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 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 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 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 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 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 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 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 response = get("/api/orders", "Bearer frontend-token"); + + assertEquals(502, response.statusCode()); + assertEquals("502 Bad Gateway: no identity issued", response.body()); + } + + private static HttpResponse 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 000000000..d31742505 --- /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 000000000..9eaa42805 --- /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 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 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 response = get(null, null); + + assertEquals(401, response.statusCode()); + } + + private static HttpResponse 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 000000000..63f001908 --- /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-----