Files
releases/docs/content/policy-testing.md
T
Stephan Renatus 04a3523b22 ast+format: introduce new keywords for rule heads: if and contains
`contains` provides an alternative way to declare partial sets:

    p contains x {
      x := { "foo": "bar"
    }

which is the same as

    p[x] {
      x := { "foo": "bar"
    }

The keyword is enabled by importing `future.keywords.contains`, and
when it _is enabled_, the format will be used for all partial sets in
that file for pretty-printing.

`if` is a new keyword allowing for more readable rule definitions:

The syntax is

    NAME [if] { EXPR [EXPR...] }

and the is a shorthand allows dropping the braces around the expression
if there is only one:

    NAME if EXPR

For example, this allows expressions like

    allow if not deny
    f(xs) if every x in xs { x != "foo" }

The one exception here are partial sets: they cannot use `if` UNLESS
they use `contains`:

    p[x] { x := "foo" }            # valid
    p contains x { x := "bar" }    # valid
    p contains x if { x := "bar" } # valid
    p[x] if { x := "foo" }         # invalid

This is because we want to interpret that differently (as an object
rule defining `p.foo = true`) in the near future.

The formatter works in the same way: if `future.keywords.if` is imported, it
will be used where it can be used.

We don't want to be too eager when it comes to introducing syntactic sugar.

So this will be rewritten, because head and body expression are on the same
line:

    p := 5 if { time.day_of_week() == "Monday" }

    # => p := 5 if time.day_of_week() == "Monday"

but this won't:

    p := 5 if {
       time.day_of_week() == "Monday"
    }

The rationale here is that if the policy author decided that they want this on
an extra line, we won't mess with it.

This also sidesteps the need to check if both the head and the single body
expression have a comment.

This change includes various docs updates. Notable exceptions are the GK docs,
since it will take a while for these keywords to be come available there; and
the frontpage: merging a PR would update the frontpage immediately, and we
don't want to show something there that isn't available in the latest release.

Signed-off-by: Stephan Renatus <stephan.renatus@gmail.com>
2022-06-22 10:36:06 +02:00

12 KiB

title, kind, weight, restrictedtoc
title kind weight restrictedtoc
Policy Testing documentation 4 true

OPA gives you a high-level declarative language (Rego) to author fine-grained policies that codify important requirements in your system.

To help you verify the correctness of your policies, OPA also gives you a framework that you can use to write tests for your policies. By writing tests for your policies you can speed up the development process of new rules and reduce the amount of time it takes to modify rules as requirements evolve.

{{< info >}} The examples in this section try to represent the best practices. As such, they make use of keywords that are meant to become standard keywords at some point in time, but have been introduced gradually. These future keywords can be enabled using

import future.keywords

{{< /info >}}

Getting Started

Let's use an example to get started. The file below implements a simple policy that allows new users to be created and users to access their own profile.

example.rego:

package authz
import future.keywords

allow if {
    input.path == ["users"]
    input.method == "POST"
}

allow if {
    input.path == ["users", input.user_id]
    input.method == "GET"
}

To test this policy, we will create a separate Rego file that contains test cases.

example_test.rego:

package authz

test_post_allowed if {
    allow with input as {"path": ["users"], "method": "POST"}
}

test_get_anonymous_denied if {
    not allow with input as {"path": ["users"], "method": "GET"}
}

test_get_user_allowed if {
    allow with input as {"path": ["users", "bob"], "method": "GET", "user_id": "bob"}
}

test_get_another_user_denied if {
    not allow with input as {"path": ["users", "bob"], "method": "GET", "user_id": "alice"}
}

Both of these files are saved in the same directory.

$ ls
example.rego      example_test.rego

To exercise the policy, run the opa test command in the directory containing the files.

$ opa test . -v
data.authz.test_post_allowed: PASS (1.417µs)
data.authz.test_get_anonymous_denied: PASS (426ns)
data.authz.test_get_user_allowed: PASS (367ns)
data.authz.test_get_another_user_denied: PASS (320ns)
--------------------------------------------------------------------------------
PASS: 4/4

The opa test output indicates that all of the tests passed.

Try exercising the tests a bit more by removing the first rule in example.rego.

$ opa test . -v
FAILURES
--------------------------------------------------------------------------------
data.authz.test_post_allowed: FAIL (277.306µs)

  query:1                 Enter data.authz.test_post_allowed = _
  example_test.rego:3     | Enter data.authz.test_post_allowed
  example_test.rego:4     | | Fail data.authz.allow with input as {"method": "POST", "path": ["users"]}
  query:1                 | Fail data.authz.test_post_allowed = _

SUMMARY
--------------------------------------------------------------------------------
data.authz.test_post_allowed: FAIL (277.306µs)
data.authz.test_get_anonymous_denied: PASS (124.287µs)
data.authz.test_get_user_allowed: PASS (242.2µs)
data.authz.test_get_another_user_denied: PASS (131.964µs)
--------------------------------------------------------------------------------
PASS: 3/4
FAIL: 1/4

Test Format

Tests are expressed as standard Rego rules with a convention that the rule name is prefixed with test_.

package mypackage

test_some_descriptive_name if {
    # test logic
}

Test Discovery

The opa test subcommand runs all of the tests (i.e., rules prefixed with test_) found in Rego files passed on the command line. If directories are passed as command line arguments, opa test will load their file contents recursively.

Specifying Tests to Run

The opa test subcommand supports a --run/-r regex option to further specify which of the discovered tests should be evaluated. The option supports re2 syntax

Test Results

If the test rule is undefined or generates a non-true value the test result is reported as FAIL. If the test encounters a runtime error (e.g., a divide by zero condition) the test result is marked as an ERROR. Tests prefixed with todo_ will be reported as SKIPPED. Otherwise, the test result is marked as PASS.

pass_fail_error_test.rego:

package example

# This test will pass.
test_ok if true

# This test will fail.
test_failure if 1 == 2

# This test will error.
test_error if 1 / 0

# This test will be skipped.
todo_test_missing_implementation if {
    allow with data.roles as ["not", "implemented"]
}

By default, opa test reports the number of tests executed and displays all of the tests that failed or errored.

$ opa test pass_fail_error_test.rego
data.example.test_failure: FAIL (253ns)
data.example.test_error: ERROR (289ns)
  pass_fail_error_test.rego:15: eval_builtin_error: div: divide by zero
--------------------------------------------------------------------------------
PASS: 1/3
FAIL: 1/3
ERROR: 1/3

By default, OPA prints the test results in a human-readable format. If you need to consume the test results programmatically, use the JSON output format.

opa test --format=json pass_fail_error_test.rego
[
  {
    "location": {
      "file": "pass_fail_error_test.rego",
      "row": 4,
      "col": 1
    },
    "package": "data.example",
    "name": "test_ok",
    "duration": 618515
  },
  {
    "location": {
      "file": "pass_fail_error_test.rego",
      "row": 9,
      "col": 1
    },
    "package": "data.example",
    "name": "test_failure",
    "fail": true,
    "duration": 322177
  },
  {
    "location": {
      "file": "pass_fail_error_test.rego",
      "row": 14,
      "col": 1
    },
    "package": "data.example",
    "name": "test_error",
    "error": {
      "code": "eval_internal_error",
      "message": "div: divide by zero",
      "location": {
        "file": "pass_fail_error_test.rego",
        "row": 15,
        "col": 5
      }
    },
    "duration": 345148
  }
]

Data and Function Mocking

OPA's with keyword can be used to replace the data document or called functions with mocks. Both base and virtual documents can be replaced.

When replacing functions, built-in or otherwise, the following constraints are in place:

  1. Replacing internal.* functions, or rego.metadata.*, or eq; or relations (walk) is not allowed.
  2. Replacement and replaced function need to have the same arity.
  3. Replaced functions can call the functions they're replacing, and those calls will call out to the original function, and not cause recursion.

Below is a simple policy that depends on the data document.

authz.rego:

package authz
import future.keywords

allow {
    some x in data.policies
    x.name == "test_policy"
    matches_role(input.role)
}

matches_role(my_role) if input.user in data.roles[my_role]

Below is the Rego file to test the above policy.

authz_test.rego:

package authz
import future.keywords

policies := [{"name": "test_policy"}]
roles := {"admin": ["alice"]}

test_allow_with_data if {
    allow with input as {"user": "alice", "role": "admin"}
      with data.policies as policies
      with data.roles as roles
}

To exercise the policy, run the opa test command.

$ opa test -v authz.rego authz_test.rego
data.authz.test_allow_with_data: PASS (697ns)
--------------------------------------------------------------------------------
PASS: 1/1

Below is an example to replace a rule without arguments.

authz.rego:

package authz
import future.keywords

allow1 if allow2

allow2 if 2 == 1

authz_test.rego:

package authz
import future.keywords

test_replace_rule if {
    allow1 with allow2 as true
}
$  opa test -v authz.rego authz_test.rego
data.authz.test_replace_rule: PASS (328ns)
--------------------------------------------------------------------------------
PASS: 1/1

Here is an example to replace a rule's built-in function with a user-defined function.

authz.rego:

package authz
import future.keywords

import data.jwks.cert

allow if {
    [true, _, _] = io.jwt.decode_verify(input.headers["x-token"], {"cert": cert, "iss": "corp.issuer.com"})
}

authz_test.rego:

package authz
import future.keywords

mock_decode_verify("my-jwt", _) := [true, {}, {}]
mock_decode_verify(x, _)        := [false, {}, {}] if x != "my-jwt"

test_allow if {
    allow
      with input.headers["x-token"] as "my-jwt"
      with data.jwks.cert as "mock-cert"
      with io.jwt.decode_verify as mock_decode_verify
}
$  opa test -v authz.rego authz_test.rego
data.authz.test_allow: PASS (458.752µs)
--------------------------------------------------------------------------------
PASS: 1/1

In simple cases, a function can also be replaced with a value, as in

test_allow_value if {
    allow
      with input.headers["x-token"] as "my-jwt"
      with data.jwks.cert as "mock-cert"
      with io.jwt.decode_verify as [true, {}, {}]
}

Every invocation of the function will then return the replacement value, regardless of the function's arguments.

Note that it's also possible to replace one built-in function by another; or a non-built-in function by a built-in function.

authz.rego:

package authz
import future.keywords

replace_rule if {
    replace(input.label)
}

replace(label) if {
    label == "test_label"
}

authz_test.rego:

package authz
import future.keywords

test_replace_rule if {
    replace_rule with input.label as "does-not-matter" with replace as true
}
$ opa test -v authz.rego authz_test.rego
data.authz.test_replace_rule: PASS (648.314µs)
--------------------------------------------------------------------------------
PASS: 1/1

Coverage

In addition to reporting pass, fail, and error results for tests, opa test can also report coverage for the policies under test.

The coverage report includes all of the lines evaluated and not evaluated in the Rego files provided on the command line. When a line is not covered it indicates one of two things:

  • If the line refers to the head of a rule, the body of the rule was never true.
  • If the line refers to an expression in a rule, the expression was never evaluated.

It is also possible that rule indexing has determined some path unnecessary for evaluation, thereby affecting the lines reported as covered.

If we run the coverage report on the original example.rego file without test_get_user_allowed from example_test.rego the report will indicate that line 8 is not covered.

opa test --coverage --format=json example.rego example_test.rego
{
  "files": {
    "example.rego": {
      "covered": [
        {
          "start": {
            "row": 3
          },
          "end": {
            "row": 5
          }
        },
        {
          "start": {
            "row": 9
          },
          "end": {
            "row": 11
          }
        }
      ],
      "not_covered": [
        {
          "start": {
            "row": 8
          },
          "end": {
            "row": 8
          }
        }
      ]
    },
    "example_test.rego": {
      "covered": [
        {
          "start": {
            "row": 3
          },
          "end": {
            "row": 4
          }
        },
        {
          "start": {
            "row": 7
          },
          "end": {
            "row": 8
          }
        },
        {
          "start": {
            "row": 11
          },
          "end": {
            "row": 12
          }
        }
      ]
    }
  }
}