Skip to main content

Write custom policies with GitOps

Add your own Open Policy Agent (OPA) Rego code to the policy that Permit.io generates, for rules that the Policy Editor can't express. This guide is for developers who have connected a Git repository to Permit and want to extend the generated policy. The worked example is a deny rule: the tmp-admin role grants access only inside a set time window, even when other policies allow the action.

Prerequisites

Why you need custom Rego for a deny rule

The policies that Permit generates for role-based, attribute-based, and relationship-based access control (RBAC, ABAC, and ReBAC) are all allow rules. A permission check returns allow: true when at least one of the policies allows the action.

The Policy Editor has no rule of the form "deny access when this condition matches, regardless of other policies". To add one, you write the condition in Rego and change how the generated root policy combines the results.

How the policy repository is organized

Permit generates Rego code for OPA. Each environment has a branch in your repository, named permit/generated/<env_id>, and each branch has this layout:

PathPackageContents
root.regopermit.rootThe entry point. Combines the generated policies and the custom package into the final allow decision.
permit/, rbac.rego, abac.rego, and other top-level filespermit.policies, permit.rbac, permit.abac, and othersCode that Permit generates from your dashboard configuration.
custom/root.regopermit.customYour custom Rego. Permit creates the file with an empty rule set.

Permit regenerates the files outside the custom folder when you change the policy. Put your code in the custom folder so a policy change doesn't overwrite it.

The PDP can also run AWS Cedar as its engine. See Policy engines. This guide covers Rego only.

Start from the custom package file

Check out the environment branch you want to change and open custom/root.rego. The file starts with the package declaration:

package permit.custom

The generated root.rego imports the permit.custom package, so the PDP evaluates your rules together with the generated policy.

The file then sets the default value of the custom allow rule:

default allow := false

With default allow := false, the custom allow rule is false unless one of your allow rules matches.

By default, the generated root.rego allows access when the generated policies allow it or when the custom allow rule is true. A deny rule needs an "and" instead. You change the combination in Enforce the deny rule.

Write custom Rego in the syntax the PDP accepts

The PDP runs OPA with the --v0-compatible flag, which the PDP sets through OPAL_INLINE_OPA_CONFIG='{"v0_compatible": true}' in the PDP Dockerfile. Write your Rego in the pre-1.0 form that OPA's v0 mode parses:

RuleForm the PDP acceptsForm the PDP rejects
Rule bodyallow { ... }, with no keyword before the bodyallow if { ... }
The in operatorimport future.keywords.in, then "rbac" in policies.__allow_sources"rbac" in policies.__allow_sources with no import

A module written in OPA 1.0 syntax fails to parse in v0 mode, with the error rego_parse_error: 'if' keyword is required before rule body. To check a file before you push it, install the OPA command-line tool and run opa check --v0-compatible custom/root.rego.

Write the deny rule

The custom allow rule for the example is true in three cases. Each case is its own rule, and Rego combines rules with the same name with "or", so the custom allow rule is true when any of the three matches:

  • The decision doesn't come from RBAC.
  • RBAC allowed the request, but the tmp-admin role wasn't one of the allowing roles.
  • The tmp-admin role allowed the request, and the current time is inside the window.

Add all three rules to custom/root.rego, in any order:

  1. Allow decisions that don't come from RBAC. Import the generated permit.policies package after the package declaration:

    import data.permit.policies

    The policies.__allow_sources set lists which generated policies allowed the request, such as "rbac" or "abac". Add a rule after default allow := false that is true when RBAC isn't one of them:

    allow {
    not "rbac" in policies.__allow_sources
    }

    The in operator needs import future.keywords.in in OPA's v0 mode. Add that import above the permit imports.

  2. Allow RBAC decisions that don't use the tmp-admin role. Import the generated permit.rbac package:

    import data.permit.rbac

    The rbac.allowing_roles set lists the roles that allowed the request. Add a second allow rule that is true when RBAC allowed the request and tmp-admin isn't one of the allowing roles:

    allow {
    "rbac" in policies.__allow_sources
    not "tmp-admin" in rbac.allowing_roles
    }
  3. Allow the tmp-admin role only inside the time window. Add two rules that hold the start and end of the window in nanoseconds, and a third allow rule that compares the current time with them:

    # Replace both timestamps with the start and end of your window.
    window_start_ns := time.parse_rfc3339_ns("2026-01-01T00:00:00Z")
    window_end_ns := time.parse_rfc3339_ns("2027-01-01T00:00:00Z")

    allow {
    "tmp-admin" in rbac.allowing_roles
    time.now_ns() >= window_start_ns
    time.now_ns() <= window_end_ns
    }

    The two timestamps in the example are placeholders. Replace them with the RFC 3339 start and end of your own window before you push the file. Once window_end_ns has passed, every request whose only allowing role is tmp-admin is denied, so a stale window silently removes that role's access. Instead of fixed strings, you can compare against user or resource attributes from the input document, so the window comes from your data instead of the policy file.

