HTTP API Authorization

Anything that exposes an HTTP API (whether an individual microservice or an application as a whole) needs to control who can run those APIs and when. OPA makes it easy to write fine-grained, context-aware policies to implement API authorization.

Goals

In this tutorial, you’ll use a simple HTTP web server that accepts any HTTP GET request that you issue and echoes the OPA decision back as text. Both OPA and the web server will be run as containers.

For this tutorial, our desired policy is:

  • People can see their own salaries (GET /finance/salary/{user} is permitted for {user})
  • A manager can see their direct reports’ salaries (GET /finance/salary/{user} is permitted for {user}’s manager)

Prerequisites

This tutorial requires Docker Compose to run a demo web server along with OPA.

Steps

1. Bootstrap the tutorial environment using Docker Compose.

First, create a docker-compose.yml file that runs OPA and the demo web server.

docker-compose.yml:

  1. version: '2'
  2. services:
  3. opa:
  4. image: openpolicyagent/opa:0.12.2
  5. ports:
  6. - 8181:8181
  7. # WARNING: OPA is NOT running with an authorization policy configured. This
  8. # means that clients can read and write policies in OPA. If you are
  9. # deploying OPA in an insecure environment, be sure to configure
  10. # authentication and authorization on the daemon. See the Security page for
  11. # details: https://www.openpolicyagent.org/docs/security.html.
  12. command:
  13. - "run"
  14. - "--server"
  15. - "--log-level=debug"
  16. api_server:
  17. image: openpolicyagent/demo-restful-api:0.2
  18. ports:
  19. - 5000:5000
  20. environment:
  21. - OPA_ADDR=http://opa:8181
  22. - POLICY_PATH=/v1/data/httpapi/authz

Then run docker-compose to pull and run the containers.

  1. docker-compose -f docker-compose.yml up

Every time the demo web server receives an HTTP request, it asks OPA to decide whether an HTTP API is authorized or not using a single RESTful API call. An example code is here, but the crux of the (Python) code is shown below.

  1. # Grab basic information. We assume user is passed on a form.
  2. http_api_user = request.form['user']
  3. # Get the path as a list (removing leading and trailing /)
  4. # Example: "/finance/salary/" will become ["finance", "salary"]
  5. http_api_path_list = request.path.strip("/").split("/")
  6. input_dict = { # create input to hand to OPA
  7. "input": {
  8. "user": http_api_user,
  9. "path": http_api_path_list, # Ex: ["finance", "salary", "alice"]
  10. "method": request.method # HTTP verb, e.g. GET, POST, PUT, ...
  11. }
  12. }
  13. # ask OPA for a policy decision
  14. # (in reality OPA URL would be constructed from environment)
  15. rsp = requests.post("http://127.0.0.1:8181/v1/data/httpapi/authz", json=input_dict)
  16. if rsp.json()["allow"]:
  17. # HTTP API allowed
  18. else:
  19. # HTTP API denied

2. Load a policy into OPA.

In another terminal, create a policy that allows users to request their own salary as well as the salary of their direct subordinates.

  1. cat >example.rego <<EOF
  2. package httpapi.authz
  3. # bob is alice's manager, and betty is charlie's.
  4. subordinates = {"alice": [], "charlie": [], "bob": ["alice"], "betty": ["charlie"]}
  5. # HTTP API request
  6. import input
  7. default allow = false
  8. # Allow users to get their own salaries.
  9. allow {
  10. some username
  11. input.method == "GET"
  12. input.path = ["finance", "salary", username]
  13. input.user == username
  14. }
  15. # Allow managers to get their subordinates' salaries.
  16. allow {
  17. some username
  18. input.method == "GET"
  19. input.path = ["finance", "salary", username]
  20. subordinates[input.user][_] == username
  21. }
  22. EOF

Then load the policy via OPA’s REST API.

  1. curl -X PUT --data-binary @example.rego \
  2. localhost:8181/v1/policies/example

3. Check that alice can see her own salary.

The following command will succeed.

  1. curl --user alice:password localhost:5000/finance/salary/alice

4. Check that bob can see alice’s salary (because bob is alice’s manager.)

  1. curl --user bob:password localhost:5000/finance/salary/alice

5. Check that bob CANNOT see charlie’s salary.

bob is not charlie’s manager, so the following command will fail.

  1. curl --user bob:password localhost:5000/finance/salary/charlie

6. Change the policy.

Suppose the organization now includes an HR department. The organization wants members of HR to be able to see any salary. Let’s extend the policy to handle this.

  1. cat >example-hr.rego <<EOF
  2. package httpapi.authz
  3. import input
  4. # Allow HR members to get anyone's salary.
  5. allow {
  6. input.method == "GET"
  7. input.path = ["finance", "salary", _]
  8. input.user == hr[_]
  9. }
  10. # David is the only member of HR.
  11. hr = [
  12. "david",
  13. ]
  14. EOF

Upload the new policy to OPA.

  1. curl -X PUT --data-binary @example-hr.rego \
  2. http://localhost:8181/v1/policies/example-hr

For the sake of the tutorial we included manager_of and hr data directly inside the policies. In real-world scenarios that information would be imported from external data sources.

7. Check that the new policy works.

