Image for post open-policy-agent
May 22, 2023 by Luc Perkins

Packaging Open Policy Agent policies with Nix

Open Policy Agent (OPA) is an open source, general-purpose policy engine that enables you to express policy logic in a Datalog-flavored DSL called Rego and evaluate those policies against JSON. Policy is of vital importance in many domains of software—complex authorization is probably the most well-known use case—but policy logic can be quite laborious to write in standard programming languages. OPA and Rego provide an elegant alternative to the nested if/else spaghetti code that we’re all painfully familiar with.

I’ve written about, presented on, and contributed to OPA in the past, so my interest isn’t new—in fact, it’s been piqued again and again over the years and I’m always looking for excuses to do new things with it. The three things I like most about OPA:

  • Because it “speaks” JSON, you can use it to evaluate policies in any domain that also speaks JSON, and that now includes just about every domain of software, from security to deployment to networking to messaging and beyond.
  • You can run it in a variety of ways: on the command line using the opa, by POSTing JSON to OPA’s REST API, by using it as a library (in a Go program), and, most promisingly for my use case here, by compiling it to WebAssembly (Wasm) and running it in one of the many places Wasm now runs (imagine evaluating policies using edge workers or in the browser).
  • Beyond its concision, Rego has a robust standard library, which includes functions for things like bitwise operations, globs, regular expressions, cryptography, and much more.

So with OPA you get both broad applicability and a highly flexible usage model, which to me is quite a formidable pairing.

An unsatisfied use case

By far the most straightforward way to use OPA is with the OPA CLI. This command, for example, evaluates user-info.json against the Rego policy in rbac-policy.rego:

opa eval \
  --data rbac-policy.rego \
  --input user-info.json

One drawback of this approach is that OPA needs to be installed in your environment and to have access to both the JSON to be evaluated and the Rego policy. This can make bundling and distribution rather non-trivial in places like continuous integration environments. What I’d like instead is to provide ready-made CLI tools that I can use to evaluate policies without OPA installed or keeping the Rego files locally. In other words, I’d like to fetch a purpose-built binary for a specific policy and provide it with JSON to evaluate, like this:

rbac-evaluate --input-path user-info.json

In this post, I’ll show you how I used Nix to accomplish precisely that—plus some Rust in places where I needed a standard programming language to provide the CLI logic. If you want to take a look now, check out the nix-policy project on Determinate Systems’ GitHub org.

Find out more about how you can use Nix in your own production workloads

What I built

nix-policy takes Rego policies and uses Nix and Rust to convert them into standalone CLI tools. Let’s say you have a Rego policy named k8s_admission.rego that provides admission control for a Kubernetes API. You could use nix-policy to generate a CLI tool called k8s-admission that provides this help output when you run k8s-admission --help:

A policy evaluator for the k8s_admission.rego Kubernetes admission control policy

Usage: k8s-admission [OPTIONS]

Options:
  -d, --data <DATA>              Policy data object
  -d, --data-path <DATA_PATH>    Path to policy data JSON object
  -i, --input <INPUT>            Policy input object
  -i, --input-path <INPUT_PATH>  Path to policy input JSON object
  -o, --output <OUTPUT>          Result JSON output path
  -h, --help                     Print help

You could then use k8s-admission to evaluate a JSON object encapsulating a Kubernetes admission control request:

k8s-admission \
  --input-path ./k8s-admission-request.json \ # The request object
  --output-path ./evaluation.json             # The OPA output object

Open Policy Agent itself isn’t directly involved here and the k8s_admission.rego file doesn’t need to be installed on the host at all. We’re not shelling out to the opa CLI, we’re not calling a REST endpoint, and we didn’t write Go code to call the OPA Go library. Instead, we use a powerful intermediary: WebAssembly.

How it works

OPA has yet another great feature that I purposely omitted above: you can use it to convert Rego policies into Wasm binaries. nix-policy uses OPA to convert policies into Wasm and then wraps that Wasm in a Rust CLI tool. The Nix code is a bit complex so I created a convenient function called mkPolicyEvaluator that takes just a few arguments:

ArgumentMeaning
nameThe name of the CLI tool (like k8s-admission above)
srcThe source root (as in most Nix derivations)
policyThe path to the Rego policy file that you want wrapped in the CLI (such as k8s_admission.rego above)
entrypointThe OPA entrypoint to the policy output (basically the evaluation root)

The mkPolicyEvaluator function uses an internal Nix derivation called policyDrv, which calls a Nushell script called opa-wasm.nu. That script

  • Uses the OPA CLI to build a WebAssembly module called policy.wasm (inside a tarball).
  • Untars the contents of the tarball and adds the policy.wasm binary to the derivation output.
  • Reduces the size of the binary using wasm-opt from binaryen.

