Fixing 'Undefined Decision' Errors in OPA Rego Policies (2025)
A comprehensive troubleshooting guide for OPA Rego undefined decision errors. Learn why decisions are undefined, how to fix them, and best practices for robust policy writing.
What You'll Learn
Quick Diagnosis
If your policy returns undefined or {}, it means:
- No rule body evaluated to
true - Missing
defaultdeclaration - Input data doesn't match expected structure
Understanding Undefined Decisions in Rego
In Rego, a decision is undefined when no rule successfully evaluates to produce a value. This is different from returning false - undefined means "no answer" rather than "the answer is false".
What Causes Undefined?
No Matching Rules
All rule bodies failed (evaluated to false), so no value was produced.
Missing Default
No default declared, and no rule matched.
Data Path Issues
Referenced data doesn't exist in input/data.
Example: Undefined Decision
Broken Policy
package example
# No default declared
allow {
input.user == "admin" # This is the ONLY rule
} Input
{
"user": "guest"
} Result
$ opa eval -d policy.rego -i input.json "data.example.allow"
{} # Undefined - no value returned Why? The rule body input.user == "admin" is false (because user is "guest"), so the rule doesn't fire. With no default and no matching rules, the decision is undefined.
Common Causes of Undefined Decisions
1. Missing Default Value
Problem
package authz
allow {
input.role == "admin"
}
# If input.role is NOT "admin", result is undefined 2. Rule Conditions Never Match
Problem
package validation
valid_tags {
# Expecting array, but input.tags is a string
input.tags[_] == "production"
} 3. Incorrect Input Path
Problem
package check
result {
# Typo: should be input.config.enabled
input.configuration.enabled == true
} 4. Type Mismatches
Problem
package compare
match {
# input.count is string "5", comparing to integer
input.count > 3
} Fix: Missing Default Values
Always declare a default value for your policy decisions.
Solution: Add Default Declaration
Before (Undefined)
package authz
allow {
input.role == "admin"
} After (Fixed)
package authz
# Default to false if no rules match
default allow := false
allow {
input.role == "admin"
} Test
# Input
{ "role": "guest" }
# Before: undefined
# After: false (because of default) When to Use Different Defaults
Default to false (Deny by Default)
default allow := false
allow {
input.user == "admin"
} Use for: Authorization (deny unless explicitly allowed)
Default to true (Allow by Default)
default deny := true
deny {
input.contains_malware
} Use for: Validation (pass unless violation found)
Default to empty set/array
default violations := []
violations[msg] {
input.resources[i].unencrypted
msg := sprintf("Resource %v not encrypted", [i])
} Use for: Collecting multiple results
Default to object
default result := {"allowed": false, "reason": "no match"}
result := {"allowed": true, "reason": "admin"} {
input.role == "admin"
} Use for: Structured responses with metadata
Fix: Rule Never Matches
Sometimes your rule conditions are too strict or check the wrong thing.
Problem: Overly Restrictive Conditions
Broken Policy
package validation
valid_environment {
input.environment == "production"
input.region == "us-east-1"
input.encrypted == true
input.backup_enabled == true
# If ANY condition is false, rule fails
} Solution 1: Use Default + Multiple Rules
Fixed with OR Logic
package validation
default valid_environment := false
# Multiple rules act as OR
valid_environment {
input.environment == "production"
input.encrypted == true
}
valid_environment {
input.environment == "staging"
input.backup_enabled == true
} Solution 2: Add Debugging with trace()
Debug Why Rule Fails
package debug_example
allow {
trace(sprintf("Checking user: %v", [input.user]))
input.user == "admin"
trace(sprintf("Checking role: %v", [input.role]))
input.role == "superadmin"
trace("All conditions passed!")
}
# Run with --explain=notes to see trace output
# opa eval -d policy.rego -i input.json --explain=notes "data.debug_example.allow" Solution 3: Check Data Existence First
Safer Pattern
package safe_check
default result := false
result {
# Check field exists before comparing
input.tags
input.tags[_] == "production"
}
# Or use negation to check non-existence
result {
not input.disable_check # Only if field doesn't exist or is false
} Fix: Incorrect Data Path
Typos or wrong assumptions about input structure cause undefined results.
Problem: Wrong Path
Broken Policy
package access
allow {
# Assumes input.user.permissions exists
"admin" in input.user.permissions
}
# Input (different structure)
# {
# "user": {
# "roles": ["admin"] # Not "permissions"
# }
# } Solution: Validate Input Structure
Method 1: Check Field Exists
package access
default allow := false
allow {
input.user # Check user exists
input.user.roles # Check roles exists
"admin" in input.user.roles
} Method 2: Use object.get() with Default
package access
default allow := false
allow {
roles := object.get(input, ["user", "roles"], [])
"admin" in roles
} Method 3: Print Input for Debugging
# In your policy
package debug
result {
trace(sprintf("Full input: %v", [input]))
input.user
}
# Run to see what input looks like
opa eval -d policy.rego -i input.json --explain=notes "data.debug.result" Common Path Mistakes
| Wrong | Correct |
|---|---|
input.user_name | input.userName (camelCase) |
input.tags.environment | input.tags["environment"] (map access) |
data.resources | input.resources (wrong source) |
input[0].name | input.items[0].name (array in object) |
Fix: Type Mismatches
Comparing different types (string vs number, string vs boolean) causes rules to fail.
Problem: String vs Integer
Broken Policy
package comparison
exceeds_limit {
# input.count is "5" (string), comparing to integer
input.count > 3
} Solution: Convert Types
Convert String to Integer
package comparison
default exceeds_limit := false
exceeds_limit {
count := to_number(input.count)
count > 3
}
# Or check if it's already a number
exceeds_limit {
is_number(input.count)
input.count > 3
} Convert String to Boolean
package bool_check
is_enabled {
# If string "true" or "false"
lower(input.enabled) == "true"
}
# Or check actual boolean
is_enabled {
input.enabled == true
} Safe Type Checking Pattern
package safe_types
default result := false
# Handle multiple possible types
result {
is_number(input.value)
input.value > 100
}
result {
is_string(input.value)
to_number(input.value) > 100
} Type Checking Functions
| Function | Description | Example |
|---|---|---|
is_number(x) | Check if x is a number | is_number(5) = true |
is_string(x) | Check if x is a string | is_string("hello") = true |
is_boolean(x) | Check if x is boolean | is_boolean(true) = true |
is_array(x) | Check if x is an array | is_array([1,2,3]) = true |
is_object(x) | Check if x is an object | is_object({"a":1}) = true |
to_number(x) | Convert string to number | to_number("42") = 42 |
Best Practices for Robust Policies
1. Always Use Defaults
# BAD: No default
allow {
input.user == "admin"
}
# GOOD: Explicit default
default allow := false
allow {
input.user == "admin"
} 2. Validate Input Structure
package validation
# Helper to check required fields
has_required_fields {
input.user
input.resource
input.action
}
# Use in your rules
default allow := false
allow {
has_required_fields
input.user == "admin"
} 3. Use Helper Rules for Clarity
package helpers
# BAD: Complex inline logic
allow {
input.user.role == "admin"
input.user.department == "engineering"
input.resource.sensitivity != "high"
}
# GOOD: Named helper rules
default allow := false
is_admin {
input.user.role == "admin"
}
is_engineer {
input.user.department == "engineering"
}
is_low_sensitivity {
input.resource.sensitivity != "high"
}
allow {
is_admin
is_engineer
is_low_sensitivity
} 4. Write Comprehensive Tests
Test for Undefined Cases
# policy_test.rego
package authz_test
import data.authz
# Test explicit allow
test_admin_allowed {
authz.allow with input as {"user": "admin"}
}
# Test explicit deny
test_guest_denied {
not authz.allow with input as {"user": "guest"}
}
# Test missing input doesn't cause undefined
test_empty_input_denied {
not authz.allow with input as {}
}
# Test malformed input
test_malformed_input {
not authz.allow with input as {"wrong_field": "value"}
} Run Tests
opa test . -v 5. Use Fail-Safe Patterns
Pattern: Check Before Access
package safe
default result := false
# BAD: Can cause undefined
result {
input.config.enabled == true
}
# GOOD: Check existence first
result {
input.config # Check config exists
input.config.enabled == true
}
# BETTER: Use object.get with default
result {
enabled := object.get(input, ["config", "enabled"], false)
enabled == true
} 6. Document Expected Input
# policy.rego
package authz
# Expected input structure:
# {
# "user": {
# "id": string,
# "role": string,
# "department": string
# },
# "resource": {
# "type": string,
# "owner": string
# }
# }
default allow := false
allow {
input.user.role == "admin"
} Quick Reference: Debugging Undefined Decisions
| Symptom | Likely Cause | Fix |
|---|---|---|
Result is {} | No default, no rule matched | Add default rulename := value |
| Rule never fires | Conditions always false | Use trace() to debug, check input |
| Field access fails silently | Wrong path or typo | Validate structure, use object.get() |
| Comparison doesn't work | Type mismatch | Use is_number(), to_number() |
| Array iteration fails | Not actually an array | Check with is_array() |