Policy as Code: Guardrails and Compliance with OPA and Sentinel
Enforce infrastructure compliance and governance automatically using Policy as Code with Open Policy Agent (OPA), HashiCorp Sentinel, or AWS Policy.
Policy as Code: Guardrails and Compliance with OPA and Sentinel
Policy as code brings governance and compliance into your automated workflows. Instead of reviewing infrastructure changes manually or hoping naming conventions are followed, you write policies that automatically validate configurations before deployment. Bad configurations get rejected with clear explanations, and compliance becomes a side effect of the deployment process rather than an afterthought.
Three main approaches dominate the landscape. Open Policy Agent (OPA) is an open-source general-purpose policy engine. HashiCorp Sentinel is a commercial product tightly integrated with HashiCorp tools. AWS Policy is the native option for AWS environments. Each has different strengths depending on your stack and requirements.
Introduction
The core idea is straightforward. You write rules that describe what valid infrastructure looks like. During deployment, a policy engine evaluates your proposed configuration against those rules. If the configuration violates a rule, the deployment stops and you get an error explaining what went wrong and why.
Policies can enforce anything you can express programmatically. Common use cases include requiring encryption at rest, enforcing naming conventions, restricting which regions resources can be deployed to, ensuring cost controls like budget alerts exist, and preventing public exposure of sensitive resources.
The alternative—manual policy review—does not scale. Human reviewers get tired, miss details, and apply rules inconsistently. Automated policy enforcement removes the variability and catches issues before they reach production.
When to Use / When Not to Use
When policy as code makes sense
Policy as code pays off when compliance failures are expensive. If you operate in regulated industries—finance, healthcare, government—or when security incidents from misconfiguration carry significant financial or reputational risk, automated policy enforcement catches problems before they reach production.
Use it when you have multiple teams deploying infrastructure independently. Without automated policy checks, each team might interpret standards differently. A policy library enforced in CI creates consistency without requiring a central approver for every change.
Policy as code also helps when you need audit evidence. Regulated environments often need to prove that controls existed before deployment, not just that someone reviewed a checklist. A policy evaluation log showing which rules passed and which failed is stronger evidence than a sign-off sheet.
When to skip it
If your infrastructure is small and a single team reviews every change manually, policy as code adds overhead without much benefit. The cost of writing and maintaining policies may exceed the cost of occasional misconfigurations.
If your organization is still iterating on standards, premature policy enforcement can slow down experimentation. Locking down configurations before the team has settled on what “good” looks like creates friction without improving outcomes.
OPA Rego Language Basics
OPA uses a custom language called Rego for writing policies. Rego is declarative—you describe what makes a configuration valid rather than how to validate it.
package terraform.analysis
import future.keywords.if
import future.keywords.contains
# Deny S3 buckets without encryption
deny_s3_unencrypted if {
input.resource_changes[_].type == "aws_s3_bucket"
not input.resource_changes[_].change.after.server_side_encryption_configuration
}
# Deny EC2 instances without tags
deny_ec2_missing_tags if {
some rc in input.resource_changes
rc.type == "aws_instance"
not rc.change.after.tags
}
# Deny RDS instances that are publicly accessible
deny_rds_public_access if {
some rc in input.resource_changes
rc.type == "aws_db_instance"
rc.change.after.publicly_accessible == true
}
Rego policies operate on input documents. For Terraform, the input is a JSON representation of the plan or state. You write rules that traverse this document and return violations when conditions are met.
The deny prefix is convention—OPA does not require it. You can name your rules anything. The important part is that when a rule evaluates to true, that represents a violation.
Writing and Testing Policies
OPA ships with a testing framework that makes policy development iterative rather than trial-and-error.
package terraform.analysis
import future.keywords.if
deny_s3_unencrypted if {
some bucket in input.resource_changes
bucket.type == "aws_s3_bucket"
not bucket.change.after.server_side_encryption_configuration
}
# Tests
Test_deny_s3_unencrypted_violation {
not deny_s3_unencrypted with input as {
"resource_changes": [{
"type": "aws_s3_bucket",
"change": {
"after": {
"bucket": "my-bucket",
"server_side_encryption_configuration": null
}
}
}]
}
}
Test_allow_s3_encrypted {
deny_s3_unencrypted with input as {
"resource_changes": [{
"type": "aws_s3_bucket",
"change": {
"after": {
"bucket": "my-bucket",
"server_side_encryption_configuration": {
"rule": {
"apply_server_side_encryption_by_default": {
"sse_algorithm": "AES256"
}
}
}
}
}
}]
}
}
Run tests with opa test. Good test coverage catches regressions when you modify policies and validates that new policies behave as intended.
opa test ./policies/ -v
Integrating with CI/CD Pipelines
The real value of policy as code emerges in automated pipelines. Every infrastructure change gets evaluated against policies before deployment proceeds.
# GitHub Actions example
name: Terraform Policy Check
on:
pull_request:
paths:
- "**.tf"
- "policies/**"
jobs:
OPA:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install OPA
run: |
curl -L -o opa https://openpolicyagent.org/downloads/latest/opa_linux_amd64
chmod +x opa
- name: Run Terraform plan
run: |
terraform init
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
- name: Evaluate policies
run: |
opa eval --fail-defined -d policies.rego -f pretty -I -d plan.json "data.terraform.analysis.deny"
If any policy returns a violation, the fail-defined flag causes OPA to exit with a non-zero code, failing the pipeline. Developers see the violation details in the pull request comments.
Terraform Validation with OPA
Beyond CI/CD integration, OPA can validate Terraform code directly without applying changes. This is useful for pre-merge checks that do not require running an actual plan.
The conftest tool integrates OPA with configuration files.
# .github/workflows/tf-validation.yml
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install conftest
run: |
curl -L -o conftest https://github.com/open-policy-agent/conftest/releases/download/v0.55.0/conftest_0.55.0_Linux_x86_64.tar.gz
tar xzf conftest.tar.gz
- name: Validate Terraform
run: |
conftest test . --policy policy.rego
For teams using Terraform Cloud, OPA integration happens through the Sentinel policy engine’s Rego-like syntax, which is compatible enough to port policies between the two systems.
Real-World Policy Examples
Budget enforcement policies prevent runaway infrastructure costs.
package terraform.analysis
import future.keywords.if
import future.keywords.contains
deny_missing_cost_alert if {
not input.resource_changes[_] {
.type == "aws_budgets_cost_notification"
.change.after.notification
}
}
deny_unlimited_budget if {
some rc in input.resource_changes
rc.type == "aws_budgets_cost_notification"
rc.change.after.budget_type == "USAGE"
}
Tagging policies enforce organizational naming standards.
package terraform.analysis
import future.keywords.if
required_tags := {"Environment", "Team", "CostCenter", "Application"}
deny_missing_required_tags if {
some rc in input.resource_changes
is_compute_resource(rc.type)
missing_tags := required_tags - get_tags(rc)
count(missing_tags) > 0
}
is_compute_resource(resource_type) if {
resource_type == "aws_instance"
}
is_compute_resource(resource_type) if {
resource_type == "aws_ecs_service"
}
get_tags(rc) := tags if {
tags := {k | rc.change.after.tags[k]}
}
Network policies prevent accidental public exposure.
package terraform.analysis
deny_public_rds if {
some rc in input.resource_changes
rc.type == "aws_db_instance"
rc.change.after.publicly_accessible == true
}
deny_public_load_balancer if {
some rc in input.resource_changes
rc.type == "aws_lb"
rc.change.after.scheme == "internet-facing"
not has_waf_access_logging(rc)
}
OPA vs Sentinel vs AWS Policy Comparison
| Aspect | OPA | Sentinel | AWS Policy |
|---|---|---|---|
| Cost | Free, open-source | Commercial (HashiCorp Enterprise) | Free (native AWS) |
| Scope | Multi-cloud, Kubernetes, CI/CD | HashiCorp tools only | AWS only |
| Language | Rego | Sentinel (DSL) | IAM JSON/YAML |
| Testing | Built-in test framework | Built-in test framework | IAM Access Analyzer |
| CI/CD integration | Native via conftest | Native via Terraform Cloud | Native via AWS config |
Policy Enforcement Flow
flowchart TD
A[Terraform Plan] --> B[OPA evaluates policies]
B --> C{All policies pass?}
C -->|Yes| D[Apply proceeds]
C -->|No| E[Show violations]
E --> F[Fix code]
F --> A
D --> G[State updated]
Trade-off Analysis
Policy Engine Selection
| Factor | OPA | Sentinel | AWS Policy |
|---|---|---|---|
| Cost | Free, open-source | Commercial (Terraform Cloud Enterprise) | Free (native AWS) |
| Multi-cloud | Full support | HashiCorp only | AWS only |
| Language paradigm | Rego (declarative, functional) | DSL (imperative-style) | IAM JSON/YAML |
| Learning curve | Steeper (new language) | Moderate (familiar patterns) | Gentle for AWS users |
| Policy reuse | Package system for sharing | Module system for sharing | Policy templates |
| Test framework | Built-in, strong | Built-in, strong | Limited to Access Analyzer |
| Runtime evaluation | Any stage (plan, apply, admission) | Terraform Cloud only | AWS Config, SCPs |
| CI/CD integration | Native (conftest,opa) | Native via TFC | Native via AWS config |
OPA Rego Complexity vs Simplicity
Simple policies (allow/deny) are straightforward to write and maintain. The deny_s3_unencrypted pattern—checking a single attribute and returning true if violated—is easy to read and debug.
Complex policies (exceptions, conditions, multi-resource) require careful Rego design. Using helper functions and separating data transformation from logic evaluation keeps policies readable. Writing all logic inline makes policies impossible to test in isolation.
The tradeoff is between brevity and maintainability. Inline policies are shorter but harder to test. Helper functions add indirection but enable unit testing of individual logical components.
Policy Scope Decisions
Global policies (applied to every resource) catch everything but generate noise when teams have legitimate exceptions. A global “all resources must have cost center tag” creates violations for resources that genuinely cannot have tags.
Resource-type policies target specific risky resources. “Deny S3 buckets without encryption” and “Deny RDS instances publicly accessible” focus on high-impact cases without flagging every untagged resource.
Exception-based policies start with a deny and add exceptions for known legitimate cases. This approach works well but requires maintenance—every legitimate exception becomes a permanent exception unless someone periodically reviews them.
For most organizations, resource-type policies with specific targeting strike the right balance between coverage and noise.
Policy Evaluation Timing Decisions
| Stage | Benefit | Risk |
|---|---|---|
| Pre-commit (local) | Fast feedback, no CI queue | Developers may skip |
| Pull request CI | Mandatory gate, consistent | Pipeline latency |
| Plan-time (in Terraform) | Context-aware, sees actual values | Slows down apply |
| Admission (Kubernetes) | Blocks bad deployments at runtime | Latency impact |
The standard approach is pre-merge CI checks with OPA or conftest. This catches violations before they reach any environment. For critical policies, add a second gate at plan-time that evaluates against the actual Terraform plan JSON.
Production Failure Scenarios
Common Policy Failures
| Failure | Impact | Mitigation |
|---|---|---|
| Overly strict policies | Developers bypass policies or disable enforcement | Start permissive, tighten incrementally |
| Policy conflicts | Impossible to satisfy two policies simultaneously | Audit policy interactions before deployment |
| Slow policy evaluation | CI pipeline delays | Cache OPA decisions, optimize Rego queries |
| Policies not enforced in CI | Violations reach production | Make policy checks mandatory in pipeline |
| Rego bugs allow violations | False sense of compliance | Write comprehensive tests for every policy |
Policy Debug Flow
flowchart TD
A[Policy violation in CI] --> B[Run OPA locally]
B --> C[Verbose output: opa eval -v]
C --> D{Is violation real?}
D -->|Yes| E[Fix infrastructure code]
D -->|No| F[Fix policy Rego]
E --> G[Retry pipeline]
F --> G
Observability Hooks
Track policy enforcement to catch systemic issues and measure compliance.
What to monitor:
- Policy violation rate per team or repository
- Most commonly violated policies
- Policy evaluation duration in CI
- Policy change frequency (too many changes may indicate instability)
- Exception request rate
# Run OPA with verbose output to debug
opa eval --fail-defined -d policy.rego -v "data.terraform.analysis.deny"
# Test policies against a plan file
opa eval --fail-defined -d policy.rego -f pretty \
-I -d plan.json "data.terraform.analysis.deny"
# List all policies and their pass/fail status
opa eval --explain=full -d policies/ -f pretty plan.json
# Check Rego syntax without running
opa check policy.rego
# Run policy tests
opa test ./policies/ -v
Common Pitfalls / Anti-Patterns
Writing policies before understanding the data
OPA policies operate on input documents—for Terraform, that means understanding the structure of plan.json or state.json. Writing policies without first examining the actual data structure leads to policies that never match or miss edge cases.
Making policies too broad
A policy that flags every resource creates noise and trains teams to ignore violations. Instead, target specific risky resources. “Deny S3 buckets without encryption” is actionable. “Deny resources without tags” is noisy when many resources legitimately lack tags.
Not testing policies
Rego policies are code and can have bugs. A policy with a logic error might pass everything when it should deny, or deny everything when it should pass. OPA’s test framework exists—use it.
Hardcoding exceptions in policies
When a legitimate use case violates a policy, the instinct is to add an exception. Over time, exceptions accumulate and the policy becomes meaningless. Instead, fix the policy to handle the legitimate case, or escalate to accepting the risk formally.
Interview Questions
Expected answer points:
- OPA operates on input documents—for Terraform, plan.json or state.json
- Terraform plan converts to JSON via `terraform show -json plan.tfplan`
- OPA Rego policies traverse the JSON document and return violations when conditions are met
- `opa eval --fail-defined` exits non-zero if any policy returns a violation
Expected answer points:
- OPA: free, open-source, multi-cloud, uses Rego language
- Sentinel: commercial, HashiCorp-only, DSL with imperative-style policies
- AWS Policy: free, native AWS, IAM JSON/YAML for SCPs and Access Analyzer
- OPA is vendor-neutral; Sentinel locks to HashiCorp; AWS Policy locks to AWS
Expected answer points:
- Overly strict policies cause developers to bypass or disable enforcement
- Starting permissive identifies actual violations before fining-tune rules
- Too strict creates noise, trains teams to ignore violations, defeats purpose
- Incrementally tighten as policy library matures and exceptions get documented
Expected answer points:
- Run OPA locally with verbose output: `opa eval -v -d policy.rego plan.json`
- Check if the violation is real—if yes, fix infrastructure code; if no, fix policy Rego
- Use `opa check` to verify Rego syntax without running
- Write comprehensive tests for every policy—Rego bugs cause false passes or false denies
Expected answer points:
- When a legitimate case violates a policy, instinct is to add exception
- Over time exceptions accumulate, policy becomes meaningless
- Instead: fix the policy to handle the legitimate case, or formalize risk acceptance
- Exception-based policies require ongoing maintenance to stay relevant
Expected answer points:
- conftest is a tool that applies OPA policies to configuration files
- Runs outside Terraform, validates HCL/Terraform code directly without plan
- Useful for pre-merge checks that do not require running actual plan
- `conftest test . --policy policy.rego` validates configs against policies
Expected answer points:
- Global policies catch everything but generate noise when teams have legitimate exceptions
- Resource-type policies target specific risky resources (S3 encryption, RDS public access)
- Global "deny untagged resources" is noisy; resource-type is actionable
- For most organizations, resource-type policies strike right balance between coverage and noise
Expected answer points:
- Separate data transformation from logic evaluation using helper functions
- Inline policies are shorter but harder to test in isolation
- Use packages to organize policies by domain (terraform.analysis, kubernetes.admission)
- Test each logical component independently, not just end-to-end
Expected answer points:
- Policy violation rate per team or repository—high rate indicates policy issues or training gap
- Most commonly violated policies—may need clearer documentation or easier to satisfy
- Policy evaluation duration in CI—slow evaluation indicates optimization opportunity
- Policy change frequency (too many changes = instability), exception request rate
Expected answer points:
- Pre-commit (local): fast feedback but developers may skip
- Pull request CI: mandatory gate, consistent, adds pipeline latency
- Plan-time (in Terraform): context-aware, sees actual values, slows down apply
- Standard approach is pre-merge CI with OPA/conftest; critical policies get second gate at plan-time
Expected answer points:
- `opa eval --fail-defined` exits with non-zero code when any policy returns a violation
- Without fail-defined, OPA returns 0 even if violations exist—pipeline continues silently
- In CI/CD, fail-defined causes the pipeline to fail when policy violations are detected
- Works with `-f pretty` for human-readable output or `-f json` for programmatic parsing
- Combined with `-I` for stdin input, evaluates plan.json against policies and blocks bad deployments
Expected answer points:
- `deny` rules: when true, the policy result is a violation that blocks deployment
- `warn` rules: when true, the policy result is a warning that does not block deployment
- Use deny for hard requirements (encryption, no public access), warn for soft recommendations
- You can combine both: some policies deny, others warn depending on severity
- CI/CD pipelines can be configured to treat warnings as failures for stricter enforcement
Expected answer points:
- Use packages to organize policies by domain: `package terraform.analysis.deny`
- Create helper functions for common checks: `is_encrypted(rc)`, `has_required_tags(rc)`
- Package imports let you share functions across policy files
- Data documents allow external configuration without modifying policy code
- Test each helper function independently before composing into larger rules
Expected answer points:
- OPA evaluates plan JSON output, not HCL source—so computed values may not be available
- Some resource attributes only appear after apply, not in plan output
- OPA cannot validate things like exact IAM policy structure that requires evaluation
- Terraform state must exist for import and resource attribute lookups
- Large plans create large JSON that can slow down policy evaluation
Expected answer points:
- Sentinel is HashiCorp's commercial policy-as-code product, only works with Terraform Cloud
- Sentinel uses its own DSL with imperative-style policy writing
- OPA is open-source and vendor-neutral, can evaluate plans locally before TFC submission
- Sentinel policies can import Terraform run details via the TFC API
- Porting policies between OPA and Sentinel is possible but requires rewriting Rego to Sentinel DSL
Expected answer points:
- `future.keywords.if` enables the `if` keyword for conditional rules without parentheses
- `future.keywords.contains` enables the `contains` keyword for set membership checks
- `future.keywords.default` enables default rule definitions for missing cases
- These keywords become standard in future OPA versions, future.keywords enables them early
- Makes Rego more readable: `deny if { condition }` vs `deny { condition }`
Expected answer points:
- Use external data documents to define exceptions rather than hardcoding in policy code
- Exception list stored as JSON/YAML, imported as data.exception_list in Rego
- Policy checks if the resource matches an exception before flagging violation
- Exception list reviewable and manageable without modifying policy logic
- Prevents policy code pollution from accumulated hardcoded exceptions
Expected answer points:
- Simple deny rules checking single attributes evaluate fast (milliseconds)
- Complex rules with nested iterations, multiple helper calls, and deep data traversal are slower
- For large Terraform plans, slow policies can add minutes to CI pipeline runtime
- Cache frequently evaluated policy decisions to avoid repeated computation
- Profile policy evaluation time with `opa eval --explain=full` to identify bottlenecks
Expected answer points:
- Write test cases using the `with input as` syntax to inject mock data
- Test positive cases: resources that should pass (no violation returned)
- Test negative cases: resources that should fail (violation returned)
- Run `opa test ./policies/ -v` for verbose test output with pass/fail per test
- Cover edge cases: null values, empty lists, unexpected data structures
Expected answer points:
- AWS Policy (IAM) governs what a specific identity can do—user, role, or service
- SCPs (Service Control Policies) govern what is denied at the organization level across all accounts
- Use IAM policies for fine-grained resource-level permissions per identity
- Use SCPs for guardrails that apply to all accounts: deny certain regions, require encryption
- SCPs are evaluated before IAM policies—SCPs can never grant permissions, only restrict them
Further Reading
- OPA Documentation - Official Open Policy Agent docs
- Rego Language Reference - Complete Rego syntax guide
- Conftest - OPA for configuration files
- Sentinel Documentation - HashiCorp Sentinel reference
- AWS Organizations Policies - AWS native policy enforcement
- OPA Gatekeeper - Kubernetes policy enforcement with OPA
Conclusion
Policy as code shifts compliance from manual review to automated enforcement. OPA provides a vendor-neutral, general-purpose policy engine that integrates with Terraform, Kubernetes, and other infrastructure tools. Writing policies in Rego takes practice, but the resulting automated governance is worth the investment.
Start with a few high-impact policies—encryption requirements, tagging enforcement, publicly accessible resource restrictions. Expand coverage as your policy library grows and your team’s confidence with the tooling increases.
For more on DevOps practices, see our post on Cost Optimization which covers cloud cost governance patterns. For securing your infrastructure and IAM, see Cloud Security. For monitoring policy changes and compliance over time, see Observability Engineering.
Category
Related Posts
Compliance Automation: SOC 2, PCI-DSS, and Audit Trails
Automate compliance evidence collection and continuous compliance monitoring for SOC 2, PCI-DSS, and other frameworks using policy-as-code.
Choosing a Git Team Workflow: Decision Framework
Decision framework for selecting the right Git branching strategy based on team size, release cadence, and project type.
Git Flow: The Original Branching Strategy Explained
Master the Git Flow branching model with master, develop, feature, release, and hotfix branches. Learn when to use it, common pitfalls, and production best practices.