.NET: Open Policy Agent (OPA) with Styra DAS

Open Policy Agent (OPA), pronounced “oh-pha”, is an incredible technology for decoupling authorization from applications. Styra the company that creates and maintains OPA has a tool called Styra DAS that provides a UI for visualizing decisions, authorizations, troubleshooting, testing, and monitoring policy. This tutorial will demonstrate how to set up Styra DAS on a custom namespace, apply mutations, and deploy your first .NET API microservice that uses OPA.

This tutorial will demonstrate how to integrate Styra DAS/OPA into a .NET Microservice running on a Kubernetes cluster with Istio as the gateway.

Requirements

GitHub

GitHub: mrjamiebowman’s OPA Styra Das

Home Lab

Note: My current home lab is an HPE Gen 10 Microserver running VMware ESXi 8 and I am running a Virtual Machine with Ubuntu and Rancher RKE single node cluster. I’m using Azure DevOps to build and push the image to the Container Registry and ArgoCD to continuously deploy helm, Kubernetes, and application changes to the cluster.

Open Policy Agent (OPA)

Using OPA has some profound benefits that can make your environment more secure, safe, and testable, but, get this, the developers no longer have to write authorization code.

Decoupled Architecture

Decoupling authorization code from your services has a lot of hidden benefits. OPA policies have the capability of inheriting other policies and thus sharing rules with other policies through Rego code. This means there is less policy code and duplication which follows the Don’t Repeat Yourself (DRY) principle. Simply reducing code reduces mistakes. These policies can be checked into source control and maintained separately from the applications.

Rego Code

Rego code is straightforward to learn and get started with. It’s very capable of doing complex things like JWT Validation and API calls to validate access. Rego policies are shipped and stored together in what are known as Bundles.

package istio.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 {
    parsed_path[0] == "hc"
    http_request.method == "GET"
}

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

Security

There are many different architectures for setting up OPA, but in this tutorial, OPA sits as a sidecar on a Kubernetes pod admitting access to the underlying service based on policy decisions. OPA can be more secure in the sense that policy decisions can easily be logged, validated, replayed, and tested. The life cycle of OPA policies in general is going to be far more secure than having authorization code distributed throughout the code of multiple applications. Having it better organized will lead to better Cloud Governance, especially at scale. There have been some concerns about Kubernetes cluster security, however, if a hacker has compromised the cluster then you’re going to have much bigger problems. If a hacker compromises an individual service and is able to execute commands against other internal APIs, those services will still be protected through OPA. The policies will still apply and the hacker will be limited.

Testing

Rego policies can easily be tested through Rego test code and instructions. This is wonderful because if a policy has changed the tests can be written, ran, and reviewed for thoroughness. This can significantly reduce mistakes through a system of checks and balances.

CI/CD Pipeline Compatible

Rego Bundles can be produced, packaged, and tested in a Continous Integration (CI) / Continous Deployment (CD) scenario. This means with a click of a button your application’s authorization code can be updated without re-deploying the apps. Not to mention, this bundle will not be deployed if all of the policy tests don’t pass.

Styra DAS

Hold on to your mouse! Styra DAS has to be the hottest-coolest Identity tool that I’ve seen come to market. I have absolutely been blown away by what Styra DAS is capable of.

 

 

Flexibility

(Styra DAS supports many different types of systems)

Styra is capable of being installed on many different types of systems.

Common Systems
  • Envoy
  • Istio
  • Kong Gateway / Mesh
  • Kubernetes
  • Terraform

Architectural Patterns

In addition, there are several different common architectural patterns that Styra DAS supports.

Capabilities

Styra DAS is much more than just an interface to Rego policy and decisions. It is very capable of doing much more than that.

Kubernetes Mutations

This has to be one of Styra’s most impressive features. Using Rego code, mutations can be dynamically applied to the Kubernetes manifest based on policy. In the tutorial below we will mutate pods to inject the OPA sidecar and bundle based on the namespace and other validating requirements (is it a pod? is it a create? ..or update? etc…).

GitHub: Kurt Roekle’s Mutations

Search Capabilities

The search capabilities of Styra DAS are built on Lucene syntax.

Policy Replay

Being able to replay and analyze a policy decision is absolutely crucial for being able to determine why and how a rule or decision was applied. This allows for much quicker development, support, and management of policies.

In this next image, we can see that the user does not have the correct role to access /api/security/grid .

Initial Kubernetes Setup

# create myapp namespace
kubectl create ns myapp

# enable istio injection
kubectl label namespace myapp istio-injection=enabled --overwrite

Deploying Styra DAS to Kubernetes

So this is where it gets tricky and Styra DAS really starts to shine with its extensive features. I found this process a bit confusing since there are many different ways to configure Styra DAS systems and different styles of architecture. Styra was very willing to help me understand and configure this. Their Technical Architect, Kurt Roekle was very helpful and provided a sample mutation that I modified to work with this particular scenario. In order to do this, I need to install 2 systems, one for Kubernetes and one for Istio which will provide application-level authorization and policy decisions through an injected sidecar. The Kubernetes system would track cluster-level authorization and apply mutations.

