Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

Author a policy set

This guide takes you from an empty set to a pushed set. The flow has five steps: pull, edit, opa test, dry-run, push. At the end you know the rules for file names, packages, builtins and size, and you know how to roll back.

For the model, read Authorization policy sets first.

  • An admin role. Policy writes need the orgs:manage action.
  • The ironflow binary on your PATH and an API key in IRONFLOW_API_KEY. For a server that is not http://localhost:9123, set IRONFLOW_SERVER_URL or pass --server.
  • The opa CLI, v1 or later, for local tests.
  • spec.auth.policyEnforcement: true on the server. Without it, the set is stored but no request meets it. In dev mode (ironflow serve --dev, spec.auth.devMode) the server skips all authorization, so a set has no effect there either.
Terminal window
ironflow policy pull ./policies

pull writes the files of the active set into ./policies. For an organization with no set, the directory holds only the helper files. pull also writes these files:

  • .ironflow-policy.json holds the version, the decision rule and the mode. push reads it.
  • .ironflow/lib/irn.rego is the IRN library. It makes opa test work on your machine.
  • .ironflow/input.schema.json is the schema of input. See Test policies locally.

If ./policies holds .rego files that the server does not have, pull stops and lists them. Run it again with --prune to delete exactly those files.

A new policy uses the native view of input. Create ./policies/authz.rego:

package ironflow.authz
deny contains "members cannot delete runs" if {
"member" in input.subject.roles
input.request.action == "runs:delete"
}

The rule deny is a set of strings. When the set is not empty, the request is denied. Each string is a reason. Reasons go to the audit row and to dry-run, so write text that helps the person who reads it.

To match a resource, use the IRN library:

package ironflow.authz
import data.ironflow.lib.irn
deny contains "members cannot touch production functions" if {
"member" in input.subject.roles
irn.match("irn:ironflow:*:*:function:env_prod:*", input.request.resource)
}

A set can have many files. Files import each other with normal Rego imports. This example puts a helper in lib/common.rego:

package acme.lib
is_service_account if endswith(input.subject.user_email, "@svc.acme.test")

and uses it in authz.rego:

package ironflow.authz
import data.acme.lib
import data.ironflow.lib.irn
deny contains "service accounts cannot touch production secrets" if {
lib.is_service_account
irn.match("irn:ironflow:*:*:secret:env_prod:*", input.request.resource)
}

subject.user_email is an empty string for a raw API key without a bound user. Test the field before you use it.

Write tests next to the rules. A test file ends in _test.rego:

package ironflow.authz_test
import data.ironflow.authz
test_member_cannot_delete_runs if {
count(authz.deny) == 1 with input as {
"request": {"action": "runs:delete"},
"subject": {"roles": ["member"]},
}
}
test_admin_can_delete_runs if {
count(authz.deny) == 0 with input as {
"request": {"action": "runs:delete"},
"subject": {"roles": ["admin"]},
}
}

Run them:

Terminal window
opa test ./policies

Ironflow stores _test.rego files with the set, so pull returns the repository that you pushed. The engine never evaluates test rules.

A dry-run sends one input to the server and returns the decision. It stores nothing. Write the input to request.json:

{
"request": { "action": "runs:delete", "resource": "irn:ironflow:org_acme:proj_default:run:env_default:run_1", "environment": "env_default", "org_id": "org_acme" },
"subject": { "id": "user_1", "roles": ["member"], "user_email": "ann@acme.test" }
}
Terminal window
ironflow policy dry-run ./policies --input request.json

Output:

decision: deny (set mode: monitor)
denied by: deny
- members cannot delete runs

Without a directory, dry-run evaluates the active set on the server. Add --explain to print the trace notes. See Debug a deny.

Validate alone, with no input, checks that the set compiles and passes every write rule:

Terminal window
ironflow policy validate ./policies
Terminal window
ironflow policy push ./policies

push replaces the whole set with every .rego file under the directory. It skips .ironflow/ and .git/. It sends the version from .ironflow-policy.json in an If-Match header. If another admin wrote first, the server answers 412 and the CLI tells you to pull again.

The first push of a new set is in monitor mode. To choose the mode, pass --mode enforce or --mode monitor. If you leave the flag out, the mode stays as it is. To set a decision rule, pass --decision data.envoy.authz.allow. See Roll out with monitor mode.

Before it saves, the server runs the self-lockout preflight. The preflight evaluates the candidate set for every admin who must keep the right to read and write the policy set. If the set would deny one of them, the write fails with policy_set_self_lockout. See Recover from a lockout.

If the answer is a 503 with policy_set_not_propagated, the set is saved as version N but not active on every node. Run the same push again. The CLI has already recorded N, so the repeat is a no-op that finishes propagation.

The server checks these rules on every write, and in validate. One failure rejects the whole set with HTTP 400 and one error per file.

