I've been running OPA policies in production for a while now. When v1.0 dropped and changed the default Rego version, we hit some breakage — not catastrophic, but annoying, and exactly the kind of thing that's easy to miss in a CI pipeline review until something silently starts returning wrong results.
This is the guide I wish I'd had. It's specifically aimed at SOC engineers and platform security teams, not application developers — the use cases are different (IaC governance, Kubernetes admission, compliance automation), and so are the migration priorities.
What Actually Changed in OPA v1.0
OPA hit v1.0 in mid-2025 and has continued evolving (currently at v1.14.0 as of February 2026). The biggest shift for existing deployments is Rego v1 as the default.
Here's what that means in practice:
The import rego.v1 Requirement
In OPA v1.x, Rego v1 is the default. If your existing policies were written for OPA 0.x without the import rego.v1 statement, they'll either fail or behave differently depending on how the OPA binary is invoked.
Old (OPA 0.x style):
package cloud.security.s3
deny[msg] {
resource := input.resource
resource.type == "aws_s3_bucket"
not resource.block_public_acls
msg := sprintf("S3 bucket '%s' is not blocking public ACLs", [resource.name])
}
New (OPA 1.x / Rego v1 style):
package cloud.security.s3
import rego.v1
deny contains msg if {
resource := input.resource
resource.type == "aws_s3_bucket"
not resource.block_public_acls
msg := sprintf("S3 bucket '%s' is not blocking public ACLs", [resource.name])
}
The differences:
import rego.v1is now at the topdeny[msg]becomesdeny contains msg{conditions becomeif {for rule bodiesdeny := {...}style sets now explicitly usecontains
The old syntax still works with explicit --rego-version=v0 flags, but you don't want to depend on that in new policy development or CI pipelines.
set() vs {} Disambiguation
In OPA 0.x, empty curly braces {} were ambiguous — they could mean an empty set or an empty object depending on context. Rego v1 requires explicit disambiguation:
- Empty set:
set() - Empty object:
{}
If you have policy code that uses {} to represent an empty set (common in helper rules), it may behave differently or fail type checking in strict mode.
The every Keyword
Rego v1 adds the every keyword for universal quantification — iterating over a collection and asserting something is true for all elements. This is a new feature rather than a breaking change, but it replaces some awkward negation patterns that were common in 0.x policies.
Old pattern (awkward negation):
all_tags_present if {
required_tags := {"Environment", "Owner", "CostCenter"}
missing := required_tags - {tag | input.resource.tags[tag]}
count(missing) == 0
}
New pattern with every:
all_tags_present if {
required_tags := ["Environment", "Owner", "CostCenter"]
every tag in required_tags {
input.resource.tags[tag]
}
}
The every version is cleaner and the intent is more obvious. Worth refactoring existing policies to use it where applicable.
Stricter Type Checking
OPA 1.x enables stricter type checking by default. This catches some policy bugs that 0.x would silently ignore — particularly around variable shadowing, unused variables, and certain type mismatches.
When you migrate, expect to find some policies that have warnings or errors from stricter checking that weren't flagged before. These are almost always real bugs, not false positives from the stricter check.
Migration Approach: Use OPA's Built-In Tools
OPA ships with tooling specifically for this migration. Don't skip it.
opa fmt — Format First
Before anything else, run opa fmt on your policy files. It will normalize whitespace and structure, making subsequent changes easier to review:
# In-place format update
opa fmt --write policies/
# Review what would change without writing
opa fmt policies/
This is safe to run first because it only changes formatting, not semantics.
opa check — Identify Breaking Issues
Run opa check with the Rego v1 target to find what breaks:
# Check all policies for v1 compatibility
opa check --rego-version v1 policies/
# More verbose output
opa check --rego-version v1 --strict policies/
This will flag:
- Policies using old
deny[msg]syntax withoutimport rego.v1 - Type checking errors
- Deprecated built-in functions
- Unused variable warnings (some of which are real bugs)
Work through the output systematically. Group errors by type — there are usually a few patterns that repeat across many policy files.
opa eval — Verify Behavior After Migration
After updating policies, verify they still produce the same outputs for your known test cases:
# Test a policy against known input
opa eval -i tests/inputs/compliant-resource.json -d policies/compliance/soc2-access-control.rego "data.compliance.soc2.access_control.deny"
# Should return an empty set for compliant input
# Should return expected violations for non-compliant input
This is especially important for policies that have gone through syntactic migration — Rego v1's type checking can surface subtle behavioral differences in edge cases.
opa test — Run Your Test Suite
If you have a test suite (and you should — more on this below), run it after migration:
opa test policies/ -v
Policies should pass the same tests before and after migration. If tests fail after migration, you've either found a bug in the original policy (now caught by stricter checking) or a migration error.
What Migration Looks Like on Real SOC Policies
Let me walk through migrating a real example — the SOC2 access control policy:
Before (OPA 0.x style):
package compliance.soc2.access_control
deny[msg] {
resource := input.resource
resource.type == "aws_iam_policy"
statement := resource.policy.Statement[_]
statement.Effect == "Allow"
statement.Action == "*"
msg := sprintf(
"SOC2 CC6.1: IAM policy '%s' grants wildcard actions",
[resource.name]
)
}
deny[msg] {
user := input.iam_users[_]
user.console_access == true
not user.mfa_active
msg := sprintf(
"SOC2 CC6.6: IAM user '%s' has console access without MFA",
[user.username]
)
}
allow {
count(deny) == 0
}
After (Rego v1 style):
package compliance.soc2.access_control
import rego.v1
deny contains msg if {
resource := input.resource
resource.type == "aws_iam_policy"
statement := resource.policy.Statement[_]
statement.Effect == "Allow"
statement.Action == "*"
msg := sprintf(
"SOC2 CC6.1: IAM policy '%s' grants wildcard (*) actions. Policies must follow least-privilege principle.",
[resource.name]
)
}
deny contains msg if {
user := input.iam_users[_]
user.console_access == true
not user.mfa_active
msg := sprintf(
"SOC2 CC6.6: IAM user '%s' has console access without MFA. MFA is required for SOC2 compliance.",
[user.username]
)
}
allow if {
count(deny) == 0
}
The structural changes are mechanical. The content — the policy logic, the messages, the SOC2 criteria references — stays the same. The migration is a syntax update, not a policy logic rethink.
The CI/CD Pipeline Implications
Your pipeline configuration needs updating alongside the policy files.
OPA Version Pinning
If you're using OPA in CI, pin the version. The behavior change between OPA 0.x and 1.x is significant enough that unpinned versions can cause unexpected policy behavior changes on OPA upgrades.
Docker:
# In your GitHub Actions or similar
- name: OPA Check
uses: docker://openpolicyagent/opa:1.14.0
with:
args: check --rego-version v1 policies/
Direct binary:
# In CI setup
OPA_VERSION="1.14.0"
curl -L -o /usr/local/bin/opa "https://github.com/open-policy-agent/opa/releases/download/v${OPA_VERSION}/opa_linux_arm64_static"
chmod +x /usr/local/bin/opa
Conftest Configuration
If you're using Conftest (the OPA wrapper for CI config file checking), update your .conftest.yaml or equivalent configuration to specify the Rego version:
# conftest.yml
policy: policies
namespace: compliance
Conftest has been tracking OPA v1 support — check your Conftest version compatibility with the OPA version you're running.
Gatekeeper (Kubernetes Admission)
If you're running OPA Gatekeeper for Kubernetes admission control, the migration path is somewhat different. Gatekeeper manages OPA versions internally, and upgrading Gatekeeper means the OPA version it uses changes.
Key points:
- Gatekeeper 3.14+ supports Rego v1 in ConstraintTemplates
- The
spec.targets[].regofield in your ConstraintTemplates will need updating - Test with
gator verifybefore deploying updated templates to production
The SOC2 Evidence Opportunity
Here's the angle most migration guides miss: OPA v1 migration is a forcing function to audit and improve your compliance policies, and that audit produces SOC2 evidence.
Let me explain the opportunity.
When you migrate your policy library to OPA v1:
- You review every policy for correctness
- You run tests against known compliant and non-compliant states
- You document what each policy covers and which control criteria it maps to
That documentation and test output is audit evidence. It demonstrates:
- You have automated controls in place (the policies themselves)
- The controls are tested against known states (your test suite results)
- The controls map to specific TSC criteria (your policy comments and mapping docs)
- The controls are version-controlled and reviewed (your git history)
Auditors are increasingly accepting policy-as-code as evidence of controls. The migration moment is when you can produce a comprehensive policy inventory, mapping, and test results in a documented package.
Practical steps to capture the SOC2 value:
-
Standardize policy comments. Every policy should have comments identifying which TSC criteria it addresses. Our policies use the format
# Maps to: SOC2 CC6.1, CC6.6— clear enough for an auditor to understand the intent without explaining what Rego is. -
Write test cases for both compliant and non-compliant states. Auditors want to see that your controls actually detect violations. A test that proves your MFA policy fires on a user without MFA is direct evidence of control effectiveness.
-
Generate a policy inventory. A document (or automated output) listing all policies, their SOC2 criterion mapping, and their last-reviewed date is the policy library equivalent of a control register.
-
Tag policy versions to audit periods. If your SOC2 audit covers a specific time period, make sure your git history makes it clear which policy versions were active during that period. Tag releases at audit period boundaries.
Example test structure for SOC2 evidence:
# soc2-access-control_test.rego
package compliance.soc2.access_control_test
import rego.v1
# CC6.6: MFA requirement test
test_user_without_mfa_is_denied if {
input := {
"iam_users": [{
"username": "test-user",
"console_access": true,
"mfa_active": false
}]
}
count(deny) > 0 with input as input
}
test_user_with_mfa_is_allowed if {
input := {
"iam_users": [{
"username": "test-user",
"console_access": true,
"mfa_active": true
}]
}
count(deny) == 0 with input as input
}
# CC6.1: Wildcard IAM action test
test_wildcard_iam_action_is_denied if {
input := {
"resource": {
"type": "aws_iam_policy",
"name": "test-policy",
"policy": {
"Statement": [{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}]
}
}
}
count(deny) > 0 with input as input
}
Running opa test policies/ -v with output saved to a file gives you timestamped test results that serve as evidence of control testing.
Common Migration Issues and Fixes
After working through several policy libraries on this migration, here are the patterns that show up repeatedly:
Issue 1: deny[msg] iteration pattern in helper rules
Old code sometimes used deny[msg] in intermediate rules that were supposed to be sets of messages. These need to be updated to deny contains msg if pattern.
Issue 2: Undefined variable warnings becoming errors
Strict mode in OPA 1.x catches some patterns that 0.x allowed silently. Common case: a variable defined in one rule head that's used in the body before being assigned. This is usually a real bug.
Issue 3: with keyword changes in tests
The with keyword for test mocking works slightly differently in Rego v1. If your tests use with input as input patterns, verify they still work correctly. The semantics shifted slightly around nested with clauses.
Issue 4: Built-in deprecations
Some OPA built-in functions were deprecated in 0.x and removed in 1.x. re_match → regex.match, for example. The opa check output will flag these.
Migration Checklist
For a structured migration:
- Run
opa check --rego-version v1 policies/— capture and categorize all errors - Run
opa fmt --write policies/— normalize formatting - Add
import rego.v1to all policy files - Convert
deny[msg]todeny contains msg ifpattern - Convert
allow { }toallow if { }pattern - Fix
set()vs{}ambiguities - Run
opa test policies/ -v— verify all tests pass - Update CI pipeline to pin OPA version
- Update Gatekeeper ConstraintTemplates if applicable
- Add/update SOC2 criterion mapping comments
- Write tests for any policies that don't have them
- Generate and save policy inventory document
- Save test run output for SOC2 audit evidence
The migration is mechanical but worth doing carefully. The syntactic changes are not complicated — the risk is in doing it hastily and introducing subtle behavioral changes that you don't catch until a compliance check fails silently.
The upside of doing it carefully: a well-migrated, well-tested OPA v1 policy library is a meaningful compliance asset. It's not just infrastructure that passes checks — it's documented, tested evidence that your controls work, presented in a format that auditors increasingly understand and accept.
That's worth the weekend of migration work.