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) {