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, 
curl -L -o opa https://github.com/open-policy-agent/opa/releases/download/v0.10.3/opa_linux_amd64 
Make it executable,
chmod 755 ./opa
Once done, we can start OPA policy engine as a server.
./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.
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.
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.

#!/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__)

opa_url = os.environ.get("OPA_ADDR", "http://localhost:8181")
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'.

python echo_server.py
Now we can try to call this API served by this python server and see the authorization policy in action.
curl --user alice:password localhost:5000/finance/salary/alice
Above is allowed based on the 1st rule, user trying to read own salary.
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.
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.
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.

Popular posts from this blog

Signing SOAP Messages - Generation of Enveloped XML Signatures

Tomcat JDBC Pool - Connection Leak - Catch the Culprit

How to convert WSDL to Java