That gives us a WebAssembly binary for a specific policy. Now we need to actually run that binary, which is not so trivial. Fortunately, the good folks at Matrix have a Rust library called rust-opa-wasm that makes this pretty straightforward. It essentially uses the wasmtime library to provide OPA-built Wasm with properly formed data inputs and then handles the output from the binary—think of it as a kind of membrane.

The Rust CLI logic for nix-policy is in the eval directory. In addition to rust-opa-wasm you’ll see dependencies on common libraries like clap and serde. Pretty standard stuff.

But you’ll also see some Nix magic in the eval package. Check out this line:

let policy_wasm = tokio::fs::read("%policy%").await?;

Why %policy% here and not a standard filename? That’s because the mkPolicyEvaluator function stores the supplied Rego file in the Nix store. So we need to supply the Rust code with that path before the code is compiled. Yikes! Well, not really yikes, because Nixpkgs has a function called substituteInPlace that I use to replace %policy% with the right filepath before the CLI is compiled. I do something similar with the policy name and the entrypoint. And so Nix enables me to assemble the exact Rust code I want inside the derivation that produces the CLI.

Putting it to use

I’ve provided a few example policies in the repo:

Let’s run the Nix flake checker on the nix-policy repo’s flake.lock:

git clone https://github.com/DeterminateSystems/nix-policy
cd nix-policy
nix build .#flake-checker
./result/bin/flake-checker ./flake.bad.lock

Uh-oh!

ERROR: 2 problems were encountered
> Disallowed Git ref for Nixpkgs: some-ancient-ref
> Outdated Nixpkgs dependency is 118 days old while the limit is 30

The flake.rego policy stipulates two things:

  • If your root nixpkgs dependency is based on a specific Git reference, such as nixpkgs-unstable, that reference has to be explicitly allowed in the allowed_refs array in the flake.json file. In this case, some-ancient-ref doesn’t make the cut, so the flake.lock is rejected.
  • The root nixpkgs dependency must have been updated in the last N days, where N is specified in the max_days field of the flake.json data file.

Let’s take a look at these policies:

package flake

# Values from the flake.json data file
import data.allowed_refs as allowed_refs
import data.max_days as max_days

# This keyword hasn't yet landed in stable Rego
import future.keywords.in

# I rename the input to be a bit more domain specific
import input as flake_lock

# Helper function
has_key(obj, k) {
	  _ = obj[k]
}

# Deny flake.lock files with a Git ref that's not included in the provided data.json
deny[{
    "issue": "disallowed-nixpkgs-ref",
    "detail": {
        "disallowed_ref": ref,
    },
}] {
    has_key(flake_lock.nodes.root.inputs, "nixpkgs")
    nixpkgs_root := flake_lock.nodes.root.inputs.nixpkgs
    nixpkgs := flake_lock.nodes[nixpkgs_root] # The root nixpkgs node
    has_key(nixpkgs.original, "ref") # Check if nixpkgs has an explicit Git ref
    ref := nixpkgs.original.ref
    not ref in allowed_refs # Ensure that the ref is explicitly allowed
}

# Deny flake.lock files where any Nixpkgs was last updated more than 30 days ago
deny[{
    "issue": "outdated-nixpkgs-ref",
    "detail": {
        "age_in_days": floor(age / ((24 * 60) * 60)),
        "max_days": data.max_days,
    },
}] {
    has_key(flake_lock.nodes.root.inputs, "nixpkgs")
    nixpkgs_root := flake_lock.nodes.root.inputs.nixpkgs
    nixpkgs := flake_lock.nodes[nixpkgs_root] # The root nixpkgs node
    last_mod := nixpkgs.locked.lastModified
    age := (time.now_ns() / 1000000000) - last_mod
    secs_per_max_period := max_days * ((24 * 60) * 60)
    age > secs_per_max_period
}

And there we have it! A Rego policy for flake.lock files transformed into a single-purpose CLI tool that can run on any Linux or macOS system. In principle, you could apply a much broader range of policies to Nix flakes, including explicit allowlists and denylists for dependencies, Git refs, and Git repositories.

Be wary of size

As always in software, there’s a downside to this approach. The CLIs that nix-policy generates are pretty bulky, usually over 10 MB. But it’s important to keep in mind that each CLI bundles its own Wasm runtime and that you don’t need to have OPA—a bulky dependency in itself—available on your system at all (it’s already done its work during the build phase). So these aren’t exactly lean and nimble CLIs but I think they’re reasonably sized given the work they do and the dependencies that they make unnecessary.

Conclusion

Overall, this was a highly fruitful exploration of Nix and its ability to package software in novel ways. I wasn’t sure that turning Rego policies into CLIs would be possible at the outset but at every turn Nix provided the levers I needed to get over the hump. I’ll be writing about more adventures using Nix to package WebAssembly here on the blog soon, so stay tuned!


Share
Avatar for Luc Perkins
Written by Luc Perkins

Luc is a technical writer, software engineer, and Nix advocate who's always on the lookout for qualitatively better ways of building software. He originally hails from the Pacific Northwest but has recently taken to living abroad.