Web authorization with Open Policy Agent

28 April 2019

So maybe you upgraded our authentication logic to no longer be a system bottleneck but now find authorization slowing you down, or have custom authorization logic mixed in with business logic - then Open Policy Agent can help! If you have not heard of Open Policy Agent, it is really worth checking out, it is a declarative way to push authorization outside your business logic in an extremely scalable way.

opa admin all on

Overview:

Architecture

One of the things that makes Open Policy Agent interesting is that we can embrace the sidecar pattern with it. Each node in our system will be running an instance of Open Policy Agent, allowing any service to make a request to localhost to check authorization, making this solution extremely scalable. Agents can point to a location to fetch current policies on startup, periodically fetch, and be triggered to re-fetch, which gives us some options for keeping them in sync.

Integrating with a Golang server

What is the goal? Just an additional middleware call that checks permissions for the routes. The most our service will know about authorization will be adding the middleware authz

func main() {
	e := echo.New()

	e.GET("/accounts/:id", getAccount)
	e.GET("/rewards/:id/redeem", getAccount)

	e.Use(authz)

	e.Logger.Fatal(e.Start(":1323"))
}

working with echo, our middleware looks something like this:

func authz(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		if isAuthorized(c) {
			return next(c)
		}
		return echo.NewHTTPError(http.StatusUnauthorized, "Please provide valid credentials")
	}
}

to check isAuthorized, we construct a payload to send to OPA, send it, then check if any of the policies returned true for the route. The payload returns policy name + result where we could check which policy matched, but in this example we only care if any matched.

type user struct {
	UserID    string   `json:"userID"`
	Employees []string `json:"employees"`
	Role      string   `json:"role"`
}

func isAuthorized(c echo.Context) bool {
	u := unmarshalUser(c.Request().Header.Get("JWT"))

	authzRequestPayload := map[string]map[string]interface{}{
		"input": {
			"userID":    u.UserID,
			"method":    c.Request().Method,
			"path":      trimPath(c.Request().URL.Path),
			"employees": u.Employees,
			"role":      u.Role,
		},
	}

	response := checkAuthz(authzRequestPayload)

	for _, v := range response.Result {
		if v == true {
			return true
		}
	}
	return false
}

OPA uses the sidecar pattern, so we will use an http call to localhost to check the requester permissions. checkAuthz will just me making a request to a OPA agent on localhost

func checkAuthz(values map[string]map[string]interface{}) opaResponse {
	jsonValue, errm := json.Marshal(values)
	if errm != nil {
		panic(errm)
	}

	opaURL := "http://localhost:8181/v1/data/httpapi/authz"
	resp, errp := http.Post(opaURL, "application/json", bytes.NewBuffer(jsonValue))
	if errp != nil {
		panic(errp)
	}
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		panic(err.Error())
	}

	var response opaResponse
	if errj := json.Unmarshal(body, &response); errj != nil {
		panic(errj)
	}

	return response
}

Writing and testing policies

Time to write some policies! So desired goal will be:

  • anyone (employees) can access their own account
  • managers can access their employees account
  • premium users can access rewards for their account

For our policies, we will start with default allowAccounts = false to default deny, as well as assigning the requesting input payload as http_api. The next block allows the "GET" method for the path "accounts/:userID", where :userID is assigned from the input payload.

package httpapi.authz

import input as http_api

default allowAccounts = false

allowAccounts {
  http_api.method = "GET"
  http_api.path = ["accounts", userID]
  userID = http_api.userID
}

So for an input {"userID": "a"}, we authorize access to "accounts/a", and deny for all others. We can even write a test around this, testing both the allowed and denied case:

package httpapi.authz

test_get_anonymous_denied {
    not allowAccounts with input as {
        "path": ["accounts", "a"],
        "method": "GET",
        "userID": "b"
    }
}

test_get_allowed {
    allowAccounts with input as {
        "path": ["accounts", "a"],
        "method": "GET",
        "userID": "a"
    }
}

Next policy is for the managers, we use http_api.employees[_] = userID to wildcard any field in the employees array to the value userID, using that for the allowed path "accounts/:userID".

package httpapi.authz

import input as http_api

default allowManagers = false

allowManagers {
  http_api.method = "GET"
  http_api.path = ["accounts", userID]
  http_api.employees[_] = userID
}

So for an input {"employees": ["x"]}, we authorize access to "accounts/x, for any value of x.

For the premium role, we start by checking for premium http_api.role = "premium". We then assign userID from the input, which we use for checking the path "rewards/:userID/redeem". Lastly we check if the api method is either GET/POST by checking array inclusion.

package httpapi.authz

import input as http_api

default allowPremium = false

allowPremium {
  userID = http_api.userID
  http_api.role = "premium"

  http_api.path =  ["rewards", userID, "redeem"]
  {http_api.method} == {http_api.method} & {"GET", "POST"}
}

For our test, we check the methods and the premium role.

package httpapi.authz

test_get_premium_allowed {
    allowPremium with input as {
        "path": ["rewards", "a", "redeem"],
        "method": "GET",
        "userID": "a",
        "role": "premium"
    }
}

test_get_premium_denied {
    not allowPremium with input as {
        "path": ["rewards", "a", "redeem"],
        "method": "GET",
        "userID": "a",
        "role": "not_premium"
    }
}

test_post_premium_allowed {
    allowPremium with input as {
        "path": ["rewards", "a", "redeem"],
        "method": "POST",
        "userID": "a",
        "role": "premium"
    }
}

Admin

We are not going to spend too much time on the web UI for the demo, although I find it pretty cool. There is not much code, we grab the current policies (here we just check the active agent http://0.0.0.0:8181/v1/policies/), compare against the available policies (http://0.0.0.0:8000/), then post to our agent if we want to enable/disable (http://0.0.0.0:8181/v1/policies/)

Demo

Now it is demo time! The full code is over here (https://github.com/KlotzAndrew/opa-firefly) if you want to follow along. For design, we have a golang http server running on :1323, for authz it checks with a OPA agent running on localhost:8181 (sidecar pattern can use localhost). To get the policies into OPA we have a react app running on :3000, it loads the policies from a python webserver hosting our policies folder on :8000, and to enable/disable it POSTS to the OPA agent on :8181.

There are a few accommodations here for the purposes of a local demo. The local python server is a replacement for file storage like s3. On start a OPA agent would pull from there, and periodically check for changes. Instead of a webserver making a request directly to a known OPA agent with an update, that would be a backend server updating all known OPA agents. Also, worth noting the nginx server in the sample code is to support the react app making cors request directly to the backend server.

Booting it up, in three terminals

docker-compose up
go run main.go authz-middleware.go
cd opa-admin && npm install && npm start
opa admin one on

The admin page is running on http://0.0.0.0:3000. Toggle on some policies and test out some http requests!

curl -H "JWT: {\"userId\": \"a\"}" \
  http://0.0.0.0:1323/accounts/a

curl -H "JWT: {\"userId\": \"a\", \"employees\": [\"c\"]}" \
  http://0.0.0.0:1323/accounts/b

curl -H "JWT: {\"userId\": \"a\", \"role\": \"premium\"}" \
  http://0.0.0.0:1323/rewards/a/redeem

In total this is what is running:

The code for a working example is over here: https://github.com/KlotzAndrew/opa-firefly