Authorization Control using OpenPolicy Agent and Google Groups

2022-05-18

Tutorial to setup OpenPolicy Agent where authorization decisions are based using the groups a Google CLoud Identity user is a direct or indirect member of.

Normally, OPA decisions use signals sent to it by the requestor and combines that with data it loads either using its push or pull model. Basically it requires a system to populate its information that it uses to make authroization decisions.

If you want to make a decision based on a user’s google workspace/cloud identity groups, you could seed OPA with all the groups data…but you’ll need to know about changes in membership and refresh OPA. Thats the problem: google cloud identity doesn’t have any immediate, low latency way to ‘get notified’ of membership changes. Sure you can use the Group Audit Log Events but that can take hours to update an event…you need these updates faster.

This sample uses OPA’s External Data: Pull Data during Evaluation mechanism to load the groups a user is a member of at runtime.

We’re using Envoy as the frontend proxy that calls out to OPA for authorization decisions…however you can use the OPA and Groups Lookup Server for any other service that integrates with OPA…you just need a way to provide OPA’s with the username to lookup…

Anyway, we’ll use envoy and the OPA-Envoy Plugin

ώπα

The OPA plugin basically functions as a self contained external_authorization server for envoy and provides access decisions back to envoy. OPA itself has authorization policies defined inside its own rego which means it is in exclusive control for the authorization decisions when it comes to group membership.

For more information about other options for external authorization Envoy, see


You can find the source here


Lets get started…for this demo, we will run everything locally…i know, OPA, envoy and whatnot usually works with kubernetes …but this is my demo to show you the component interactions and integration.

Setup

First edit /etc/hosts and add on some hosts to make life a bit easier…

127.0.0.1	grpc.yourdomain.com envoy.yourdomain.com server.yourdomain.com redis.yourdomain.com

I used my own CA for these certs…if you want you can generate your own here (CA_scratchpad)

To run this demo, you’ll also need the following: golang, docker, python3

Once you have that, get a copy of the Envoy binary (you can also use envoy+dockerimage but i find this easier…)

cd envoy/
docker cp `docker create envoyproxy/envoy-dev:latest`:/usr/local/bin/envoy .

This demo will use a JWT sent over by a client that will get validated by either Envoy or OPA, then using the embedded claims, issue an API call to an external groups server for group memebership info. OPA uses the group membership to make the final decision.

There are two modes you can run OPA here…pick either A or B..it just depends on what you what to test:

A) Envoy JWT Validation

In ths mode, Envoy will first validate the JWT provided to it and hand off the decoded JWT to OPA as Envoy Dynamic Metadata. Since envy will validate the JWT, all OPA has to do is parse and make the external call.

To use the mode, run

docker run -p 8181:8181 -p 9191:9191 \
   --net=host \
   -v `pwd`/opa_policy:/policy \
   -v `pwd`/opa_config:/config openpolicyagent/opa:latest-envoy run \
   --server --addr=localhost:8181 \
      --set=plugins.envoy_ext_authz_grpc.addr=:9191 \
      --set=plugins.envoy_ext_authz_grpc.enable-reflection=true \
      --set=plugins.envoy_ext_authz_grpc.path=envoy/authz/allow \
      --set=status.console=true \
      --set=decision_logs.console=true --log-level=debug --log-format json-pretty \
      --ignore=.* /policy/policy_jwt.rego

Then run envoy with JWT validation filter enabled

cd envoy/
./envoy -c envoy_jwt.yaml -l trace

A) OPA JWT Validation

In this mode, the JWT is not validated by envoy and is simply sent to OPA as-is. OPA loads the public certificate that issued the JWT and validates then extracts the claims.

To use the mode, run:

docker run -p 8181:8181 -p 9191:9191 \
   --net=host \
   -v `pwd`/opa_policy:/policy \
   -v `pwd`/opa_config:/config openpolicyagent/opa:latest-envoy run \
   --server --addr=localhost:8181 \
      --set=plugins.envoy_ext_authz_grpc.addr=:9191 \
      --set=plugins.envoy_ext_authz_grpc.enable-reflection=true \
      --set=plugins.envoy_ext_authz_grpc.path=envoy/authz/allow \
      --set=status.console=true \
      --set=decision_logs.console=true --log-level=debug --log-format json-pretty \
      --ignore=.* /policy/policy_passthrough.rego

And its corresponding envoy filter

cd envoy/
./envoy -c envoy_passthrough.yaml -l debug

Cache Options

Since we’re loading data from an external source, it’d help if we could cache the data.

There are again two options here which you ca use independently from the section above.