A name is a relative path with / separators. Each segment uses only letters, digits, _, . and -. The name ends in .rego. Names with .. or . segments, a leading /, empty segments or control characters are rejected. A file in a .ironflow or .git directory is rejected too, in any letter case, because ironflow policy push does not read those directories. Two names that differ only in case are rejected. Error code: policy_set_bad_file_name.

A file in Rego v0 syntax fails with policy_set_v0_syntax. Convert it, and keep the logic, with:

Terminal window
opa fmt --v0-v1 -w ./policies

You cannot declare a package under data.ironflow. Two names are open to you: ironflow.authz for the entry point, and ironflow.authz_test for tests. Ironflow injects its IRN library as ironflow.lib.irn. Any other package under ironflow fails with policy_set_reserved_namespace. Packages such as envoy.authz or acme.lib are free. All reserved names are listed in Reserved names.

deny must be a partial set rule: deny contains <reason> if { ... }. The decision rule must be a complete rule, such as allow := ..., that returns a boolean or an object. It cannot be a set or a function, and it must live outside data.ironflow. Error codes: policy_set_entrypoint_type and policy_set_bad_decision.

Rules in package ironflow.authz are also type-checked against the input schema. A wrong field type, for example input.request.action + 1, fails with policy_set_schema_error. Other packages are not type-checked, because they are your own policies for your own input shape.

These builtins do input or output, read engine state, or can allocate memory without bound in one call that evaluation cannot interrupt. A set that uses one fails with policy_set_builtin_forbidden:

  • http.send
  • net.lookup_ip_addr
  • opa.runtime
  • json.match_schema, json.verify_schema
  • bits.lsh
  • regex.replace, regex.split, regex.find_n, regex.find_all_string_submatch_n, regex.template_match
  • indexof_n

Pure builtins stay. This includes time.* for time windows and net.cidr_* for address math. The list of allowed builtins is internal/auth/policyset/testdata/allowed-builtins.golden in the repository.

sprintf has two extra rules. The format must be a string literal. The format may have at most 32 verbs, no width or precision above 9999, and no * width or precision. Otherwise the write fails with policy_set_sprintf_format. replace and split on large strings can still allocate much memory. Keep the strings that they read small.

LimitValueError
Files per set500policy_set_too_large
Source per set2 MiBpolicy_set_too_large
Compile time2 secondspolicy_set_compile_timeout
Evaluation time per request50 msThe request is denied in enforce mode (monitor mode records would_deny).
Audit reason1 KiBThe reasons are cut with a …(+N more) suffix.

The limits apply to each organization on each node. A cluster of N nodes allows N times the rate.

  • Writes (push and rollback): one write every 6 seconds, with a burst of 5. Above the limit the answer is 429 with Retry-After in whole seconds.
  • Checks (validate and dry-run): one request every second, with a burst of 10.
  • All compiles in flight on one node: at most 4. Above that, the answer is 429 with policy_set_busy.

Every write that changes the set creates a version. A write that equals the active set (same files, decision and mode) creates no version and returns the current one. Ironflow keeps the newest 100 versions for each organization.

List the versions, newest first:

Terminal window
ironflow policy versions
VERSION MODE CREATED BY DECISION
4 enforce 2026-10-12T08:00:00.000000Z user_ann
3 monitor 2026-10-11T09:11:48.000000Z user_ann
2 monitor 2026-10-10T14:30:11.000000Z user_bob data.envoy.authz.allow
1 monitor 2026-10-09T10:14:02.000000Z user_bob

Roll back with the number of the version you want:

Terminal window
ironflow policy rollback 3 --dir ./policies

A rollback is a new write. It stores the files and the decision rule of version 3 as version 5. History never changes. These rules apply:

  • The mode stays. A rollback restores files and the decision rule. It keeps the current mode. Rolling back does not switch enforcement on or off.
  • Pull afterward. The CLI does not change your local directory on a rollback. Run ironflow policy pull ./policies to get the restored files and the new version number.
  • Same safeguards as a push. A rollback needs the current version in If-Match (from --dir or --if-version), runs the self-lockout preflight and is rate-limited like a push.

The dashboard shows a per-file diff between two versions under Version history.

The page Policies (/policies) edits the same set. It has a file tree, a code editor with Rego highlighting, a Decision rule field and a Mode switch. The Validate button checks the draft with the server. A dry-run pane evaluates an input against the draft and can show the explain trace. Save writes the draft. The page asks before you leave with unsaved changes. Switching from monitor to enforce opens a dialog with the count of would-deny rows from the last 24 hours.

The dashboard never bypasses the self-lockout preflight. The platform set has the same editor at /platform/policies. On the CLI, platform operators address the platform set with the --platform flag (it needs platform credentials) on pull, push, validate, dry-run, versions and rollback.