How to convert Sentinel policies to OPA and Rego
Leaving Terraform Cloud usually means leaving Sentinel. Most Sentinel policies are short and translate cleanly to Rego once you know how the concepts map.
How Sentinel concepts map to Rego
| Sentinel | Rego on StackShip |
|---|---|
import "tfplan/v2" | input.resource_changes |
filter and all | some x in and rule bodies, which are already "for each" |
main = rule { ... } returns true or false | decision returns a list of findings; empty means pass |
print() to explain a failure | Each finding's message and resource_address |
| Advisory, soft-mandatory, hard-mandatory | The same three enforcement modes, set when you attach the policy |
sentinel test | opa test |
The biggest shift is the result. A Sentinel rule says pass or fail. A Rego policy returns findings, one per problem, so the reviewer sees exactly which resources broke which rule.
A side-by-side rewrite
A common Sentinel policy limits EC2 instance types. In Sentinel:
import "tfplan/v2" as tfplan
allowed_types = ["t3.micro", "t3.small", "t3.medium"]
instances = filter tfplan.resource_changes as _, rc {
rc.type is "aws_instance" and
(rc.change.actions contains "create" or rc.change.actions contains "update")
}
main = rule {
all instances as _, instance {
instance.change.after.instance_type in allowed_types
}
}The same policy in Rego, as a StackShip policy set:
package stackship.v1
import rego.v1
decision := {"findings": findings}
allowed_types := {"t3.micro", "t3.small", "t3.medium"}
# Matches aws_instance resources at the root or inside modules.
instance_address := `^(module\.[^.]+(\[[^\]]*\])?\.)*aws_instance\.`
findings contains finding if {
some change in input.resource_changes
regex.match(instance_address, change.address)
some action in change.change.actions
action in {"create", "update"}
not change.change.after.instance_type in allowed_types
finding := {
"id": sprintf("instance-type:%s", [change.address]),
"title": "Instance type not allowed",
"message": sprintf(
"%s uses %s. Allowed: %s.",
[change.address, change.change.after.instance_type, concat(", ", sort(allowed_types))],
),
"resource_address": change.address,
}
}Three differences to notice:
- Rego has no
all; you write the rule for the bad case and each match becomes a finding. - The finding names the resource and the value, so nobody has to dig through logs to see what failed.
- StackShip's policy input identifies resources by
address, so the type comes from a pattern on the address. The pattern skips data sources.
Test the rewrite
package stackship.v1_test
import rego.v1
import data.stackship.v1 as policy
instance(address, type) := {
"address": address,
"provider_name": "registry.opentofu.org/hashicorp/aws",
"change": {"actions": ["create"], "before": null, "after": {"instance_type": type}},
}
test_large_instance_in_module_is_flagged if {
result := policy.decision with input as {"resource_changes": [
instance("module.app.aws_instance.web[0]", "m5.4xlarge"),
]}
count(result.findings) == 1
}
test_allowed_instance_passes if {
result := policy.decision with input as {"resource_changes": [
instance("aws_instance.web", "t3.small"),
]}
count(result.findings) == 0
}Run opa check --strict . and opa test . locally. StackShip runs both again when you publish a version.
What doesn't translate directly
tfconfigandtfstateimports. StackShip policies see the plan's resource changes, not the raw configuration or full state. Rewrite configuration checks against the planned values instead. That is usually what you meant anyway.tfrunand cost estimates. Policies get the run kind (tracked, speculative, or manual) and the commit, not workspace run metadata or cost data.- HTTP calls. Policies can't call out to the network, so every result is reproducible from the plan alone.
Roll it out safely
- Rewrite and test each policy.
- Attach it as advisory and watch a week of real plans.
- Compare findings with what Sentinel reported.
- Switch to the enforcement level the Sentinel policy had.
For more examples, read OPA policy for OpenTofu plans, or see the full policy input reference.