From f23c8ce49cf2d6b8fe45ebd59851a94f956113d9 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Tue, 8 Sep 2026 09:52:01 +0200 Subject: [PATCH] CAMEL-24634: Authorize with camel-opa in the camel-spiffe example 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 ++ .../example/spiffe/policy/AllowList.java | 49 ----- .../spiffe/policy/WorkloadIdentityPolicy.java | 39 +++- .../src/main/resources/application.properties | 8 +- .../spiffe/backend/BackendRoutesTest.java | 63 ++++++- .../spiffe/inventory/InventoryRoutesTest.java | 40 +++- 11 files changed, 419 insertions(+), 112 deletions(-) create mode 100644 spiffe/opa/backend.rego create mode 100644 spiffe/opa/inventory.rego create mode 100644 spiffe/opa/policy_test.rego delete mode 100644 spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AllowList.java diff --git a/spiffe/README.adoc b/spiffe/README.adoc index 23f5fb153..929c8f61e 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: - +-------------------+---------------+ +-----------------------+ - 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: + +-------------------+---------------+ | +------------------+ | + frontend: orders 200, audit 403 | | + auditor: orders 403, audit 200 | may call ?| + 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 <>: 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 -`.allow.` 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 c5b250d64..9becca9ab 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 000000000..9eada2053 --- /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 000000000..113e9b17f --- /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 000000000..becb42555 --- /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 0d5a136c9..e01a34536 100644 --- a/spiffe/pom.xml +++ b/spiffe/pom.xml @@ -48,6 +48,14 @@ pom import + + + com.fasterxml.jackson + jackson-bom + ${jackson2-version} + pom + import + @@ -66,6 +74,11 @@ org.apache.camel camel-spiffe + + + org.apache.camel + camel-opa + 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 e1c5cd95c..000000000 --- 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 .allow.}. A route without an allow-list accepts nobody. - */ -public class AllowList { - - private final CamelContext camelContext; - private final String prefix; - - public AllowList(CamelContext camelContext, String service) { - this.camelContext = camelContext; - this.prefix = service + ".allow."; - } - - public boolean isAllowed(String routeId, String spiffeId) { - if (routeId == null || spiffeId == null) { - return false; - } - Optional allowedCallers = camelContext.getPropertiesComponent().resolveProperty(prefix + routeId); - return allowedCallers.stream() - .flatMap(callers -> Arrays.stream(callers.split(","))) - .map(String::trim) - .anyMatch(spiffeId::equals); - } -} diff --git a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/WorkloadIdentityPolicy.java b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/WorkloadIdentityPolicy.java index a951a7053..312e70ce6 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 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 @@ * a route configuration: every route that opts in with {@code routeConfigurationId(WorkloadIdentityPolicy.ID)} gets * these checks before its own steps run, so the routes contain business logic only. *
    - *
  • Authentication: the request must carry a JWT-SVID as bearer token, minted for this service (the audience). The - * SPIFFE Workload API checks the signature, the expiry and the audience; a failure is answered with HTTP 401.
  • - *
  • Authorization: the SPIFFE ID of the caller must be on the allow-list of the route (see {@link AllowList}), or - * the request is answered with HTTP 403.
  • + *
  • 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.
  • + *
  • 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.
  • *
  • Audit: every decision is recorded in the {@link AuditTrail} of the service.
  • *
*/ @@ -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 .audience}) and its - * allow-lists ({@code .allow.}) in the configuration + * @param service the name of the service, used to look up its audience ({@code .audience}) in the + * configuration and its policy ({@code camel/spiffe//allow}) in OPA */ public WorkloadIdentityPolicy(String service) { this.service = service; @@ -58,7 +62,6 @@ public AuditTrail getAuditTrail() { @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 void configuration() { .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 void configuration() { .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 void configuration() { .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 776b1c079..8bdf672b4 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 .allow.. -# A route without an allow-list accepts nobody -backend.allow.orders = spiffe://example.org/frontend -backend.allow.audit = spiffe://example.org/auditor -inventory.allow.stock = spiffe://example.org/backend +# 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//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 3a869839c..fce83e8e2 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.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 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 inventoryRequestHeaders = new ConcurrentHashMap<>(); + /** The last input document sent to OPA. */ + private final Map 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 @@ public void configureContext(CamelContextConfiguration camelContextConfiguration @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 @@ void frontendGetsTheOrdersWithTheStockLevels() throws Exception { 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 @@ void frontendGetsTheOrdersWithTheStockLevels() throws Exception { @Test void auditorMayNotReadTheOrders() throws Exception { + opaDecidesLikeThePolicy(); JwtSvid auditor = jwtSvid(AUDITOR, null); when(workloadApiClient.validateJwtSvid("auditor-token", BACKEND)).thenReturn(auditor); @@ -143,6 +163,7 @@ void auditorMayNotReadTheOrders() throws Exception { @Test void auditorReadsTheAuditTrail() throws Exception { + opaDecidesLikeThePolicy(); JwtSvid auditor = jwtSvid(AUDITOR, null); when(workloadApiClient.validateJwtSvid("auditor-token", BACKEND)).thenReturn(auditor); @@ -161,6 +182,7 @@ void auditorReadsTheAuditTrail() throws Exception { @Test void frontendMayNotReadTheAuditTrail() throws Exception { + opaDecidesLikeThePolicy(); JwtSvid frontend = jwtSvid(FRONTEND, null); when(workloadApiClient.validateJwtSvid("frontend-token", BACKEND)).thenReturn(frontend); @@ -171,6 +193,7 @@ void frontendMayNotReadTheAuditTrail() throws Exception { @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 @@ void invalidTokenIsUnauthorized() throws Exception { @Test void missingTokenIsUnauthorized() throws Exception { + opaDecidesLikeThePolicy(); HttpResponse response = get("/api/orders", null); assertEquals(401, response.statusCode()); @@ -194,6 +218,7 @@ void missingTokenIsUnauthorized() throws Exception { @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 @@ void unreachableInventoryIsABadGateway() throws Exception { 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 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 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 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 9eaa42805..cf7ad19e7 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.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.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 @@ protected void configure(MainConfigurationProperties configuration) { @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 @@ void backendGetsTheStockLevels() throws Exception { @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 @@ void frontendMayNotAskTheInventoryDirectly() throws Exception { response.body()); } + @Test + void backendMayNotAskOnBehalfOfSomeoneWhoMayNotReadTheOrders() throws Exception { + opaDecidesLikeThePolicy(); + JwtSvid backend = jwtSvid("spiffe://example.org/backend"); + when(workloadApiClient.validateJwtSvid("backend-token", INVENTORY)).thenReturn(backend); + + HttpResponse response = get("Bearer backend-token", "spiffe://example.org/auditor"); + + assertEquals(403, response.statusCode()); + } + @Test void missingTokenIsUnauthorized() throws Exception { + opaDecidesLikeThePolicy(); HttpResponse 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 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 get(String authorization, String onBehalfOf) throws Exception { HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://localhost:" + PORT + "/api/stock")).GET(); if (authorization != null) {