System ID

Each Styra DAS system will have a unique System ID that is often used in configuration and is important to know for troubleshooting.

Installing Styra DAS to a Kubernetes System

It is best to install the Kubernetes system first before installing the Istio system. It’s going to be much easier to install and it’s required for the pod mutations. If this can’t be installed and working none of the processes below will work either.

// TODO: PICS / INSTRUCTIONS

Installing Styra DAS to an Istio System

Note: Installing the Istio System is a bit tricky when it comes to deploying to a custom namespace but I will show you how to do this. This part may be updated and removed entirely because I hear they are planning on adding the ability to set the namespace on the deployment. This section is subject to change.

Create the Istio System

Creating the Istio System in Styra DAS is very easy. Just name it and click “Add system”. This will open a window that defaults to the “Settings” tab with Install instructions.

Download the Manifest Files

Instead of running the commands in the settings, we will download the manifest files so we can modify them before deploying this to our Kubernetes cluster. That way we can add our custom namespace to their manifest files.

Modifying the Manifest Files

The manifest files as they are will deploy to the default namespace since they don’t have a specified namespace. We need to modify this so it goes to the “myapp” namespace by adding the namespace property through a variable.

With this architectural pattern with Styra DAS, we will create a new system for each set of microservices. Therefore, we need to modify the manifest files to include the namespace that it belongs to.

Now, there are 3 files downloaded and I will include these in the Helm chart, however, the EnvoyFilter.yaml doesn’t need to be modified. I include that in the Helm charts just because it’s easier to keep it all together.

Modifying values.yaml

The Helm values.yaml file should be empty and contain this value.

namespace: "myapp"
Modifying OpaConfig.yaml

This one is easy all you have to do here is add the namespace to the metadata path. Also, take note that the System ID is used here.

metadata:

  namespace: {{ .Values.namespace }}
Modifying Slp.yaml

The slp.yaml file has a lot of services necessary to install and set up the Styra Local Control Plane (SLP) and will require careful attention.

All of the individual manifests (Secret, Service, StatefulSet) will need this added to them.

metadata:
  namespace: {{ .Values.namespace }}

The StatefulSet has a property for creating a Volume Claim, add it there.

spec:
  volumeClaimTemplates:
  - metadata:
      namespace: {{ .Values.namespace }}

Deploying Styra DAS via Helm Charts

Run this command after modifying the files with the namespace.

# deploy styra das
helm install -n myapp charts/styradas

Verifying the Styra DAS Installation

To verify that the service has deployed correctly you will want to check several things to know if Styra DAS was deployed correctly.

Logs

The logs should look like JSON without any errors.

(Failed Installation)
OPA Sidecar

We want to make sure that the mutation does not apply an OPA sidecar to this service. At this point, we haven’t set up the mutation but sometimes we re-create systems and clusters so it’s a good habit to learn to check this. When I run the command below I can see 1/1 containers ready. This tells me that the OPA sidecar was not injected. This is good because if it had been injected there would be 1/2 in the READY state.

kubectl get pods -n myapp

NAME              READY   STATUS    RESTARTS   AGE
slp-istio-app-0   1/1     Running   0          114s

ConfigMap

If you are deploying multiple times it may be a good idea to check the opa-istio-config to see if the System ID matches.

kubectl get cm -n myapp

NAME                  DATA   AGE
istio-ca-root-cert    1      6m59s
kube-root-ca.crt     1      6m59s
opa-istio-config      1      4m39s

Secrets

This secret contains the token bearer access token that is needed to communicate with Styra DAS.

kubectl get secrets -n myapp

NAME                             TYPE                 DATA   AGE
sh.helm.release.v1.styradas.v1   helm.sh/release.v1   1      5m5s
slp-istio                        Opaque               1      5m5s

Setting up Pod Mutations in Styra DAS

(Mutations are easy to modify, enable/disable, and are written using Rego.)

I was completely unaware that this is a strategy for injecting OPA sidecars into pods but I can assure you, this is the best way I’ve seen this done. This technique gives a lot of flexibility with which pods get OPA injected whereas a namespace label would apply to all pods in a namespace. Manually applying this through manifest would still mean the application has to be redeployed. Using Styra DAS allows me to dynamically apply this policy in several ways. If I needed to disable it, I could modify the mutation by setting it to “Monitor” or “Ignore”, then restart the deployment, and the OPA sidecar would be removed. I can modify the mutation and restart the application deployment to see if the sidecar has been injected. It’s actually very easy once you learn it.

I also recommend the OPA and Styra Slack Channels.

GitHub: Kurt Roekle’s Mutations

package policy["com.styra.kubernetes.mutating"].rules.rules

# collection of rules
injectablePod {
  input.request.kind.kind = "Pod"
  data.kubernetes.resources.namespaces[input.request.namespace].metadata.labels["istio-injection"] = "enabled"
  not input.request.object.metadata.labels["app"] = "slp" # slp does not need an opa sidecar
  not opaContainerExists
  isValidNamespace
  isCreateOrUpdate  # calls both methods below (really cool..)
}

