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:

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.

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:

Argument Meaning
name The name of the CLI tool (like k8s-admission above)
src The source root (as in most Nix derivations)
policy The path to the Rego policy file that you want wrapped in the CLI (such as k8s_admission.rego above)
entrypoint The 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

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:

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!