This is an automated email from the ASF dual-hosted git repository.

oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-examples.git


The following commit(s) were added to refs/heads/main by this push:
     new 315d5779 CAMEL-24634: Authorize with camel-opa in the camel-spiffe 
example (#264)
315d5779 is described below

commit 315d5779fbc834ab54ebe7f5dffc898c0591d66e
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Sep 14 10:23:13 2026 +0200

    CAMEL-24634: Authorize with camel-opa in the camel-spiffe example (#264)
    
    SPIFFE keeps authenticating the callers; Open Policy Agent now decides
    what an authenticated caller may do. The shared workload identity policy
    sends the SPIFFE ID of the caller and the route id to OPA, the allow-lists
    move from application.properties to Rego policies with their own tests,
    and Compose gains an OPA server that reloads the policies when they
    change. A policy that cannot be evaluated is answered with HTTP 503.
---
 spiffe/README.adoc                                 | 172 +++++++++++++++------
 spiffe/compose.yaml                                |  18 +++
 spiffe/opa/backend.rego                            |  34 ++++
 spiffe/opa/inventory.rego                          |  29 ++++
 spiffe/opa/policy_test.rego                        |  66 ++++++++
 spiffe/pom.xml                                     |  13 ++
 .../camel/example/spiffe/policy/AllowList.java     |  49 ------
 .../spiffe/policy/WorkloadIdentityPolicy.java      |  39 +++--
 spiffe/src/main/resources/application.properties   |   8 +-
 .../example/spiffe/backend/BackendRoutesTest.java  |  63 +++++++-
 .../spiffe/inventory/InventoryRoutesTest.java      |  40 ++++-
 11 files changed, 419 insertions(+), 112 deletions(-)

diff --git a/spiffe/README.adoc b/spiffe/README.adoc
index 23f5fb15..929c8f61 100644
--- a/spiffe/README.adoc
+++ b/spiffe/README.adoc
@@ -2,8 +2,11 @@
 
 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.
+and how a small chain of services uses that identity to authenticate their 
calls without any shared secret,
+password or API key in the code, in the configuration or on disk. What an 
authenticated caller may do is decided
+by https://www.openpolicyagent.org/[Open Policy Agent] through the
+https://camel.apache.org/components/next/opa-component.html[Camel OPA 
component] (`camel-opa`), with the rules
+written in Rego and kept out of the routes.
 
 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
@@ -14,7 +17,8 @@ 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`:
+Four Camel applications, a SPIRE deployment and an OPA server run with Docker 
Compose, all in the trust domain
+`example.org`:
 
 ----
                     
+---------------------------------------------------------------------+
@@ -37,23 +41,29 @@ Four Camel applications and a SPIRE deployment run with 
Docker Compose, all in t
 | fetchX509Svid    | | fetchX509Svid    | | fetchJwtSvid       | | 
fetchX509Svid          |
 |                  | |                  | | fetchX509Svid      | |             
           |
 +---------+--------+ +--------+---------+ +----+----------+----+ 
+---------------+--------+
-          |                   |               ^          |                     
  ^
-          |  GET /api/orders  |               |          |  GET /api/stock     
  |
-          |  GET /api/audit   |               |          |  Authorization: 
Bearer JWT (backend)
-          |  Authorization: Bearer JWT        |          |  X-On-Behalf-Of: 
<caller>
-          +-------------------+---------------+          
+-----------------------+
-                 frontend: orders 200, audit 403
-                 auditor:  orders 403, audit 200
+          |                   |               ^     |    |                  ^  
  |
+          |  GET /api/orders  |               |     |    |  GET /api/stock  |  
  |
+          |  GET /api/audit   |               |     |    |  Authorization: 
Bearer JWT (backend)
+          |  Authorization: Bearer JWT        |     |    |  X-On-Behalf-Of: 
<caller>
+          +-------------------+---------------+     |    +------------------+  
  |
+                 frontend: orders 200, audit 403    |                          
  |
+                 auditor:  orders 403, audit 200    |  may <caller> call 
<route>?|
+                                                    v                          
  v
+                                        
+-----------+----------------------------+-----------+
+                                        | opa: Open Policy Agent, policies in 
opa/*.rego     |
+                                        
+----------------------------------------------------+
 ----
 
 * The *backend* exposes `GET /api/orders` and `GET /api/audit`. Both routes 
use the same
   <<the-workload-identity-policy,workload identity policy>>: every request 
must carry a JWT-SVID as bearer token,
   which the backend hands to the Workload API (`validateJwtSvid`) to check its 
signature, its expiry and that it was
   minted for the backend (the _audience_ of the token). The SPIFFE ID of the 
caller comes back in the
-  `CamelSpiffeSpiffeId` header, and the allow-list of the route decides 
whether that caller may use it.
+  `CamelSpiffeSpiffeId` header. The backend then asks OPA whether that caller 
may use that route, and OPA answers
+  from the Rego policy of the backend.
 * 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.
+  inventory uses the very same policy, with its own Rego rules: it accepts the 
backend, and only on behalf of a
+  caller who may read the orders.
 * 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
@@ -68,14 +78,36 @@ Four Camel applications and a SPIRE deployment run with 
Docker Compose, all in t
   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`:
+registers the workloads at startup (see `spire/entrypoint.sh`). What each 
identity may do is not in the
+applications either: it is the Rego policy of each service in the `opa` 
directory, which the `opa` container
+serves and reloads when it changes. This is `opa/backend.rego`:
 
-[source,properties]
+[source,rego]
 ----
-backend.allow.orders = spiffe://example.org/frontend
-backend.allow.audit = spiffe://example.org/auditor
-inventory.allow.stock = spiffe://example.org/backend
+package camel.spiffe.backend
+
+default allow := false
+
+# which SPIFFE IDs may call which route of the backend
+permissions := {
+       "orders": {"spiffe://example.org/frontend"},
+       "audit": {"spiffe://example.org/auditor"},
+}
+
+allow if {
+       input.headers.CamelSpiffeSpiffeId in permissions[input.routeId]
+}
+----
+
+and `opa/inventory.rego` refers to it, so that the inventory only serves the 
backend on behalf of a caller who may
+read the orders:
+
+[source,rego]
+----
+allow if {
+       headers.camelspiffespiffeid == "spiffe://example.org/backend"
+       headers["x-on-behalf-of"] in 
data.camel.spiffe.backend.permissions.orders
+}
 ----
 
 [width="100%",cols="1,1,3,5",options="header"]
@@ -95,8 +127,8 @@ inventory.allow.stock = spiffe://example.org/backend
 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.
+(`new WorkloadIdentityPolicy("backend")`), which is how it finds the audience 
of the service in the configuration
+and the policy of the service in OPA. SPIFFE authenticates, OPA authorizes:
 
 [source,java]
 ----
@@ -110,15 +142,26 @@ policy.onException(JwtSvidException.class, 
IllegalArgumentException.class)
         .setBody(simple("401 Unauthorized: ${body}"))
         .removeHeaders("CamelSpiffe*");
 
+// OPA could not decide (unreachable, an error, an undefined decision): nobody 
gets in, HTTP 503
+policy.onException(OpaPolicyEvaluationException.class)
+        .handled(true)
+        .bean(auditTrail, "record(${routeId}, ${header.CamelSpiffeSpiffeId}, 
'error', ${exception.message})")
+        .log(LoggingLevel.ERROR, "Could not evaluate the policy for 
${routeId}: ${exception.message}")
+        .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(503))
+        .setBody(constant("503 Service Unavailable: the policy could not be 
evaluated"))
+        .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
+        // authorization: OPA gets the SPIFFE ID of the caller and the id of 
the route, nothing else
+        .to("opa:camel/spiffe/" + service + "/allow?serverUrl={{opa.url}}"
+            + "&includeHeaders=" + SpiffeConstants.SPIFFE_ID + 
",X-On-Behalf-Of")
         .choice()
-            .when(method(allowList, "isAllowed(${routeId}, 
${header.CamelSpiffeSpiffeId})"))
+            .when(header(OpaConstants.DECISION_ALLOW).isEqualTo(true))
                 .bean(auditTrail, "record(${routeId}, 
${header.CamelSpiffeSpiffeId}, 'allowed', null)")
                 .log("Authenticated caller ${header.CamelSpiffeSpiffeId}, 
allowed to call ${routeId}")
             .otherwise()
@@ -128,15 +171,28 @@ policy.interceptFrom()
                 .removeHeaders("CamelSpiffe*")
                 // the route itself does not run
                 .stop()
-        .end();
+        .end()
+        .removeHeaders("CamelOpa*");
 ----
 
 `validateJwtSvid` takes the token from the `CamelSpiffeToken` header. The 
message body becomes the validated
 `io.spiffe.svid.jwtsvid.JwtSvid` and the SPIFFE ID of the caller is set as the 
`CamelSpiffeSpiffeId` header. A
 failed validation throws an `io.spiffe.exception.JwtSvidException`, whose 
cause says why (expired, wrong audience,
-unknown key, ...); the policy puts that reason in the HTTP 401 response. 
`AllowList` looks up
-`<service>.allow.<route id>` in the configuration, and `AuditTrail` keeps the 
last decisions of the policy, which
-the backend exposes on `/api/audit`.
+unknown key, ...); the policy puts that reason in the HTTP 401 response.
+
+The `opa` endpoint builds an input document out of the message and asks OPA to 
evaluate the rule at the given
+path, `camel/spiffe/backend/allow` for the backend. With `includeHeaders` set, 
the document only carries the two
+headers the policy needs, so the token never leaves the application:
+
+[source,json]
+----
+{"headers": {"CamelSpiffeSpiffeId": "spiffe://example.org/frontend"}, 
"routeId": "orders", "exchangeId": "..."}
+----
+
+The verdict comes back in the `CamelOpaDecisionAllow` header. The component 
fails closed: if OPA cannot be reached
+or cannot evaluate the policy, it throws an `OpaPolicyEvaluationException` 
instead of answering, and the policy
+turns that into an HTTP 503. `AuditTrail` keeps the last decisions of the 
policy, which the backend exposes on
+`/api/audit`.
 
 === The Camel routes
 
@@ -222,8 +278,16 @@ The example is built with Maven:
 $ 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.
+This also runs the unit tests, which need neither SPIRE nor OPA (see below), 
and copies the runtime dependencies
+to `target/lib`, from where `src/main/docker/Dockerfile` picks them up.
+
+The Rego policies have unit tests of their own in `opa/policy_test.rego`, 
which run with the OPA binary or its
+container image:
+
+[source,sh]
+----
+$ docker run --rm -v $PWD/opa:/policies:ro,z openpolicyagent/opa:1.9.0-static 
test /policies -v
+----
 
 === How to run
 
@@ -235,8 +299,8 @@ $ 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
+the server with a one-time token. The `opa` container loads the policies of 
the `opa` directory. 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:
@@ -251,6 +315,7 @@ frontend-1  | 17:27:23.6 [timer://orders] call-backend INFO 
 Fetched a JWT-SVID
 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
+opa-1       | 
{"decision_id":"...","input":{"exchangeId":"...","headers":{"CamelSpiffeSpiffeId":"spiffe://example.org/frontend"},"routeId":"orders"},"path":"camel/spiffe/backend/allow","result":true,...}
 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
@@ -293,6 +358,18 @@ HTTP/1.1 401 Unauthorized
 $ docker compose exec spire /opt/spire/bin/spire-server entry show
 ----
 
+* Ask OPA the same question the backend asks, with the input document the 
component sends (OPA is published on
+  port 8181), and read its decision log to see every question the services 
asked:
++
+[source,sh]
+----
+$ curl -s -X POST localhost:8181/v1/data/camel/spiffe/backend/allow \
+    -d '{"input": {"headers": {"CamelSpiffeSpiffeId": 
"spiffe://example.org/auditor"}, "routeId": "audit"}}'
+{"decision_id":"...","result":true}
+
+$ docker compose logs opa | grep '"result"'
+----
+
 * 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:
@@ -309,11 +386,16 @@ $ docker compose logs -f frontend | grep -A 8 "X.509-SVID 
of"
 $ 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.
+* Change who may do what without touching the applications: add 
`"spiffe://example.org/auditor"` to the `orders`
+  set in `opa/backend.rego` and save the file. OPA reloads the policy, and the 
next call of the auditor gets the
+  orders back. Nothing was rebuilt or restarted. Then take it out again and 
watch the auditor being denied
+  again.
+
+* Stop OPA with `docker compose stop opa` and watch what happens to the 
backend: nobody gets in anymore. The
+  requests in flight wait while the OPA client retries, about 45 seconds the 
first time, then every call is
+  answered with HTTP 503 and the audit trail records an `error` decision. The 
policy fails closed: a decision point
+  that cannot be reached is never an allow. Start OPA again with `docker 
compose start opa` and the calls succeed
+  again.
 
 Stop everything, and remove the containers and the volume with the Workload 
API socket, with:
 
@@ -334,23 +416,27 @@ on Kubernetes, attests the workloads by their pod and 
service account instead of
 $ 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.
+The tests need neither SPIRE nor OPA. Each test class binds a Mockito mock of
+`io.spiffe.workloadapi.WorkloadApiClient` and, for the HTTP services, of 
`com.styra.opa.OPAClient` to the Camel
+registry with `@BindToRegistry`; the SPIFFE and OPA components autowire the 
single client they find there (their
+`workloadApiClient` and `opaClient` options). The routes under test are 
therefore exactly the ones that run in the
+containers, only the SPIRE agent and the OPA server are replaced by fakes: one 
mints, validates or refuses SVIDs as
+the test needs, the other decides like the Rego policies do and lets the tests 
check what it was asked. 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. The Rego policies
+themselves are tested with `opa test`, as shown above.
 
 === 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:
+SPIFFE Workload API) is reachable and has a registration entry for the process 
that runs them, and an OPA server
+with the policies of the `opa` directory listens somewhere (`docker compose up 
opa` starts one on port 8181):
 
 [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 -Dinventory.host=localhost -Dinventory.port=8081 
-Dopa.url=http://localhost:8181
 $ mvn camel:run -Dbackend.host=localhost 
-Dcamel.mainClass=org.apache.camel.example.spiffe.frontend.FrontendApplication
 ----
 
diff --git a/spiffe/compose.yaml b/spiffe/compose.yaml
index c5b250d6..9becca9a 100644
--- a/spiffe/compose.yaml
+++ b/spiffe/compose.yaml
@@ -43,6 +43,17 @@ services:
       retries: 30
       start_period: 5s
 
+  # Open Policy Agent, the policy decision point: the backend and the 
inventory ask it whether an authenticated
+  # caller may do what it asks. The Rego policies live in the opa directory 
and are reloaded when they change
+  opa:
+    image: openpolicyagent/opa:1.9.0-static
+    command: ["run", "--server", "--addr", "0.0.0.0:8181", "--watch", "--set", 
"decision_logs.console=true", "/policies"]
+    volumes:
+      # the z flag lets the container read the directory on hosts with SELinux 
(Fedora, RHEL)
+      - ./opa:/policies:ro,z
+    ports:
+      - "8181:8181"
+
   # the second hop: an HTTP API that only the backend may call. uid 1004 is 
registered as
   # spiffe://example.org/inventory
   inventory:
@@ -54,6 +65,11 @@ services:
       SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock
     ports:
       - "8081:8080"
+    depends_on:
+      spire:
+        condition: service_healthy
+      opa:
+        condition: service_started
 
   # the HTTP API the clients talk to, which calls the inventory with its own 
identity.
   # uid 1002 is registered as spiffe://example.org/backend
@@ -69,6 +85,8 @@ services:
     depends_on:
       spire:
         condition: service_healthy
+      opa:
+        condition: service_started
       inventory:
         condition: service_started
 
diff --git a/spiffe/opa/backend.rego b/spiffe/opa/backend.rego
new file mode 100644
index 00000000..9eada205
--- /dev/null
+++ b/spiffe/opa/backend.rego
@@ -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.
+
+# The authorization policy of the backend, evaluated by Open Policy Agent. 
Camel sends OPA an input document
+# such as
+#
+#   {"headers": {"CamelSpiffeSpiffeId": "spiffe://example.org/frontend"}, 
"routeId": "orders", "exchangeId": "..."}
+#
+# and reads the boolean answer of the "allow" rule, published by OPA as 
camel/spiffe/backend/allow.
+package camel.spiffe.backend
+
+default allow := false
+
+# which SPIFFE IDs may call which route of the backend
+permissions := {
+       "orders": {"spiffe://example.org/frontend"},
+       "audit": {"spiffe://example.org/auditor"},
+}
+
+allow if {
+       input.headers.CamelSpiffeSpiffeId in permissions[input.routeId]
+}
diff --git a/spiffe/opa/inventory.rego b/spiffe/opa/inventory.rego
new file mode 100644
index 00000000..113e9b17
--- /dev/null
+++ b/spiffe/opa/inventory.rego
@@ -0,0 +1,29 @@
+# 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 authorization policy of the inventory, evaluated by Open Policy Agent as 
camel/spiffe/inventory/allow.
+package camel.spiffe.inventory
+
+default allow := false
+
+# HTTP header names are case-insensitive, so look them up in lower case
+headers := {lower(name): value | some name, value in input.headers}
+
+# Only the backend may ask for the stock levels, and only on behalf of a 
caller that may read the orders.
+# The permissions of the backend are data of the same OPA server, so the 
policy can refer to them.
+allow if {
+       headers.camelspiffespiffeid == "spiffe://example.org/backend"
+       headers["x-on-behalf-of"] in 
data.camel.spiffe.backend.permissions.orders
+}
diff --git a/spiffe/opa/policy_test.rego b/spiffe/opa/policy_test.rego
new file mode 100644
index 00000000..becb4255
--- /dev/null
+++ b/spiffe/opa/policy_test.rego
@@ -0,0 +1,66 @@
+# 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.
+
+# Unit tests of the policies, run with:  opa test /policies  (see the README)
+package camel.spiffe_test
+
+import data.camel.spiffe.backend
+import data.camel.spiffe.inventory
+
+frontend := "spiffe://example.org/frontend"
+
+auditor := "spiffe://example.org/auditor"
+
+backend_id := "spiffe://example.org/backend"
+
+test_frontend_may_read_the_orders if {
+       backend.allow with input as {"headers": {"CamelSpiffeSpiffeId": 
frontend}, "routeId": "orders"}
+}
+
+test_frontend_may_not_read_the_audit_trail if {
+       not backend.allow with input as {"headers": {"CamelSpiffeSpiffeId": 
frontend}, "routeId": "audit"}
+}
+
+test_auditor_may_read_the_audit_trail if {
+       backend.allow with input as {"headers": {"CamelSpiffeSpiffeId": 
auditor}, "routeId": "audit"}
+}
+
+test_auditor_may_not_read_the_orders if {
+       not backend.allow with input as {"headers": {"CamelSpiffeSpiffeId": 
auditor}, "routeId": "orders"}
+}
+
+test_an_unknown_route_is_denied if {
+       not backend.allow with input as {"headers": {"CamelSpiffeSpiffeId": 
frontend}, "routeId": "something-else"}
+}
+
+test_a_caller_without_identity_is_denied if {
+       not backend.allow with input as {"headers": {}, "routeId": "orders"}
+}
+
+test_backend_may_ask_the_stock_on_behalf_of_the_frontend if {
+       inventory.allow with input as {"headers": {"CamelSpiffeSpiffeId": 
backend_id, "X-On-Behalf-Of": frontend}, "routeId": "stock"}
+}
+
+test_backend_may_not_ask_the_stock_on_behalf_of_the_auditor if {
+       not inventory.allow with input as {"headers": {"CamelSpiffeSpiffeId": 
backend_id, "X-On-Behalf-Of": auditor}, "routeId": "stock"}
+}
+
+test_backend_may_not_ask_the_stock_on_behalf_of_nobody if {
+       not inventory.allow with input as {"headers": {"CamelSpiffeSpiffeId": 
backend_id}, "routeId": "stock"}
+}
+
+test_frontend_may_not_ask_the_inventory_directly if {
+       not inventory.allow with input as {"headers": {"CamelSpiffeSpiffeId": 
frontend, "X-On-Behalf-Of": frontend}, "routeId": "stock"}
+}
diff --git a/spiffe/pom.xml b/spiffe/pom.xml
index 0d5a136c..e01a3453 100644
--- a/spiffe/pom.xml
+++ b/spiffe/pom.xml
@@ -48,6 +48,14 @@
                 <type>pom</type>
                 <scope>import</scope>
             </dependency>
+            <!-- the OPA SDK declares an older Jackson than the one Camel 
uses: keep them on the Camel version -->
+            <dependency>
+                <groupId>com.fasterxml.jackson</groupId>
+                <artifactId>jackson-bom</artifactId>
+                <version>${jackson2-version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
         </dependencies>
     </dependencyManagement>
 
@@ -66,6 +74,11 @@
             <groupId>org.apache.camel</groupId>
             <artifactId>camel-spiffe</artifactId>
         </dependency>
+        <!-- asks Open Policy Agent whether an authenticated caller may do 
what it asks -->
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-opa</artifactId>
+        </dependency>
         <!-- the Workload API is a gRPC service on a Unix domain socket; 
java-spiffe needs a native transport for that.
              This is the Linux one, which is what runs inside the containers 
of this example. When running the
              applications directly on macOS use io.spiffe:grpc-netty-macos or 
io.spiffe:grpc-netty-macos-aarch64 instead. -->
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
deleted file mode 100644
index e1c5cd95..00000000
--- a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AllowList.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.camel.example.spiffe.policy;
-
-import java.util.Arrays;
-import java.util.Optional;
-
-import org.apache.camel.CamelContext;
-
-/**
- * Decides which callers may use which route. Authentication is left to 
SPIFFE, so this is all the authorization logic
- * the services need: an allow-list of SPIFFE IDs per route, read from the 
configuration as
- * {@code <service>.allow.<route id>}. A route without an allow-list accepts 
nobody.
- */
-public class AllowList {
-
-    private final CamelContext camelContext;
-    private final String prefix;
-
-    public AllowList(CamelContext camelContext, String service) {
-        this.camelContext = camelContext;
-        this.prefix = service + ".allow.";
-    }
-
-    public boolean isAllowed(String routeId, String spiffeId) {
-        if (routeId == null || spiffeId == null) {
-            return false;
-        }
-        Optional<String> allowedCallers = 
camelContext.getPropertiesComponent().resolveProperty(prefix + routeId);
-        return allowedCallers.stream()
-                .flatMap(callers -> Arrays.stream(callers.split(",")))
-                .map(String::trim)
-                .anyMatch(spiffeId::equals);
-    }
-}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/WorkloadIdentityPolicy.java
 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/WorkloadIdentityPolicy.java
index a951a705..312e70ce 100644
--- 
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
@@ -20,6 +20,8 @@ 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.opa.OpaConstants;
+import org.apache.camel.component.opa.OpaPolicyEvaluationException;
 import org.apache.camel.component.spiffe.SpiffeConstants;
 import org.apache.camel.model.RouteConfigurationDefinition;
 
@@ -28,10 +30,12 @@ import org.apache.camel.model.RouteConfigurationDefinition;
  * a route configuration: every route that opts in with {@code 
routeConfigurationId(WorkloadIdentityPolicy.ID)} gets
  * these checks before its own steps run, so the routes contain business logic 
only.
  * <ul>
- * <li>Authentication: the request must carry a JWT-SVID as bearer token, 
minted for this service (the audience). The
- * SPIFFE Workload API checks the signature, the expiry and the audience; a 
failure is answered with HTTP 401.</li>
- * <li>Authorization: the SPIFFE ID of the caller must be on the allow-list of 
the route (see {@link AllowList}), or
- * the request is answered with HTTP 403.</li>
+ * <li>Authentication, with SPIFFE: the request must carry a JWT-SVID as 
bearer token, minted for this service (the
+ * audience). The SPIFFE Workload API checks the signature, the expiry and the 
audience; a failure is answered with
+ * HTTP 401.</li>
+ * <li>Authorization, with Open Policy Agent: the SPIFFE ID of the caller and 
the id of the route are sent to OPA,
+ * which evaluates the Rego policy of this service (see the opa directory of 
the example). A deny is answered with
+ * HTTP 403, and a policy that cannot be evaluated with HTTP 503: the policy 
fails closed.</li>
  * <li>Audit: every decision is recorded in the {@link AuditTrail} of the 
service.</li>
  * </ul>
  */
@@ -44,8 +48,8 @@ public class WorkloadIdentityPolicy extends 
RouteConfigurationBuilder {
     private final AuditTrail auditTrail;
 
     /**
-     * @param service the name of the service, used to look up its audience 
({@code <service>.audience}) and its
-     *                allow-lists ({@code <service>.allow.<route id>}) in the 
configuration
+     * @param service the name of the service, used to look up its audience 
({@code <service>.audience}) in the
+     *                configuration and its policy ({@code 
camel/spiffe/<service>/allow}) in OPA
      */
     public WorkloadIdentityPolicy(String service) {
         this.service = service;
@@ -58,7 +62,6 @@ public class WorkloadIdentityPolicy extends 
RouteConfigurationBuilder {
 
     @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, ...)
@@ -73,6 +76,16 @@ public class WorkloadIdentityPolicy extends 
RouteConfigurationBuilder {
                 .setBody(simple("401 Unauthorized: ${body}"))
                 .removeHeaders("CamelSpiffe*");
 
+        // OPA could not decide (unreachable, an error, an undefined 
decision): nobody gets in
+        policy.onException(OpaPolicyEvaluationException.class)
+                .handled(true)
+                .bean(auditTrail, "record(${routeId}, 
${header.CamelSpiffeSpiffeId}, 'error', ${exception.message})")
+                .log(LoggingLevel.ERROR, "Could not evaluate the policy for 
${routeId}: ${exception.message}")
+                .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(503))
+                .setHeader(Exchange.CONTENT_TYPE, constant("text/plain"))
+                .setBody(constant("503 Service Unavailable: the policy could 
not be evaluated"))
+                .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
@@ -81,9 +94,13 @@ public class WorkloadIdentityPolicy extends 
RouteConfigurationBuilder {
                 .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
+                // 2. authorization: OPA gets the SPIFFE ID of the caller 
(and, on the second hop, on whose behalf it
+                //    calls) together with the id of the route, and evaluates 
the policy of this service. Only those
+                //    two headers are sent: the token stays here
+                .to("opa:camel/spiffe/" + service + 
"/allow?serverUrl={{opa.url}}"
+                    + "&includeHeaders=" + SpiffeConstants.SPIFFE_ID + 
",X-On-Behalf-Of")
                 .choice()
-                    .when(method(allowList, "isAllowed(${routeId}, 
${header.CamelSpiffeSpiffeId})"))
+                    .when(header(OpaConstants.DECISION_ALLOW).isEqualTo(true))
                         .bean(auditTrail, "record(${routeId}, 
${header.CamelSpiffeSpiffeId}, 'allowed', null)")
                         .log("Authenticated caller 
${header.CamelSpiffeSpiffeId}, allowed to call ${routeId}")
                     .otherwise()
@@ -96,6 +113,8 @@ public class WorkloadIdentityPolicy extends 
RouteConfigurationBuilder {
                         .removeHeaders("CamelSpiffe*")
                         // the route itself does not run
                         .stop()
-                .end();
+                .end()
+                // the decision headers are of no use to the route
+                .removeHeaders("CamelOpa*");
     }
 }
diff --git a/spiffe/src/main/resources/application.properties 
b/spiffe/src/main/resources/application.properties
index 776b1c07..8bdf672b 100644
--- a/spiffe/src/main/resources/application.properties
+++ b/spiffe/src/main/resources/application.properties
@@ -29,11 +29,9 @@ camel.main.name = camel-spiffe
 backend.audience = spiffe://example.org/backend
 inventory.audience = spiffe://example.org/inventory
 
-# Who may call what: the SPIFFE IDs (comma separated) allowed on each route, 
as <service>.allow.<route id>.
-# A route without an allow-list accepts nobody
-backend.allow.orders = spiffe://example.org/frontend
-backend.allow.audit = spiffe://example.org/auditor
-inventory.allow.stock = spiffe://example.org/backend
+# Authorization is decided by Open Policy Agent: the SPIFFE ID of the caller 
and the id of the route are sent to
+# OPA, which evaluates the Rego policy of the service 
(camel/spiffe/<service>/allow, see the opa directory)
+opa.url = http://opa:8181
 
 # Where the services are found
 backend.host = backend
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
index 3a869839..fce83e8e 100644
--- 
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
@@ -25,6 +25,8 @@ import java.util.Map;
 import java.util.Properties;
 import java.util.concurrent.ConcurrentHashMap;
 
+import com.styra.opa.OPAClient;
+import com.styra.opa.OPAException;
 import io.spiffe.exception.JwtSvidException;
 import io.spiffe.spiffeid.SpiffeId;
 import io.spiffe.svid.jwtsvid.JwtSvid;
@@ -42,15 +44,18 @@ 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.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
 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.
+ * Tests the backend over HTTP, on the embedded server of Camel Main, against 
a fake SPIFFE Workload API, a fake OPA
+ * and a stub of the inventory service. The spiffe and opa components autowire 
the single {@link WorkloadApiClient}
+ * and {@link OPAClient} they find in the registry, so the routes and the 
policy under test are exactly the ones used
+ * at runtime: only the SPIRE agent and the OPA server are replaced.
  */
 class BackendRoutesTest extends CamelMainTestSupport {
 
@@ -58,6 +63,7 @@ class BackendRoutesTest extends CamelMainTestSupport {
     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 POLICY = "camel/spiffe/backend/allow";
     private static final String STOCK_LEVELS
             = "{\"Camel in Action, 2nd edition\":12,\"Enterprise Integration 
Patterns\":0,\"Zero Trust Networks\":5}";
 
@@ -68,10 +74,15 @@ class BackendRoutesTest extends CamelMainTestSupport {
     private final WorkloadIdentityPolicy policy = new 
WorkloadIdentityPolicy("backend");
     /** The headers of the last request received by the stub inventory. */
     private final Map<String, Object> inventoryRequestHeaders = new 
ConcurrentHashMap<>();
+    /** The last input document sent to OPA. */
+    private final Map<String, Object> opaInput = new ConcurrentHashMap<>();
 
     @BindToRegistry
     private final WorkloadApiClient workloadApiClient = 
mock(WorkloadApiClient.class);
 
+    @BindToRegistry
+    private final OPAClient opaClient = mock(OPAClient.class);
+
     @Override
     protected void configure(MainConfigurationProperties configuration) {
         configuration.httpServer().withEnabled(true).withPort(PORT);
@@ -105,6 +116,7 @@ class BackendRoutesTest extends CamelMainTestSupport {
 
     @Test
     void frontendGetsTheOrdersWithTheStockLevels() throws Exception {
+        opaDecidesLikeThePolicy();
         JwtSvid frontend = jwtSvid(FRONTEND, null);
         when(workloadApiClient.validateJwtSvid("frontend-token", 
BACKEND)).thenReturn(frontend);
         JwtSvid backend = jwtSvid(BACKEND, "backend-token");
@@ -118,6 +130,13 @@ class BackendRoutesTest extends CamelMainTestSupport {
         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);
 
+        // OPA was asked about the caller and the route, and told nothing else
+        assertEquals("orders", opaInput.get("routeId"));
+        Map<?, ?> headers = (Map<?, ?>) opaInput.get("headers");
+        assertEquals(FRONTEND, headers.get(SpiffeConstants.SPIFFE_ID));
+        assertFalse(headers.containsKey(SpiffeConstants.TOKEN), "the token 
must not be sent to OPA");
+        assertFalse(headers.containsKey("Authorization"), "the token must not 
be sent to OPA");
+
         // 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"));
@@ -131,6 +150,7 @@ class BackendRoutesTest extends CamelMainTestSupport {
 
     @Test
     void auditorMayNotReadTheOrders() throws Exception {
+        opaDecidesLikeThePolicy();
         JwtSvid auditor = jwtSvid(AUDITOR, null);
         when(workloadApiClient.validateJwtSvid("auditor-token", 
BACKEND)).thenReturn(auditor);
 
@@ -143,6 +163,7 @@ class BackendRoutesTest extends CamelMainTestSupport {
 
     @Test
     void auditorReadsTheAuditTrail() throws Exception {
+        opaDecidesLikeThePolicy();
         JwtSvid auditor = jwtSvid(AUDITOR, null);
         when(workloadApiClient.validateJwtSvid("auditor-token", 
BACKEND)).thenReturn(auditor);
 
@@ -161,6 +182,7 @@ class BackendRoutesTest extends CamelMainTestSupport {
 
     @Test
     void frontendMayNotReadTheAuditTrail() throws Exception {
+        opaDecidesLikeThePolicy();
         JwtSvid frontend = jwtSvid(FRONTEND, null);
         when(workloadApiClient.validateJwtSvid("frontend-token", 
BACKEND)).thenReturn(frontend);
 
@@ -171,6 +193,7 @@ class BackendRoutesTest extends CamelMainTestSupport {
 
     @Test
     void invalidTokenIsUnauthorized() throws Exception {
+        opaDecidesLikeThePolicy();
         // 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",
@@ -185,6 +208,7 @@ class BackendRoutesTest extends CamelMainTestSupport {
 
     @Test
     void missingTokenIsUnauthorized() throws Exception {
+        opaDecidesLikeThePolicy();
         HttpResponse<String> response = get("/api/orders", null);
 
         assertEquals(401, response.statusCode());
@@ -194,6 +218,7 @@ class BackendRoutesTest extends CamelMainTestSupport {
 
     @Test
     void unreachableInventoryIsABadGateway() throws Exception {
+        opaDecidesLikeThePolicy();
         JwtSvid frontend = jwtSvid(FRONTEND, null);
         when(workloadApiClient.validateJwtSvid("frontend-token", 
BACKEND)).thenReturn(frontend);
         when(workloadApiClient.fetchJwtSvid(INVENTORY)).thenThrow(new 
JwtSvidException("no identity issued"));
@@ -204,6 +229,36 @@ class BackendRoutesTest extends CamelMainTestSupport {
         assertEquals("502 Bad Gateway: no identity issued", response.body());
     }
 
+    @Test
+    void unreachableOpaIsServiceUnavailable() throws Exception {
+        when(opaClient.evaluate(eq(POLICY), anyMap(), eq(Object.class)))
+                .thenThrow(new OPAException("connection refused"));
+        JwtSvid frontend = jwtSvid(FRONTEND, null);
+        when(workloadApiClient.validateJwtSvid("frontend-token", 
BACKEND)).thenReturn(frontend);
+
+        HttpResponse<String> response = get("/api/orders", "Bearer 
frontend-token");
+
+        // the policy fails closed: nobody gets in while OPA cannot decide
+        assertEquals(503, response.statusCode());
+        assertEquals("503 Service Unavailable: the policy could not be 
evaluated", response.body());
+    }
+
+    /**
+     * The fake OPA decides like opa/backend.rego does, and keeps the input 
document for the tests to check.
+     */
+    private void opaDecidesLikeThePolicy() throws Exception {
+        when(opaClient.evaluate(eq(POLICY), anyMap(), 
eq(Object.class))).thenAnswer(invocation -> {
+            Map<String, Object> input = invocation.getArgument(1);
+            opaInput.clear();
+            opaInput.putAll(input);
+            Map<?, ?> headers = (Map<?, ?>) input.get("headers");
+            Object caller = headers.get(SpiffeConstants.SPIFFE_ID);
+            Object route = input.get("routeId");
+            return ("orders".equals(route) && FRONTEND.equals(caller))
+                    || ("audit".equals(route) && AUDITOR.equals(caller));
+        });
+    }
+
     private static HttpResponse<String> get(String path, String authorization) 
throws Exception {
         HttpRequest.Builder request = 
HttpRequest.newBuilder(URI.create("http://localhost:"; + PORT + path)).GET();
         if (authorization != null) {
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
index 9eaa4280..cf7ad19e 100644
--- 
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
@@ -21,7 +21,9 @@ import java.net.http.HttpClient;
 import java.net.http.HttpRequest;
 import java.net.http.HttpResponse;
 import java.util.Date;
+import java.util.Map;
 
+import com.styra.opa.OPAClient;
 import io.spiffe.spiffeid.SpiffeId;
 import io.spiffe.svid.jwtsvid.JwtSvid;
 import io.spiffe.workloadapi.WorkloadApiClient;
@@ -33,15 +35,19 @@ 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.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.eq;
 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.
+ * Tests the inventory over HTTP, on the embedded server of Camel Main, 
against a fake SPIFFE Workload API and a fake
+ * OPA that decides like opa/inventory.rego does.
  */
 class InventoryRoutesTest extends CamelMainTestSupport {
 
     private static final String INVENTORY = "spiffe://example.org/inventory";
+    private static final String POLICY = "camel/spiffe/inventory/allow";
 
     // static, because configureContext() runs in the constructor of 
CamelTestSupport, before the instance
     // fields are initialized
@@ -51,6 +57,9 @@ class InventoryRoutesTest extends CamelMainTestSupport {
     @BindToRegistry
     private final WorkloadApiClient workloadApiClient = 
mock(WorkloadApiClient.class);
 
+    @BindToRegistry
+    private final OPAClient opaClient = mock(OPAClient.class);
+
     @Override
     protected void configure(MainConfigurationProperties configuration) {
         configuration.httpServer().withEnabled(true).withPort(PORT);
@@ -60,6 +69,7 @@ class InventoryRoutesTest extends CamelMainTestSupport {
 
     @Test
     void backendGetsTheStockLevels() throws Exception {
+        opaDecidesLikeThePolicy();
         JwtSvid backend = jwtSvid("spiffe://example.org/backend");
         when(workloadApiClient.validateJwtSvid("backend-token", 
INVENTORY)).thenReturn(backend);
 
@@ -72,6 +82,7 @@ class InventoryRoutesTest extends CamelMainTestSupport {
 
     @Test
     void frontendMayNotAskTheInventoryDirectly() throws Exception {
+        opaDecidesLikeThePolicy();
         JwtSvid frontend = jwtSvid("spiffe://example.org/frontend");
         when(workloadApiClient.validateJwtSvid("frontend-token", 
INVENTORY)).thenReturn(frontend);
 
@@ -82,13 +93,40 @@ class InventoryRoutesTest extends CamelMainTestSupport {
                 response.body());
     }
 
+    @Test
+    void backendMayNotAskOnBehalfOfSomeoneWhoMayNotReadTheOrders() throws 
Exception {
+        opaDecidesLikeThePolicy();
+        JwtSvid backend = jwtSvid("spiffe://example.org/backend");
+        when(workloadApiClient.validateJwtSvid("backend-token", 
INVENTORY)).thenReturn(backend);
+
+        HttpResponse<String> response = get("Bearer backend-token", 
"spiffe://example.org/auditor");
+
+        assertEquals(403, response.statusCode());
+    }
+
     @Test
     void missingTokenIsUnauthorized() throws Exception {
+        opaDecidesLikeThePolicy();
         HttpResponse<String> response = get(null, null);
 
         assertEquals(401, response.statusCode());
     }
 
+    /**
+     * The fake OPA decides like opa/inventory.rego does: the backend, on 
behalf of a caller that may read the orders.
+     */
+    private void opaDecidesLikeThePolicy() throws Exception {
+        when(opaClient.evaluate(eq(POLICY), anyMap(), 
eq(Object.class))).thenAnswer(invocation -> {
+            Map<String, Object> input = invocation.getArgument(1);
+            Map<?, ?> headers = (Map<?, ?>) input.get("headers");
+            Object caller = headers.get("CamelSpiffeSpiffeId");
+            Object onBehalfOf = headers.entrySet().stream()
+                    .filter(header -> 
"X-On-Behalf-Of".equalsIgnoreCase(String.valueOf(header.getKey())))
+                    .map(Map.Entry::getValue).findFirst().orElse(null);
+            return "spiffe://example.org/backend".equals(caller) && 
"spiffe://example.org/frontend".equals(onBehalfOf);
+        });
+    }
+
     private static HttpResponse<String> get(String authorization, String 
onBehalfOf) throws Exception {
         HttpRequest.Builder request = 
HttpRequest.newBuilder(URI.create("http://localhost:"; + PORT + 
"/api/stock")).GET();
         if (authorization != null) {

Reply via email to