You can either have a global shared cache or a per-opa local cache

  • A) Global Redis server

You can either run a commond Redis Server which can hold (with a TTL) the various groups a user is a member of.

This cache is filled in at runtime so when a user’s jwt is first presented, the groups server will lookup the user’s groups and save it into Redis with an auto-expiring TTL.

If you want to suse this mode, run redis

docker run -ti --net=host -p 6379:6379  redis:latest
  • B) Local OPA Cache

OPA usually loads data locally and uses that to make decisions and to the cache is determined by its pull or push scheme.

External HTTP datasources, however, allows you to define how long to cache the response. To use this, edit *.rego and set the value for ("force_cache_duration_seconds"):

r := http.send({"method": "POST", "url": "https://server.yourdomain.com:8443/authz", "body":  jwt_payload.sub, "timeout": "3s", "force_cache": true, "force_cache_duration_seconds": 1, "tls_ca_cert": ca_cert})

for more information, see OPA External Pull Performance and Availability

Groups Lookup External Datasource

We’re finally rady to start our external groups lookups server.

The specific API that enables this mode is described here:

Its a lot to get setup and you as a reader probably dont’ have the time or access to do this..

to help with that, i’ve faked the groups lookup server for local testing by defining a set of static users and groups they are members of:

	mocks               = map[string]groupsStruct{
		"alice@domain.com": {
			Groups: []string{"securitygroup1@domain.com", "group_of_groups_1@domain.com", "group8_10@domain.com", "group4_7@domain.com", "deniedgcs@domain.com", "all_users_group@domain.com"},
		},
		"bob@domain.com": {
			Groups: []string{"all_users_group@domain.com"},
		},
	}

so, lets start the server

## Start groups server
cd groups_server
go run server.go 

If you want to use redis, specify the --useRedis flag when starting the groups_server, the default is to use the fakes and no redis

  -tlsCert string
    	Public x509 (default "../certs/server.crt")
  -tlsKey string
    	Private Key (default "../certs/server.key")
  -useCloudIdentityAPI
    	Use mock Groups
  -useRedis
    	Use redis cache

You can test the local group server using the curl (this is what opa will be calling anyway)

$ curl -s --cacert certs/tls-ca.crt  -X POST --data "alice@domain.com" https://server.yourdomain.com:8443/authz | jq '.'
{
  "groups": [
    "securitygroup1@domain.com",
    "group_of_groups_1@domain.com",
    "group8_10@domain.com",
    "group4_7@domain.com",
    "deniedgcs@domain.com",
    "all_users_group@domain.com"
  ]
}

Test Client

We’re now ready to run the test client testing all this:

Fist we need a JWT…included in this repo is a handy script from istio i’m borrowing:

cd jwt_generator
export ADMIN_TOKEN=`python3 gen-jwt.py -iss foo.bar -aud bar.bar -sub alice@domain.com -claims role:admin -expire 100000 key.pem`

You can see the specifics of this JWT at jwt.io

{
  "alg": "RS256",
  "kid": "DHFbpoIUqrY8t2zpA2qXfCmr5VO5ZEr4RzHU_-envvQ",
  "typ": "JWT"
}
{
  "aud": "bar.bar",
  "exp": 1653086822,
  "iat": 1652986822,
  "iss": "foo.bar",
  "role": "admin",
  "sub": "alice@domain.com"
}

Notice the aud, iss and sub fields…we will use these in the OPA policies.

Now use this token to call envoy

$ curl -s -H "Authorization: Bearer $ADMIN_TOKEN" --cacert certs/tls-ca-chain.pem -H "xfoo: bar" https://envoy.yourdomain.com:8080/get

> GET /get HTTP/1.1
> Host: envoy.yourdomain.com:8080
> User-Agent: curl/7.82.0
> Accept: */*
> Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IkRIRmJwb0lVcXJZOHQyenBBMnFYZkNtcjVWTzVaRXI0UnpIVV8tZW52dlEiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJiYXIuYmFyIiwiZXhwIjoxNjUzMDg2OTQ2LCJpYXQiOjE2NTI5ODY5NDYsImlzcyI6ImZvby5iYXIiLCJyb2xlIjoiYWRtaW4iLCJzdWIiOiJhbGljZUBkb21haW4uY29tIn0.GPqG0kke_Lhc3Ma3Cl0H592w_v5Szl7qUyih26ftor6DK0FPCUaD8YueK4eOezYu4j8DMfTz1t-m-oSUcNKA_B9W1FkQy4S2AxtcI2LeG3Ffd6q0Yf0LPokxe1E3lkeumsn_NAw3gvzLaRDqMtZmK-1Qe_o6msPWaiPXpIoSeRSRrDusNRClt30mLKtBQgrnHxtN6Tfg3MnRRd9vycOCvNtNjk0_oE9VsrkKyIiPkS96fLgz_lQ-US51pPcKC6ChMQRJnm0k3eQJPD6XnDUTFjrXCQDyUSfgD7oRwr8JyNGSCrzCthN0Q_UPeZFmLzJ1ab9kHKStAHR0neEbmecuSQ
> xfoo: bar

< HTTP/1.1 200 OK
< date: Thu, 19 May 2022 19:02:57 GMT
< content-type: application/json
< content-length: 569
< server: envoy
< access-control-allow-origin: *
< access-control-allow-credentials: true
< x-envoy-upstream-service-time: 23
< response-header-key-1: resp_value_1
< 
{
  "args": {}, 
  "headers": {
    "Accept": "*/*", 
    "Host": "envoy.yourdomain.com", 
    "User-Agent": "curl/7.82.0", 
    "X-Amzn-Trace-Id": "Root=1-62869461-435c1d3b5cd1748d6fec7142", 
    "X-Envoy-Expected-Rq-Timeout-Ms": "15000", 
    "X-Ext-Auth-Allow": "yes", 
    "X-Google-Groups": "securitygroup1@domain.com group_of_groups_1@domain.com group8_10@domain.com group4_7@domain.com deniedgcs@domain.com all_users_group@domain.com", 
    "X-Validated-By": "security-checkpoint"
  }, 
  "origin": "72.83.67.174", 
  "url": "https://envoy.yourdomain.com/get"
}

What your’e seeing is three parts:

  1. request to envoy
  2. response from envoy
  3. the request from envoy that httpbin.org/get actually saw.

Our OPA rules stipulated that we should add the groups information to the output request

headers["x-ext-auth-allow"] := "yes"
headers["x-validated-by"] := "security-checkpoint"
headers["x-google-groups"] := concat(" ",external_data.upstream_body["groups"])
request_headers_to_remove := ["xfoo"]
response_headers_to_add["response-header-key-1"] :=  "resp_value_1"

and thats exactly what we see…the upstream service (httpbin in our case) will get to see the groups this user is a member of!

"X-Google-Groups": "securitygroup1@domain.com group_of_groups_1@domain.com group8_10@domain.com group4_7@domain.com deniedgcs@domain.com all_users_group@domain.com", 

note, this header should be filtered out by opa or envoy on the inbound, downstream request. IMO, it should also be a jwt that is signed by the groups server that upstream validates….but this is just a demo

gRPC

Envoy is very grpc-first…it has really good grpc handling capabilities.

OPA-Envoy plugin leverages all that to allow you to inspect and make decisions based on the gRPC Probuf messages!

(well, in a limited way: just unary requests w/o compression)

Anyway, i think its nice so lets see this in action:

For this we will need a grpc client and server, then envoy that uses grpc and an OPA rego that will parse out and decode the message

In our case the Message proto is trivial:

syntax = "proto3";
package echo;

service EchoServer {
  rpc SayHelloUnary (EchoRequest) returns (EchoReply) {}
}

message EchoRequest {
  string name = 1;
}

message EchoReply {
  string message = 1;
}

Start gRPC Server

go run greeter_server/grpc_server.go --grpcport :50051 --tlsCert grpc_server_crt.pem --tlsKey grpc.pem

Start Envoy with grpc handlers:

envoy -c envoy_grpc.yaml -l trace

Start OPA with grpc decoding enabled (--set=plugins.envoy_ext_authz_grpc.proto-descriptor=/proto/echo.proto.pb)

docker run -p 8181:8181 -p 9191:9191 \
   --net=host \
   -v `pwd`/opa_policy:/policy \
   -v `pwd`/grpc/echo/echo.proto.pb:/proto/echo.proto.pb \
   -v `pwd`/opa_config:/config openpolicyagent/opa:latest-envoy run \
   --server --addr=localhost:8181 \
      --set=plugins.envoy_ext_authz_grpc.addr=:9191 \
      --set=plugins.envoy_ext_authz_grpc.enable-reflection=true \
      --set=plugins.envoy_ext_authz_grpc.proto-descriptor=/proto/echo.proto.pb \
      --set=plugins.envoy_ext_authz_grpc.path=envoy/authz/allow \
      --set=status.console=true \
      --set=decision_logs.console=true --log-level=debug --log-format json-pretty \
      --ignore=.* /policy/policy_jwt_grpc.rego

