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
, byPOST
ing 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
:
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:
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
:
You could then use k8s-admission
to evaluate a JSON object encapsulating a Kubernetes admission control request:
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
- 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:
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
:
Uh-oh!
The flake.rego
policy stipulates two things:
- If your root
nixpkgs
dependency is based on a specific Git reference, such asnixpkgs-unstable
, that reference has to be explicitly allowed in theallowed_refs
array in theflake.json
file. In this case,some-ancient-ref
doesn’t make the cut, so theflake.lock
is rejected. - The root
nixpkgs
dependency must have been updated in the last N days, where N is specified in themax_days
field of theflake.json
data file.
Let’s take a look at these policies:
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!