# policy
enforce[decision] {
  # title: Inject OPA sidecar
  injectablePod

  decision := {
    "allowed": true,
    "message": "Adding OPA Injection",
    "patch": patches
  }
}

patches = [
  opaPath,
  volumePatch
]

# create envoy opa sidecar and mount configuration into opa
opaPath = patch {
  patch := {
        "op": "add",
        "path": "/spec/containers/-",
        "value": {
          "image": "openpolicyagent/opa:latest-envoy",
          "name": "opa",
          "args": [
            "run",
            "--server",
            "--config-file=/config/conf.yaml"
          ],
          "startupProbe": {
            "httpGet": {
            "path": "/health?bundles",
            "port": 8181
          }
        },
        "volumeMounts": [
          {
            "mountPath": "/config",
            "name": "opa-config-vol"
          }]
        }
     }
}

# create a volume for opa's configuration
volumePatch = patch {
  patch := {
        "op": "add",
        "path": "/spec/volumes/-",
        "value": {
          "name": "opa-config-vol",
          "configMap": {
            "name": "opa-istio-config"
          }
        }
      }
}

# limit to 1 opa sidecar
opaContainerExists {
  input.request.object.spec.containers[_].name == "opa"
}

# create?
isCreateOrUpdate {
  input.request.operation == "CREATE"
}

# update?
isCreateOrUpdate {
  input.request.operation == "UPDATE"
}

# must be in myapp namespace
isValidNamespace {
  input.request.namespace == "myapp"
}

Application Setup

This will demonstrate using a simple API that authorizes through the Identity Server using a username and password credentials. The application will be minimal and run in a Docker container deployed on Kubernetes through Helm. The Helm charts will be included in the GitHub repository.

.NET 8 Minimal API

With the latest .NET, building minimal APIs is very easy and can be done on a single .csharp file. This service will have 3 API Methods that will be protected through OPA.

using MrJB.OPA.StyraDas.API;

var builder = WebApplication.CreateSlimBuilder(args);

var app = builder.Build();

var securityApi = app.MapGroup("api/security/grid");
securityApi.MapGet("/", () => Results.Ok("Access Granted Security Grid!"));

var systemApi = app.MapGroup("api/system/test");
systemApi.MapGet("/", () => Results.Ok("Access Granted to System Test!"));

app.Run();

Note: .NET 8 is currently in preview and will be released in November of 2023 and be tagged as a Long Term Support (LTS) release.

Deploy Application Helm Charts

# deploy microservice api
helm install -n myapp charts/myapp

Verifying the Pod Mutations

If the app is deploying it should create 3 containers (Istio, OPA, App) and 1 init container (Istio startup). We need to verify a few things to make sure it’s starting up correctly. First, we’ll want to check that the OPA container is there and that it is not throwing any errors. Then we can view the schema of the Pod just to look over the modified YAML.

OPA Policy

/api/up – always allow anonymous
/api/security/grid – only allow Dennis
/api/system/test – allow Ray and Dennis

package policy.ingress

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

default allow = false

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

# allow /api/hc
allow {
    parsed_path[0] == "api"
    parsed_path[1] == "hc"
    http_request.method == "GET"
}

# allow /api/up
allow {
    parsed_path[0] == "api"
    parsed_path[1] == "up"
    http_request.method == "GET"
}

allow if {
	some r in roles_for_user
	r in required_roles
}

roles_for_user contains r if {
	some r in user_roles[user_name]
}

required_roles contains r if {
	some perm in role_perms[r]
	perm.method == http_request.method
	perm.path == http_request.path
}

user_name := parsed if {
	[_, encoded] := split(http_request.headers.authorization, " ")
	[parsed, _] := split(base64url.decode(encoded), ":")
}

user_roles := {
	"ray": ["guest"],
	"dennis": ["admin"],
}

role_perms := {
	"guest": [{"method": "GET", "path": "/api/system/test"}],
	"admin": [
		{"method": "GET", "path": "/api/security/grid"},
		{"method": "GET", "path": "/api/system/test"}
	],
}

Testing Authorization

We will demonstrate this using basic auth which will not check the password and only verify that the Username matches the correct path and security role.

# get ip of the service
export SERVICE_HOST=$(kubectl -n myapp get service myapp -o jsonpath='{.status.loadBalancer.ingress[0].ip}')

# allow all
curl -i http://$SERVICE_HOST/api/up

# dennis
curl --user dennis:password -i http://$SERVICE_HOST/api/security/grid
curl --user dennis:password -i http://$SERVICE_HOST/api/system/test

# ray
curl --user ray:password -i http://$SERVICE_HOST/api/security/grid
curl --user ray:password -i http://$SERVICE_HOST/api/system/test

Further Reading

Styra DAS: Istio Documentation

Envoy External Authorization Filter