Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 129 additions & 43 deletions spiffe/README.adoc

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions spiffe/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -69,6 +85,8 @@ services:
depends_on:
spire:
condition: service_healthy
opa:
condition: service_started
inventory:
condition: service_started

Expand Down
34 changes: 34 additions & 0 deletions spiffe/opa/backend.rego
Original file line number Diff line number Diff line change
@@ -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]
}
29 changes: 29 additions & 0 deletions spiffe/opa/inventory.rego
Original file line number Diff line number Diff line change
@@ -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
}
66 changes: 66 additions & 0 deletions spiffe/opa/policy_test.rego
Original file line number Diff line number Diff line change
@@ -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"}
}
13 changes: 13 additions & 0 deletions spiffe/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand All @@ -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. -->
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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.
* <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>
*/
Expand All @@ -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;
Expand All @@ -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, ...)
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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*");
}
}
8 changes: 3 additions & 5 deletions spiffe/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading