- A 64-bit Linux environment (AMD, Intel, or Arm).
- Basic familiarity with Docker containers and
docker
commands. - jq installed on your device.
Coastal Containers Ltd. is piloting a new system to manage access to their port-records
database, which contains vital
information about vessel manifests. Fleet Supervisors and Ship Captains alike need to access these manifests for
operational and logistical purposes. However, Coastal Containers wants to ensure that the captains can only access
records relevant to their assigned vessels, while fleet supervisors, due to their managerial role, should have
unrestricted access to all records.
In order to implement the aforementioned business logic, Coastal Containers Ltd. has opted to explore the usage of Open Policy Agent, an open source, general purpose policy engine. Open Policy Agent (aka OPA) is a tool which can make policy decisions on structured data using the Rego policy language. Throughout the course of this hands-on exercise, we will be using Rego to implement authorization through Open Policy Agent.
- Design Architecture: To better understand how OPA works, you can check out the OPA Overview, which outlines the design architecture for Open Policy Agent and provides a simple example to demonstrate how it all works.
- Rego: As mentioned previously, Rego is the native policy language used by OPA, which allows for convenient data querying and policy definitions. Rego is built to be declarative, allowing authors to focus on outcome as opposed to execution. Before diving into this exercise, it is recommended to check out the Policy Language and Policy Reference documentation to build a baseline understanding of how Rego works. On top of these resources, OPA offers the OPA Playground, an interactive environment where you can explore packaged examples, test your own Rego policies, and validate their output.
The logic behind policy decisions is fairly straightforward for this scenario:
-
Fleet Supervisors: Should have unrestricted access to view all vessel records in the
port-records
database. -
Ship Captains: Should only access records of vessels assigned to them, ensuring they cannot view or modify records irrelevant to their operational duties.
📝Note: For the purposes of our demonstration, we will abstract the details of where and how policy decisions are enforced.
In a more complex example, policy decisions are often enforced by Policy Enforcement Points (PEP), which could be something like a network proxy filtering traffic before it hits the application (e.g., Envoy Proxy). However, in our use-case example, all you need to know is that HTTP requests are made to the 'example' application, and contain the following:
- a
bearer
field containing a JWT (and associated claims) about the authenticated calling user. - a
vessel_id
field which represents the Coastal Containers vessel record being accessed. - an
action
to be performed on the record, such asread
,update
, ordelete
. For the purposes of this scenario, - we won't go into depth about the different actions, and will stick with
read
as a basis for testing policy decisions.
The port_data
stored within the port-records
database is made available to OPA as an
input, which we will explore in-depth later
in this lab.
Before you cast off, prepare your ships to sail by setting your working directory in lab-06-opa-basics as an environment variable:
export LAB_DIR=$(pwd)
This will make issuing commands easier in the following steps of this exercise, and will reduce the possibility of reference errors.
To simulate user authentication in our simplified scenario, we will need to create example JWTs (JSON Web Tokens). In
order to do so, we will invoke the inbuilt io.jwt.encode_sign
function in Rego. Explore how this is done within the
create_jwt.rego file, and utilize the
Policy Reference as necessary.
Before we can create the example JWTs, we must understand how the create_jwt.rego works with Rego.
At a baseline, this policy file works to generate JWTs with pre-specified claims that indicate the fleet_supervisor
or ship_captain
role(s), and other relevant metadata. At the beginning, the
create_jwt.rego file is initialized with a hierarchical package
name, ensuring that Rego policies
and rules are organized based on their functionality. After this, the built-in functions [ceil] and [time.now_ns], are
used to define an expiry_time
for our JWTs. This expiry_time
is set to one day from the point of creation, however,
this should NOT be used in production scenarios as JWTs are intended to be short-lived.
Throughout this file, you will notice the usage of equality operators such as :=
, ==
, & =
. Natively, Rego supports
three kinds of equality:
-
Assignment (
:=
) is used to assign values to variables. Assigned variables are locally scoped to the rule that they are set within and 'shadow' global variables. An in-depth explanation of this works can be found here on the official docs. -
Comparison (
==
) is used to check if two values are equal within a rule, this is recursive and semantic. An in-depth explanation of how this works can be found here on the official docs. -
Unification (
=
) is used to combine assignment and comparison. Rego will assign one or more variables to make the defined comparison true, effectively letting you query values for variables that make an expression true. An in-depth explanation of how this works can be found here on the official docs.
Now, with the expiry_time
set for our example tokens, we can now create JWTS for the Fleet Supervisor and Ship Captain
using the invoked io.jwt.encode_sign
function. Keep in mind that we have hardcoded an RSA key pair in the
create_jwt.rego file, this is NOT safe for production and should only be used for demonstration
purposes as the RSA private key can be easily stolen.
To obtain the Fleet Supervisor's JWT (spv_token
), try using the
opa eval command by running OPA in a Docker container:
docker run --rm -v ${LAB_DIR}:/example openpolicyagent/opa:latest eval \
-d /example/create_jwt.rego 'data.example.jwt.spv_token' | jq '.result[0].expressions[0].value'
Functionally, this command loads the create_jwt.rego into OPA using the -d
or --data
flag, and
queries the value of data.example.jwt.spv_token
. This works through the
OPA Document Model, demonstrating how
the hierarchical package
and associated rules sit under OPA's data
document.
The expected output should be:
"eyJhbGciOiAiUlMyNTYifQ.eyJhdWQiOiAicG9ydC1yZWNvcmRzIiwgImV4cCI6IDE2OTcwMzc1NDksICJpc19zdXBlcnZpc29yIjogdHJ1ZSwgImlzcyI6ICJodHRwczovL2lkcC5jb2FzdGFsLWNvbnRhaW5lcnMuZXhhbXBsZSIsICJzdWIiOiAiZmxlZXRfc3VwZXJ2aXNvciJ9.OKq69mj0Z22l4I2pWKSr4xaErVKBEQcdOaCYUi3sckUmjixYFb4nZGRXFp2eSPlYdhDiqldgBkrE9W1--8Soluemamg1WHd4jrPtKwHKwFHPAkrH4TUTHJ-3wXIeWr8WRXDiulvBOAd2w4Wmq0fMUo3iwTnN5M67dBUmtqSX03tkwnL7QdIHUwpTGYaBm79N5RiOo_vw7HPtkZv6nLTd0LYT9jui_EpL4l-jHQxlp8omuI9FupjHkA1tWRtEh3ny_prSgntV1X277_EkWmJh0TrORQDoZ390gxaDSTcvfxxIdICsdogG_UT4mBPhqalAByUigVgTmgngykm4qSsfxw"
To verify the token includes the defined claims, copy and paste the outputted JWT into the Encoded
field of
jwt.io. This operation will show the subsequently decoded fields or 'claims', such as the
is_supervisor
claim which denotes if a role is a Fleet Supervisor per our scenario's business logic. For the Ship
Captain business logic, however, we will need to better understand how OPA manages external data.
In the previous step, we have walked through a simple demonstration of how OPA can handle external data via JWT tokens.
However, per our scenario of the port-records
database which represents a relatively static store of vessel
information, which should change infrequently, and can be reasonably stored in-memory all at once, we can replicate it
in bulk via OPA's bundle feature.
Through this approach, both policies and external data can be added to a bundle (tar.gz
file). OPA can then consume
the packaged bundle via a bundle server.
Reference our example bundle directory to see where our external data sits within OPA's data
document.
Within our setup, the data.json file represents the port-records
database, and is
located within the port_data directory. This means that specific role data can be accessed at
data.port_data.roles
, or data.port_data.vessels
for vessel-related queries.
Keeping the data structure and example JWTs in mind, we are now in a position to write and define policies implementing
Coastal Containers business logic. First, inspect the policy.rego file to see how
we intend to do this. Notice that the outcome of our policy decision(s) will be encapsulated in the value of allow
.
Due to the hierarchical nature of packages and the OPA data
document, this information becomes available at
data.example.authz.allow
.
For Coastal Containers Ltd., the input provided to OPA will adhere to the following structure:
{ "input": { "bearer": "<JWT>", "action": "read", "vessel_id": 1 } }
Within our policy, we can refer to input.bearer
, input.action
, and input.employee_id
. However, adding an import
statement at the top of the policy file (e.g., import input.bearer
), means we can refer to bearer
in the
encapsulated Rego rules.
Fundamentally, OPA policies are formed as a collection of rules, where rules take the form of
assignment if { condition(s) }
. An example of this within our policy is:
allow {
token_is_valid
role_is_supervisor
}
The rule body between {}
is a collection of assignments and expressions. allow
will evaluate to true if a logical
AND of all the assignments and expressions is true. If an assignment is false or undefined, allow is also undefined.
As such, we need to set default allow := false
in the policy, so that allow
can only be true if one of the rules
evaluates to true - otherwise it will be false, but never undefined. In this way, multiple allow
rules represent a
logical OR.
You are encouraged to read through and understand the rest of the policy, referring out to the OPA documentation if necessary.
With a baseline understanding of how the underlying tooling works, and how we plan to implement it, we can start this demo by building the policy and data bundle.
To do so, run the following docker command:
docker run --rm -v ${LAB_DIR}:/example openpolicyagent/opa:latest build \
--bundle /example/bundle \
-o /example/bundle.tar.gz
If you are running rootless docker, you will need to set the user to root to enable writing to the directory.
docker run --rm --user 0:0 -v ${LAB_DIR}:/example openpolicyagent/opa:latest build \
--bundle /example/bundle \
-o /example/bundle.tar.gz
Once this is completed, you should see the packaged bundle.tar.gz
file within the root
lab-06-opa-basics directory.
Now, to get OPA up and running locally, we will run OPA as a server in a Docker container:
docker run --name opa-server --rm -p 8181:8181 -d -v ${LAB_DIR}:/example \
openpolicyagent/opa:latest run --server \
--bundle /example/bundle.tar.gz \
--addr 0.0.0.0:8181
Once executed, you should see an outputted container ID like this:
f3b4d5da79909e1f577a34dfab869517c58a8814e88c10274b2bc7e6bb572bb1
This indicates that the server is running as a docker container (in the background via the -d
detach flag), and
listening on http://0.0.0.0:8181
. To view the logs of the newly created opa-server
container, run:
docker logs opa-server
You should see the output:
{"addrs":["0.0.0.0:8181"],"diagnostic-addrs":[],"level":"info","msg":"Initializing server.","time":"2023-10-10T15:10:57Z"}
After the opa-server
container begins waiting for requests, we can move onto the next steps.
📝Note: To view the list of running Docker containers on your device, you can run:
docker ps
Navigate to the root lab-06-opa-basics directory by running:
cd $LAB_DIR
Once here, we will export our Fleet Supervisor & Ship Captain JWTs as environment variables for the sake of convenience and re-usability.
To do this for the Fleet Supervisor token (spv_jwt
), run:
export SPV_JWT=$(docker run -v ${LAB_DIR}:/example openpolicyagent/opa:latest eval \
-d /example/create_jwt.rego 'data.example.jwt.spv_token' | jq '.result[0].expressions[0].value')
To view the claims execute the following command
jq -R 'split(".") | .[1] | @base64d | fromjson' <<< $SPV_JWT
{
"aud": "port-records",
"exp": 1706968419,
"is_supervisor": true,
"iss": "https://idp.coastal-containers.example",
"sub": "fleet_supervisor"
}
To do this for the Ship Captain token (cpt_jwt
), run:
export CPT_JWT=$(docker run -v ${LAB_DIR}:/example openpolicyagent/opa:latest eval \
-d /example/create_jwt.rego 'data.example.jwt.cpt_token' | jq '.result[0].expressions[0].value')
To view the claims execute the following command
jq -R 'split(".") | .[1] | @base64d | fromjson' <<< $CPT_JWT
{
"aud": "port-records",
"exp": 1706968440,
"is_supervisor": false,
"iss": "https://idp.coastal-containers.example",
"sub": "ship_captain"
}
Functionally, these commands are loading the create_jwt.rego file into OPA via the -d
or --data
flag, and querying the tokens stored at data.example.jwt.spv_token
& data.example.jwt.cpt_token
. As mentioned
previously, this query utilizes the hierarchical nature of OPA packages (specifically the example.jwt
package).
In order to evaluate the policy decisions per our business logic, we can make POST requests to the OPA server in the following format:
curl -X POST -H "Content-Type: application/json" \
-d '{"input": {"bearer": '"$CPT_JWT"', "action": "read", "vessel_id": 1}}' 0.0.0.0:8181/v1/data/example/authz/allow
📝Note: Through the form of this POST request, we are providing the relevant input via the parameters in the body of the
request, and we are querying the value of data.example.authz.allow
via OPA's
Data API.
Now, to apply this request format and validate our scenario's business logic, try answering the following questions by issuing the associated POST requests.
Can the Fleet Supervisor view the maritime-mover
vessel?
curl -X POST -H "Content-Type: application/json" \
-d '{"input": {"bearer": '"$SPV_JWT"', "action": "read", "vessel_id": 1}}' 0.0.0.0:8181/v1/data/example/authz/allow
To further test our business logic, try this command again and run it for "vessel_id": 2
& "vessel_id": 3
, ensuring
the Fleet Supervisor can view all of them.
The expected output is:
{"result":true}
Next, can the Ship Captain view it's assigned vessel (maritime-mover
)?
curl -X POST -H "Content-Type: application/json" \
-d '{"input": {"bearer": '"$CPT_JWT"', "action": "read", "vessel_id": 1}}' 0.0.0.0:8181/v1/data/example/authz/allow
The expected output is:
{"result":true}
Finally, can the Ship Captain view a vessel it is not assigned to?
curl -X POST -H "Content-Type: application/json" \
-d '{"input": {"bearer": '"$CPT_JWT"', "action": "read", "vessel_id": 2}}' 0.0.0.0:8181/v1/data/example/authz/allow
To further test our business logic, try this command again and run it for "vessel_id": 3
, ensuring the Ship Captain
can only see the assigned maritime-mover
vessel.
The expected output is:
{"result":false}
Keep in mind that, at anytime, you can view the opa-server
logs by running:
docker logs -f opa-server
This command will 'follow' the opa-server
Docker container logs via the -f
or --follow
flag, and should provide an
output similar to:
{"addrs":["0.0.0.0:8181"],"diagnostic-addrs":[],"level":"info","msg":"Initializing server.","time":"2023-10-10T20:09:10Z"}
{"client_addr":"172.17.0.1:44962","level":"info","msg":"Received request.","req_id":1,"req_method":"POST","req_path":"/v1/data/example/authz/allow","time":"2023-10-10T20:14:51Z"}
{"client_addr":"172.17.0.1:44962","level":"info","msg":"Sent response.","req_id":1,"req_method":"POST","req_path":"/v1/data/example/authz/allow","resp_bytes":16,"resp_duration":1.279826,"resp_status":200,"time":"2023-10-10T20:14:51Z"}
{"client_addr":"172.17.0.1:44968","level":"info","msg":"Received request.","req_id":2,"req_method":"POST","req_path":"/v1/data/example/authz/allow","time":"2023-10-10T20:14:57Z"}
{"client_addr":"172.17.0.1:44968","level":"info","msg":"Sent response.","req_id":2,"req_method":"POST","req_path":"/v1/data/example/authz/allow","resp_bytes":16,"resp_duration":0.926202,"resp_status":200,"time":"2023-10-10T20:14:57Z"}
{"client_addr":"172.17.0.1:35082","level":"info","msg":"Received request.","req_id":3,"req_method":"POST","req_path":"/v1/data/example/authz/allow","time":"2023-10-10T20:15:10Z"}
{"client_addr":"172.17.0.1:35082","level":"info","msg":"Sent response.","req_id":3,"req_method":"POST","req_path":"/v1/data/example/authz/allow","resp_bytes":16,"resp_duration":1.253941,"resp_status":200,"time":"2023-10-10T20:15:10Z"}
{"client_addr":"172.17.0.1:57136","level":"info","msg":"Received request.","req_id":4,"req_method":"POST","req_path":"/v1/data/example/authz/allow","time":"2023-10-10T20:15:14Z"}
{"client_addr":"172.17.0.1:57136","level":"info","msg":"Sent response.","req_id":4,"req_method":"POST","req_path":"/v1/data/example/authz/allow","resp_bytes":17,"resp_duration":0.916282,"resp_status":200,"time":"2023-10-10T20:15:14Z"}
For those interested in diving deeper into how OPA can integrate with SPIRE and manage policy decisions with Rego, you are encouraged to check out the envoy-jwt-opa & envoy-opa demo exercises located in the spire-tutorials repository. Alternatively, you can try your hand at expanding on this demo by building more roles, vessel assignments, and policy decisions. Can you get the policies to work as-expected with your new configurations?
To kill the opa-server
Docker container, run:
docker kill opa-server
Next, navigate to the root lab directory and remove the bundle.tar.gz
tarball:
cd $LAB_DIR && rm bundle.tar.gz
Congratulations aspiring captain! You have helped Coastal Containers Ltd. venture into unexplored waters by running a
simple implementation of Open Policy Agent providing policy decisions to their port-records
datastore. This is a
small, but important step towards implementing a robust authorization system into their current shipping systems. In the
next lab, we will explore how to bridge turbulent seas and integrate OPA with SPIRE.
To learn more about Open Policy Agent and the Rego policy language, you are highly encouraged to explore the official documentation, and the existing OPA Ecosystem which outlines current integrations with the policy engine.