The complete custom/root.rego file:

package permit.custom

import future.keywords.in
import data.permit.policies
import data.permit.rbac

default allow := false

# Replace both timestamps with the start and end of your window.
window_start_ns := time.parse_rfc3339_ns("2026-01-01T00:00:00Z")
window_end_ns := time.parse_rfc3339_ns("2027-01-01T00:00:00Z")

# The decision does not come from RBAC.
allow {
not "rbac" in policies.__allow_sources
}

# RBAC allowed the request, but not through the tmp-admin role.
allow {
"rbac" in policies.__allow_sources
not "tmp-admin" in rbac.allowing_roles
}

# The tmp-admin role allowed the request, and the current time is inside the window.
allow {
"tmp-admin" in rbac.allowing_roles
time.now_ns() >= window_start_ns
time.now_ns() <= window_end_ns
}

Enforce the deny rule

The generated root.rego in the root of the branch has two allow rules. In Rego, two rules with the same name combine with "or", so access is allowed when either rule is true:

allow {
policies.allow
}

allow {
custom.allow
}

Replace both rules with one rule that requires both conditions:

allow {
policies.allow
custom.allow
}

After the change, access is allowed only when the generated policies allow the request and the custom allow rule is true. The comments that Permit generates in root.rego describe the same change.

An empty custom rule denies every request

After the change, the custom allow rule must be true for every request you want to allow. If custom/root.rego has only default allow := false and no matching allow rule, the PDP denies every permission check in the environment.

Re-apply the root.rego change after a policy change

root.rego sits outside the custom folder, so Permit regenerates it when you change the policy in the dashboard, and the regenerated file can overwrite the combined rule. Combining the allow rules is the one edit outside custom that Test the connection with a custom policy sanctions, and Permit's generated comments in root.rego name it. After every policy change in the dashboard, check the environment branch and re-apply the combined rule if it is gone. If the combined rule disappears, the generated policies and the custom rule go back to combining with "or", and requests that only the custom rule denies are allowed again.

Test the custom policy

  1. Run opa check --v0-compatible custom/root.rego root.rego. The command prints nothing when both files parse.
  2. Commit your changes to custom/root.rego and root.rego, and push the environment branch permit/generated/<env_id>. Permit detects the push and sends the updated policy to the PDPs of the environment.
  3. Run a permission check for a user whose only allowing role is tmp-admin. Outside the time window, the check returns allow: false. Inside the window, the check returns allow: true.
  4. Run a permission check for a user with another role that allows the action. The check returns allow: true at any time.

The audit log shows detailed decision reasons for the generated policies. For custom Rego, the audit log shows only the result. To debug a custom rule, add a Rego print call to it. For example, add this line inside the first allow rule to write the allowing policies of each evaluation to the PDP logs:

print(policies.__allow_sources)

Add more custom rules

You can add more files and folders in the custom folder. Connect them to the decision in one of two ways:

  • Reference them from custom/root.rego. Import each package in custom/root.rego and use its rules in the custom allow rule. custom/root.rego stays the one custom entry point.
  • Reference them from the top-level root.rego. Import each package in root.rego and add its rule to an allow rule: as its own rule for an "or" effect, or inside an existing rule for an "and" effect. Permit regenerates root.rego, so re-apply these edits after a policy change, as Re-apply the root.rego change after a policy change describes.

Get help with custom policies

Custom Rego changes how the PDP decides every permission check in the environment. A mistake can deny all requests or allow requests that your policy should deny. Test custom rules in a development environment before you merge them into a production environment's branch.

To model a rule without custom code, or for help with a custom policy, ask in the Permit Slack community or book a demo.

Next steps