Check that david can see anyone’s salary.

  1. curl --user david:password localhost:5000/finance/salary/alice
  2. curl --user david:password localhost:5000/finance/salary/bob
  3. curl --user david:password localhost:5000/finance/salary/charlie
  4. curl --user david:password localhost:5000/finance/salary/david

8. (Optional) Use JSON Web Tokens to communicate policy data.

OPA supports the parsing of JSON Web Tokens via the builtin function io.jwt.decode. To get a sense of one way the subordinate and HR data might be communicated in the real world, let’s try a similar exercise utilizing the JWT utilities of OPA.

Shut down your docker-compose instance from before with ^C and then restart it to ensure you are working with a fresh instance of OPA.

Then update the policy:

  1. cat >example.rego <<EOF
  2. package httpapi.authz
  3. import input
  4. # io.jwt.decode takes one argument (the encoded token) and has three outputs:
  5. # the decoded header, payload and signature, in that order. Our policy only
  6. # cares about the payload, so we ignore the others.
  7. token = {"payload": payload} { io.jwt.decode(input.token, [_, payload, _]) }
  8. # Ensure that the token was issued to the user supplying it.
  9. user_owns_token { input.user == token.payload.azp }
  10. default allow = false
  11. # Allow users to get their own salaries.
  12. allow {
  13. some username
  14. input.method == "GET"
  15. input.path = ["finance", "salary", username]
  16. token.payload.user == username
  17. user_owns_token
  18. }
  19. # Allow managers to get their subordinate' salaries.
  20. allow {
  21. some username
  22. input.method == "GET"
  23. input.path = ["finance", "salary", username]
  24. token.payload.subordinates[_] == username
  25. user_owns_token
  26. }
  27. # Allow HR members to get anyone's salary.
  28. allow {
  29. input.method == "GET"
  30. input.path = ["finance", "salary", _]
  31. token.payload.hr == true
  32. user_owns_token
  33. }
  34. EOF

And load it into OPA:

  1. curl -X PUT --data-binary @example.rego \
  2. localhost:8181/v1/policies/example

For convenience, we’ll want to store user tokens in environment variables (they’re really long).

  1. export ALICE_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWxpY2UiLCJhenAiOiJhbGljZSIsInN1Ym9yZGluYXRlcyI6W10sImhyIjpmYWxzZX0.rz3jTY033z-NrKfwrK89_dcLF7TN4gwCMj-fVBDyLoM"
  2. export BOB_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYm9iIiwiYXpwIjoiYm9iIiwic3Vib3JkaW5hdGVzIjpbImFsaWNlIl0sImhyIjpmYWxzZX0.n_lXN4H8UXGA_fXTbgWRx8b40GXpAGQHWluiYVI9qf0"
  3. export CHARLIE_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiY2hhcmxpZSIsImF6cCI6ImNoYXJsaWUiLCJzdWJvcmRpbmF0ZXMiOltdLCJociI6ZmFsc2V9.EZd_y_RHUnrCRMuauY7y5a1yiwdUHKRjm9xhVtjNALo"
  4. export BETTY_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYmV0dHkiLCJhenAiOiJiZXR0eSIsInN1Ym9yZGluYXRlcyI6WyJjaGFybGllIl0sImhyIjpmYWxzZX0.TGCS6pTzjrs3nmALSOS7yiLO9Bh9fxzDXEDiq1LIYtE"
  5. export DAVID_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiZGF2aWQiLCJhenAiOiJkYXZpZCIsInN1Ym9yZGluYXRlcyI6W10sImhyIjp0cnVlfQ.Q6EiWzU1wx1g6sdWQ1r4bxT1JgSHUpVXpINMqMaUDMU"

These tokens encode the same information as the policies we did before (bob is alice’s manager, betty is charlie’s, david is the only HR member, etc). If you want to inspect their contents, start up the OPA REPL and execute io.jwt.decode(<token here>, [header, payload, signature]).

Let’s try a few queries (note: you may need to escape the ? characters in the queries for your shell):

Check that charlie can’t see bob’s salary.

  1. curl --user charlie:password localhost:5000/finance/salary/bob?token=$CHARLIE_TOKEN

Check that charlie can’t pretend to be bob to see alice’s salary.

  1. curl --user charlie:password localhost:5000/finance/salary/alice?token=$BOB_TOKEN

Check that david can see betty’s salary.

  1. curl --user david:password localhost:5000/finance/salary/betty?token=$DAVID_TOKEN

Check that bob can see alice’s salary.

  1. curl --user bob:password localhost:5000/finance/salary/alice?token=$BOB_TOKEN

Check that alice can see her own salary.

  1. curl --user alice:password localhost:5000/finance/salary/alice?token=$ALICE_TOKEN

Wrap Up

Congratulations for finishing the tutorial!

You learned a number of things about API authorization with OPA:

  • OPA gives you fine-grained policy control over APIs once you set up the server to ask OPA for authorization.
  • You write allow/deny policies to control which APIs can be executed by whom.
  • You can import external data into OPA and write policies that depend on that data.
  • You can use OPA data structures to define abstractions over your data.

The code for this tutorial can be found in the open-policy-agent/contrib repository.