OPA for HTTP Authorization
Open Policy Agent[1] is a promising, light weight and very generic policy engine to govern authorization is any type of domain. I found this comparion[2] very attractive in evaluating OPA for a project I am currently working on, where they demonstrate how OPA can cater same functionality defined in RBAC, RBAC with Seperation of Duty, ABAC and XACML.
Here are the steps to a brief demonstration of OPA used for HTTP API authorization based on the sample [3], taking it another level up.
Running OPA Server
First we need to download OPA from [4], based on the operating system we are running on.
For linux,
1 | curl -L -o opa https: //github.com/open-policy-agent/opa/releases/download/v0.10.3/opa_linux_amd64 |
Make it executable,
1 | chmod 755 ./opa |
Once done, we can start OPA policy engine as a server.
1 | ./opa run --server |
Define Data and Rules
Next we need to load data and authorization rules to the server, so it can make decisions. OPA defines these in files in the format of .rego. Below is a sample file I used.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | package httpapi.authz subordinates = { "alice" : [], "charlie" : [], "bob" : [ "alice" ], "betty" : [ "charlie" ]} # HTTP API request import input as http_api # http_api = { # "path" : [ "finance" , "salary" , "alice" ], # "user" : "alice" , # "method" : "GET" # "user_agent" : "cURL/1.0" # "remote_addr" : "127.0.0.1" # } default allow = false # Allow users to get their own salaries. allow { http_api.method = "GET" http_api.path = [ "finance" , "salary" , username] username = http_api.user } # Allow managers to get their subordinates' salaries. allow { http_api.method = "GET" http_api.path = [ "finance" , "salary" , username] subordinates[http_api.user][_] = username } # Allow managers to edit their subordinates' salaries only if the request came # from user agent cURL and address 127.0.0.1. allow { http_api.method = "POST" http_api.path = [ "finance" , "salary" , username] subordinates[http_api.user][_] = username http_api.remote_addr = "127.0.0.1" http_api.user_agent = "curl/7.47.0" } |
At first it defines a data set, which represents the relationship subordinates. For example as per this dataset, alice is a subordinate of bob. Then it defines 3 rules that will give feedback as ‘allow’.
- If user tries to get own salary it is allowed.
- If a user tries to get the salary of a subordinate it is allowed.
- If a user tries to modify the salary, it is allowed only if it is of a subordinate, request is initiated from remote address ‘127.0.0.1’ and user agent ‘curl/7.47.0’
To load this policy into the OPA engine we use below call.
1 | curl -X PUT --data-binary @salary-example.rego localhost:8181/v1/policies/example |
The above policy is stored into a file named ‘salary-example.rego’ and referred in the above command.
Evaluate at API Invocation
Below is a sample API implementation in python, that consults the OPA engine on the decision whether to provide a response or deny as unauthorized.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | #!/usr/bin/env python import base64 import os from flask import Flask from flask import request import json import requests import logging import sys logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) app = Flask(__name__) policy_path = os.environ.get( "POLICY_PATH" , "/v1/data/httpapi/authz" ) def check_auth(url, user, method, user_agent, remote_addr,url_as_array, token): input_dict = { "input" : { "user" : user, "path" : url_as_array, "method" : method, "user_agent" : user_agent, "remote_addr" : remote_addr }} if token is not None: input_dict[ "input" ][ "token" ] = token logging.info( "Checking auth..." ) logging.info(json.dumps(input_dict, indent=2)) try : rsp = requests.post(url, data=json.dumps(input_dict)) except Exception as err: logging.info(err) return {} if rsp.status_code >= 300: logging.info( "Error checking auth, got status %s and message: %s" % (j.status_code, j.text)) return {} j = rsp.json() logging.info( "Auth response:" ) logging.info(json.dumps(j, indent=2)) return j @app.route( '/' , defaults={ 'path' : '' }, methods = [ 'GET' , 'POST' , 'DELETE' ]) @app.route( '/<path:path>' , methods = [ 'GET' , 'POST' ]) def root(path): user_encoded = request.headers.get( 'Authorization' , "Anonymous:none" ) logging.info( "User Agent: %s" % request.user_agent.string) logging.info( "Remote Address: %s" % request.remote_addr) if user_encoded: user_encoded = user_encoded.split( "Basic " )[1] user, _ = base64.b64decode(user_encoded).decode( "utf-8" ).split( ":" ) url = opa_url + policy_path path_as_array = path.split( "/" ) token = request.args[ "token" ] if "token" in request.args else None j = check_auth(url, user, request.method, request.user_agent.string, request.remote_addr, path_as_array, token).get( "result" , {}) if j.get( "allow" , False) == True: return "Success: user %s is authorized \n" % user return "Error: user %s is not authorized to %s url /%s \n" % (user, request.method, path) if __name__ == "__main__" : app.run() |
The function ‘check_auth’ is responsible to retreive the decision from OPA engine, providing the input details required for authorization. Run the above python script with below command. It uses python modules ‘flask’ and ‘request’.
1 | python echo_server.py |
Now we can try to call this API served by this python server and see the authorization policy in action.
1 | curl --user alice:password localhost:5000/finance/salary/alice |
Above is allowed based on the 1st rule, user trying to read own salary.
1 | curl --user bob:password localhost:5000/finance/salary/alice |
Above is allowed based on the 2nd rule, user trying to read the salary of a subordinate.
1 | curl -X POST -d "empoyeeID=100&value=2000" --user bob:password localhost:5000/finance/salary/alice |
This will be allowed based on the 3rd rule, if the user agent also matches the exact same cURL client version we have defined in the policy.
1 | curl -X POST -d "empoyeeID=100&value=2000" --user bob:password localhost:5000/finance/salary/alice |
Even though the previous request was allowed for bob to edit alice’s salary, the above request is failed as a user cannot modify own salary based on the defined rule.
This was a good excercise to understand the power and the behavior of OPA which enjoyed. Hope you too. Cheers!
[1] –https://www.openpolicyagent.org
[2] –https://www.openpolicyagent.org/docs/comparison-to-other-systems.html
[3] –https://www.openpolicyagent.org/docs/http-api-authorization.html
[4] –https://github.com/open-policy-agent/opa/releases
Published on Web Code Geeks with permission by Pushpalanka, partner at our WCG program. See the original article here: OPA for HTTP Authorization Opinions expressed by Web Code Geeks contributors are their own. |