One of my absolute favorite aspects of Open Policy Agent (OPA) is the general purpose nature of the tool. While commonly seen in deployments for Kubernetes admission control or application authorization, the large OPA ecosystem includes integrations with anything from databases, and operating systems to test frameworks and REST clients for most common languages. OPA is truly a versatile tool, and projects like Conftest, with its approach of policy based testing of files, have helped push OPA into even more domains by allowing conversion of non-JSON formats, like INI files or even Dockerfiles, into a JSON format consumable by OPA and Rego.
But what about Rego itself? Could we possibly use OPA and Rego to write policy on our policies? While the idea might sound wild at first, it’s not such a stretch to imagine, for example, internal company standards to emerge around naming conventions, structure and rules around what policy should look like. Many developers have adopted linters as a natural part of their toolset, and for any given language there are often many high quality linters to choose from. A good linter can help not only with enforcing agreed upon conventions in teams and organizations, but can — perhaps even more importantly — also help avoid many of the common mistakes found in code of the language targeted by the linter. Could something like that be useful for Rego? Let’s find out!
The first challenge we’ll need to address is that of the Rego format. In order to write Rego policies that evaluate other Rego policies, we’ll first need to transform the Rego source file into a format accepted by OPA—e.g. JSON. While Rego itself obviously looks entirely different from JSON, one of the commands accepted by the OPA program could help us with this: opa parse.
So what does opa parse do? From the minimal description given by opa parse --help we get to know that the command is made to “Parse Rego source file and print AST”. AST here refers to the abstract syntax tree—an internal representation of the Rego source code as “seen” by OPA itself. While this representation is not as easy on the eyes as the source code from which it was produced, it lends itself perfectly for programmatic access and manipulation. Since the code is represented in a tree structure it can be represented as JSON (provided by adding --format json to the command)—just what we need for policy authoring! Let’s start with a simple policy:
In seven lines of Rego, we have an RBAC policy that denies anyone except for those granted the admin role. What would the AST of such a policy look like? Let’s have a look!
opa parse --format json rbac.rego
Our seven lines of Rego transformed into 85 lines of JSON! Don’t be intimidated though — most of it is just pretty printed formatting. Let’s go through the AST and see if we can find anything suitable for a policy governing policy! The order of the AST follows that of the Rego code, so the first block we’ll encounter is naturally the package declaration. If we take a look at it in isolation, we could see potential for a policy around e.g. naming conventions.
Building linter policy
Let’s say we wanted all policies in our organization to be named in three parts —the department responsible for the policy, the tier to which it is deployed and finally the name of the policy. An example policy to enforce this could then look something like this:
Let’s try running our new package_name policy against the previous rbac.rego one. We can do this by piping the output of the opa parse command directly into opa eval:
opa parse --format json rbac.rego | \
opa eval --data package_name.rego --stdin-input 'data.rego.deny'
In the output of the opa eval command we should now see the reason for denial: "Package rbac.authz does not follow naming convention department.tier.name". Changing the package name to something like “finance.backend.authz” should make the “test” pass.
In the simple example policy we started with, we have a single rule named allow. While names like that don’t have any particular meaning in Rego, they certainly have for us writing and reading the policy. We could enforce naming conventions on rules similarly to how we did it for packages, but let’s instead spice things up a bit for our allow rule. A pretty good practice when writing allowlist rules (as opposed to denylist rules) is to use a default rule to ensure that even if none of the rules evaluates to true, the result of evaluating the allow rule will come back not as undefined but as false. Let’s create a policy that checks for the presence of an allow rule and, if found, requires that it is assigned a default value of false.
If we look at the rules list in the AST we’ll see our allow rule appear twice — one time for the default declaration, and a second time for the “normal” rule. You’ll probably notice that the AST representation of the default rules differs somewhat from the text representation — while it does not include a body in our policy it does so in the AST; one that always evaluates to true. In order to build our policy we’d however only need to check for two attributes in the list of rules, the name and whether the rule has a default attribute.
Here we are able to succinctly express our requirements by iterating over the list of rules, check for the presence of an allow rule, and if found require that it has a default attribute equal to false. Let’s run the “linter” rule on our policy:
opa parse --format json rbac.rego | \
opa eval --data require_default.rego --stdin-input 'data.rego.deny'
Since we’ve used a default value for allow we should see no violations reported. Try and remove the default assignment and re-run the check — a warning should now be emitted.
OPA includes over 150 built-in functions. While all of them are useful in the right context it doesn’t mean they are all useful or safe in the context we are targeting. If you ever tried the Rego Playground you might have noticed that using the http.send function will render an error about using an “unsafe built-in function”. There is obviously nothing inherently unsafe about a HTTP client function, but allowing arbitrary network calls to any server from a publicly hosted site might not be the best idea. Similarly, you might find that some of the built-ins offered by OPA should best be forbidden, or at least discouraged, in the environment your policy targets.
Let’s say our company policy requires the use of asymmetric algorithms, like RSA, for issued JSON Web Tokens, we might want to codify this requirement in policy by simply not allowing any of the symmetric HMAC algorithms to be used, either for token issuance or verification. This could be done by scanning our policies for for calls to functions like jwt.verify_hs*, or checking the input of encoding functions like io.jwt.encode_sign. For the sake of the example, let’s pick a single one of the HMAC verification functions — jwt.verify_hs256 — and see if we can scan for its presence in any given policy. We could start by creating a dummy policy containing a call to this function, just to see what the generated AST would look like.
If we look at the AST produced by opa parse we’ll see that the term representing the call to to the function (sans arguments) looks like this:
Equipped with the knowledge of the structure of a jwt.verify_hs256 call we could write a policy that traverses the AST searching for this:
The example policy above uses the built-in walk function to traverse the AST, and whenever the value encountered matches that of the jwt.verify_hs256 function, it records the path to the location of the occurrence in the AST. These locations could then be included in a report detailing the existence of any “forbidden” function and their location.
Limitations and "gotchas"
As we’ve seen, using Rego on Rego isn’t such a wild idea after all. The more people involved in policy authoring and management, the more likely the need to find common rules or style guides to adhere to. So what’s stopping us? Well, nothing really! Some limitations and “gotchas” should however be taken into account with the rego-on-rego linter approach we’ve used here:
- The opa parse command works only with a single input file at a time. You'll need to write a script to process multiple files.
- opa parse does not catch errors in policy—combine with the opa check command to ensure your policy is valid before running opa parse.
- The AST as “seen” by the parser differs somewhat from the text representation of the policy as seen in the Rego file. Some style specific linting rules may thus not be possible to write.
- Source locations (i.e. line numbers) are not currently included in opa parse output as AST structs omit location when serialized. While we’ve seen some pretty useful linter rules without this, some type of rules are almost impossible to write without this, and this should be fixed.
- Rego is not a general purpose programming language. This means that some things you might want to express for linting policy code falls outside of what Rego does best. To a large extent this could be countered by having a set of built-ins and helpers specifically for the purpose of linting.
Rego truly is versatile. So versatile in fact that we may use it even to intentionally limit what we may use it for! Or perhaps more commonly, to make sure agreed upon conventions are followed and that organizational style guides are enforced across teams. As we’ve seen, the JSON representation of the Rego AST lends itself quite well to policy authoring — and we definitely have something here to build upon.
What use cases do you see for a Rego linter? Which rules would you want to see included in one? Are there any common mistakes you find yourself doing a linter could help correct? Make your voice heard in the Github issue tracking this feature!
Learn more about OPA and Rego by taking the OPA Policy Authoring course in the Styra Academy!