What this rego does is validates the JWT as in the examples above but it checks for some other stuff:

allow_theeggman {
  http_request.method == "POST"
  glob.match("/echo.EchoServer/SayHelloUnary", [], http_request.path)
  input.parsed_body.name == "iamtheeggman"
}

this policy will only work if a specific endpoint is called AND if the client emits a gRPC message of Type EchoRequest with the payload value of iamtheeggman (yeah, see the command below)

	ctx = grpcMetadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+*authToken)
	ctx = grpcMetadata.AppendToOutgoingContext(ctx, "xfoo", "bar")
  r, err := c.SayHelloUnary(ctx, &echo.EchoRequest{Name: *payloadData})

Get an ADMIN_TOKEN using the pythons script above

Test gRPC client…remember to pass in iamtheeggman too!

 go run greeter_client/grpc_client.go \
   --host localhost:8080 --cacert ../certs/tls-ca.crt \
   --servername envoy.yourdomain.com -skipHealthCheck  \
   --payloadData=iamtheeggman \
   --authToken=$ADMIN_TOKEN 

now notice the OPA request it got for evaluation…specifically the parsed_body! thats the decoded protobuf

{
  "decision_id": "c2da69a1-b678-4df4-9055-e8edec379ab2",
  "input": {
    "attributes": {
      "destination": {
        "address": {
          "socketAddress": {
            "address": "127.0.0.1",
            "portValue": 8080
          }
        },
        "principal": "envoy.yourdomain.com"
      },
      "metadataContext": {
        "filterMetadata": {
          "envoy.filters.http.jwt_authn": {
            "verified_jwt": {
              "aud": "bar.bar",
              "exp": 1653086946,
              "iat": 1652986946,
              "iss": "foo.bar",
              "role": "admin",
              "sub": "alice@domain.com"
            }
          }
        }
      },
      "request": {
        "http": {
          "headers": {
            ":authority": "envoy.yourdomain.com",
            ":method": "POST",
            ":path": "/echo.EchoServer/SayHelloUnary",
            ":scheme": "https",
            "content-type": "application/grpc",
            "grpc-timeout": "997788u",
            "te": "trailers",
            "user-agent": "grpc-go/1.33.2",
            "x-envoy-auth-partial-body": "false",
            "x-forwarded-proto": "https",
            "x-request-id": "3a3ae89f-bc08-4216-a520-128c3cc0696d",
            "xfoo": "bar"
          },
          "host": "envoy.yourdomain.com",
          "id": "13150880515659298070",
          "method": "POST",
          "path": "/echo.EchoServer/SayHelloUnary",
          "protocol": "HTTP/2",
          "rawBody": "AAAAAA4KDGlhbXRoZWVnZ21hbg==",
          "scheme": "https",
          "size": "19"
        },
        "time": "2022-05-19T19:14:04.177094Z"
      },
      "source": {
        "address": {
          "socketAddress": {
            "address": "127.0.0.1",
            "portValue": 36564
          }
        }
      }
    },
    "parsed_body": {
      "name": "iamtheeggman"
    },
    "parsed_path": [
      "echo.EchoServer",
      "SayHelloUnary"
    ],
    "parsed_query": {},
    "truncated_body": false,
    "version": {
      "encoding": "protojson",
      "ext_authz": "v3"
    }
  },
  "labels": {
    "id": "42c36cb1-8d64-43a4-b7cf-1c84faec0a45",
    "version": "0.40.0-envoy-1"
  },
  "level": "info",
  "metrics": {
    "timer_rego_builtin_http_send_ns": 3415355,
    "timer_rego_query_compile_ns": 80794,
    "timer_rego_query_eval_ns": 3847696,
    "timer_server_handler_ns": 4795661
  },
  "msg": "Decision Log",
  "path": "envoy/authz/allow",
  "result": {
    "allowed": true,
    "body": "ok",
    "headers": {
      "x-ext-auth-allow": "yes",
      "x-google-groups": "securitygroup1@domain.com group_of_groups_1@domain.com group8_10@domain.com group4_7@domain.com deniedgcs@domain.com all_users_group@domain.com",
      "x-validated-by": "security-checkpoint"
    },
    "http_status": 200,
    "request_headers_to_remove": [
      "xfoo"
    ],
    "response_headers_to_add": {
      "response-header-key-1": "resp_value_1"
    }
  },
  "time": "2022-05-19T19:14:04Z",
  "timestamp": "2022-05-19T19:14:04.183258064Z",
  "type": "openpolicyagent.org/decision_logs"
}

This site supports webmentions. Send me a mention via this form.