Authorization with OPA and Envoy

In my previous post I described how we can use Envoy for Authentication. The purpose of authentication is to verify that someone or something is who they claim to be. This post is about Authorization which determines what they can do once they have access. (Who vs What).

In dotnet, authorization is usually done by verifying the user has the right scopes and/or application roles. To protect an asp.net or dotnetcore web API, you must add the [Authorize] attribute on the controller or an action as below.

[Authorize(Roles = "client.read")]
MyController : ApiController
{
    // ...
}

[Authorize(Roles = "client.write")]
public async Task<IActionResult> AddTodoItem(....)

Instead of doing this in application code, what we are looking at here is how we can do it using Envoy and Open Policy Agent(OPA). This will help to decouple authorization from application code and move it to Envoy which runs alongside the application in "Out of Process" manner.

Envoy supports an External Authorization filter which calls an authorization service to check if the incoming request is authorized or not. In this particular scenario, we use OPA as the authorization service which makes an informed decision about the fate of the incoming request received by Envoy.

What is OPA...

The Open Policy Agent (OPA, pronounced “oh-pa”) is CNCF graduated open source project that lets you specify policy as code and allow you to offload policy decision-making from your software. You can define those policies using high-level declarative language called Rego which is easy to read and write. Then you can enforce those policies in microservices, Kubernetes, CI/CD pipelines, API gateways, etc..

When your software needs to make policy decisions it queries OPA and supplies structured data as input. Then OPA evaluates those query input against policies/data and provide you the policy decision.

There are multiple ways you can integrate OPA. Most common way is deploying it as a sidecar. In this post we'll look at how we can do this using  OPA-Envoy which is an extended version of OPA with a gRPC server that implements the Envoy External Authorization API.

Now that we got some basic understanding of OPA, Let's look at the code. (complete sample is here in github).

In the program.cs file we have 3 endpoints as below to simulate get and post requests. Other than that there is no any added code for authorization.

app.MapGet("/health", () => "alive");

app.MapPost("/weatherfeed", () => "im here");

app.MapGet("/weatherforecast", () =>
{
   ....
});

In the docker-compose.yml file, we have images for Envoy and OPA and In the docker.compose.override.yml file, you will see that there are configs for both envoy and OPA as below. ( note that both envoy.yaml and policy.rego files added as volumes)

services:
  envoy:
   volumes:
     - ./Envoy/config/envoy.yaml:/etc/envoy/envoy.yaml
   ports:
   - "5200:8000"
   - "15200:8001"

  opa:
   volumes:
     - ./opa-policy/policy.rego:/etc/policy.rego
   command:
     - run
     - --server
     - --log-level=debug
     - --log-format=json-pretty
     - --set=plugins.envoy_ext_authz_grpc.addr=:9191
     - --set=decision_logs.console=true
     - --set=plugins.envoy_ext_authz_grpc.path=envoy/authz/allow
     - /etc/policy.rego

Now let's take a look at envoy.yaml. As describe above It has envoy.filters.http.ext_authz Http filter which responsible for calling OPA to verify if the incoming request is authorized or not.

http_filters:
- name: envoy.ext_authz
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
    transport_api_version: V3
    with_request_body:
      max_request_bytes: 8192
      allow_partial_message: true
    failure_mode_allow: false
      grpc_service:
        google_grpc:
          target_uri: opa:9191
          stat_prefix: ext_authz
        timeout: 0.5s

Finally, let's examine policy.rego file. It will decode the incoming jwt_token and extract roles. Then the configured OPA policy restricts/allows access to the endpoints exposed by our sample dotnet api:

  • /health has no restrictions and its always allowed (no jwt_token required).
  • Users with client.read  role can perform a GET request to /weatherforecast.
  • Users with client.readwrite  role can perform a POST request to /weatherfeed.
package envoy.authz

    import input.attributes.request.http as http_request
    import input.parsed_path

    default allow = false

    allow {
        parsed_path[0] == "health"
        http_request.method == "GET"
    }

    allow {
        print("Found Claims",claims.roles) 
        required_roles[r]
    }


    required_roles[r] {
        perm := role_perms[claims.roles[r]][_]
        perm.method = http_request.method
        perm.path = http_request.path
    }

    claims := payload {
        [_, payload, _] := io.jwt.decode(bearer_token)
    }
  
    bearer_token := t {
        v := http_request.headers.authorization
        startswith(v, "Bearer ")
        t := substring(v, count("Bearer "), -1)
    }

    role_perms = {
        "client.read": [
            {"method": "GET",  "path": "/weatherforecast"},
        ],
        "client.readwrite": [
            {"method": "POST",  "path": "/weatherfeed"},
        ],
    }

Now lets test this...

1) Clone the git repo.

2) Assuming you are in same directory as docker-compose.yml file, run docker compose up.

3) Now if you curl the below two endpoints you will see the result like below. Notice the /weatherforecast endpoint fails with "403 Forbidden".

$  curl http://localhost:5200/health
alive

$ curl http://localhost:5200/weatherforecast -w "responsecode: %{response_code}\n"
responsecode: 403

4) Finally, follow my post on Azure AD app registration till the end to generate an access_token with client.read app role. Now curl the /weatherforecast with the token as below.

TOKEN=<replace this with your access_token>
$ curl  -H "Authorization: Bearer ${TOKEN}"  http://localhost:5200/weatherforecast
[{"date":"2022-10-01T20:02:54.3453397+00:00","temperatureC":9,"summary":"Mild","temperatureF":48}]

5) Now if you try to use the same token which only has client.read role to access /weatherfeed you will end up with "403 Forbidden".  (You need a access_token with client.readwrite role to perform the POST request to /weatherfeed).

$ curl  -H "Authorization: Bearer ${TOKEN}"  http://localhost:5200/weatherfeed -w "responsecode: %{response_code}\n"
responsecode: 403

Also take a look at the OPA container logs to see the input received by OPA and detailed decision logs to better understand or debug...

src-opa-1                 |   "level": "info",
src-opa-1                 |   "msg": "/etc/policy.rego:14: Found Claims [\"client.read\"]",
.................
src-opa-1                 |       "request": {
src-opa-1                 |         "http": {
src-opa-1                 |           "headers": {
src-opa-1                 |             ":authority": "localhost:5200",
src-opa-1                 |             ":method": "GET",
src-opa-1                 |             ":path": "/weatherfeed",
src-opa-1                 |             ":scheme": "http",
src-opa-1                 |             "accept": "*/*",
src-opa-1                 |             "authorization": "Bearer eyJ0eXAiOi...",
src-opa-1                 |             "user-agent": "curl/7.83.0",
src-opa-1                 |             "x-forwarded-proto": "http",
src-opa-1                 |             "x-request-id": "e276f282-99b7-418a-9e6a-922e13eec7fa"
src-opa-1                 |           },
src-opa-1                 |           "host": "localhost:5200",
src-opa-1                 |           "id": "217324202243584697",
src-opa-1                 |           "method": "GET",
src-opa-1                 |           "path": "/weatherfeed",
src-opa-1                 |           "protocol": "HTTP/1.1",
src-opa-1                 |           "scheme": "http"
................
src-opa-1                 |     "parsed_body": null,
src-opa-1                 |     "parsed_path": [
src-opa-1                 |       "weatherfeed"
src-opa-1                 |     ],
.................
src-opa-1                 |   "msg": "Decision Log",
src-opa-1                 |   "path": "envoy/authz/allow",
src-opa-1                 |   "result": false,
.

That's All!