Enforce policy-as-code guardrails across AWS and Azure accounts
For developers and platform engineers who need cloud guardrails that actually block bad changes across multiple accounts and subscriptions. This walks you through a working baseline using Open Policy Agent with Conftest in CI and Terraform plan JSON, plus examples for AWS and Azure so you can fail unsafe changes before they deploy.
TL;DR — Use policy as code at the Terraform plan stage, not just after deployment. The fastest reliable baseline is: generate
terraform show -jsonoutput, evaluate it with Conftest/OPA, and fail CI on rules like “no public S3 buckets”, “no 0.0.0.0/0 SSH”, and “storage accounts must block public access”. Reading time: ~5 min
Goal
When you finish, every Terraform change for your cloud accounts/subscriptions is checked by versioned policy rules in CI, and the pipeline exits non-zero before apply if a change violates your guardrails.
Prerequisites
- Git repository with Terraform code for AWS and/or Azure
- CI system that can run shell commands (GitHub Actions, GitLab CI, Jenkins, etc.)
- Terraform >= 1.6 — check with:
terraform version
- Conftest >= 0.58.0 — check with:
conftest --version
- jq >= 1.6 — check with:
jq --version
- AWS credentials able to run
terraform planagainst target accounts, or Azure credentials able to runterraform planagainst target subscriptions - Existing remote state/backend already working; this article does not set up state storage
- A branch protection rule or equivalent so failed CI blocks merge
Steps
Step 1: Create a policy directory and baseline Rego rules
Run:
mkdir -p policy
cat > policy/terraform.rego <<'rego'
package main
import rego.v1
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_security_group_rule"
rc.change.after.type == "ingress"
rc.change.after.from_port <= 22
rc.change.after.to_port >= 22
rc.change.after.cidr_blocks[_] == "0.0.0.0/0"
msg := sprintf("AWS security group rule %s allows SSH from 0.0.0.0/0", [rc.address])
}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_public_access_block"
after := rc.change.after
not after.block_public_acls
msg := sprintf("S3 public access block disabled for %s: block_public_acls=false", [rc.address])
}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_public_access_block"
after := rc.change.after
not after.block_public_policy
msg := sprintf("S3 public access block disabled for %s: block_public_policy=false", [rc.address])
}
deny contains msg if {
some rc in input.resource_changes
rc.type == "azurerm_storage_account"
after := rc.change.after
after.public_network_access_enabled == true
msg := sprintf("Azure storage account %s has public_network_access_enabled=true", [rc.address])
}
deny contains msg if {
some rc in input.resource_changes
rc.type == "azurerm_network_security_rule"
after := rc.change.after
lower(after.access) == "allow"
lower(after.direction) == "inbound"
(after.destination_port_range == "22" or after.destination_port_ranges[_] == "22")
(after.source_address_prefix == "*" or after.source_address_prefix == "0.0.0.0/0" or after.source_address_prefixes[_] == "0.0.0.0/0")
msg := sprintf("Azure NSG rule %s allows SSH from the internet", [rc.address])
}
REGO
You should see no output and a new file at policy/terraform.rego.
Step 2: Generate a Terraform plan JSON file
Run from your Terraform root:
terraform init -input=false
terraform plan -out=tfplan -input=false -lock=false
terraform show -json tfplan > tfplan.json
jq '.resource_changes | length' tfplan.json
You should see a number such as 3; that confirms the JSON contains planned resource changes.
Step 3: Test the policy locally and inspect failures
Run:
conftest test tfplan.json --policy policy --output table
printf 'exit_code=%s\n' "$?"
You should see either 0 tests, 0 failures or a table of failures followed by exit_code=1.
Typical failure output shape:
FAIL - tfplan.json - main - AWS security group rule aws_security_group_rule.allow_ssh allows SSH from 0.0.0.0/0
FAIL - tfplan.json - main - Azure storage account azurerm_storage_account.app has public_network_access_enabled=true
2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptions
exit_code=1
Step 4: Add a CI job that blocks merges on policy violations
For GitHub Actions, create .github/workflows/policy-check.yml:
name: policy-check
on:
pull_request:
push:
branches: [ main ]
jobs:
conftest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.6.6
- name: Install Conftest
run: |
wget -qO- https://github.com/open-policy-agent/conftest/releases/download/v0.58.0/conftest_0.58.0_Linux_x86_64.tar.gz | tar xz
sudo mv conftest /usr/local/bin/conftest
- name: Terraform init
run: terraform init -input=false
- name: Terraform plan
run: terraform plan -out=tfplan -input=false -lock=false
- name: Terraform show JSON
run: terraform show -json tfplan > tfplan.json
- name: Policy check
run: conftest test tfplan.json --policy policy --output table
You should see a workflow named policy-check; a violating change ends the job with a failed Policy check step.
Step 5: Add an exception mechanism with explicit expiry
Do not hardcode permanent skips in Rego. Add an exceptions data file:
mkdir -p policy/data
cat > policy/data/exceptions.json <<'json'
{
"exceptions": [
{
"address": "aws_security_group_rule.breakglass_ssh",
"rule": "public_ssh",
"expires": "2026-12-31"
}
]
}
json
Replace policy/terraform.rego with:
package main
import rego.v1
is_exception(address, rule) if {
some e in data.exceptions
e.address == address
e.rule == rule
}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_security_group_rule"
rc.change.after.type == "ingress"
rc.change.after.from_port <= 22
rc.change.after.to_port >= 22
rc.change.after.cidr_blocks[_] == "0.0.0.0/0"
not is_exception(rc.address, "public_ssh")
msg := sprintf("AWS security group rule %s allows SSH from 0.0.0.0/0", [rc.address])
}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_public_access_block"
after := rc.change.after
not after.block_public_acls
msg := sprintf("S3 public access block disabled for %s: block_public_acls=false", [rc.address])
}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_public_access_block"
after := rc.change.after
not after.block_public_policy
msg := sprintf("S3 public access block disabled for %s: block_public_policy=false", [rc.address])
}
deny contains msg if {
some rc in input.resource_changes
rc.type == "azurerm_storage_account"
after := rc.change.after
after.public_network_access_enabled == true
msg := sprintf("Azure storage account %s has public_network_access_enabled=true", [rc.address])
}
Test with data loaded:
conftest test tfplan.json --policy policy --data policy/data --output table
You should see the excepted resource no longer fail, while other violations still fail.
Step 6: Require the policy check before apply
In your CI system, set the apply job to depend on the policy job. For GitHub Actions, use:
jobs:
conftest:
runs-on: ubuntu-latest
steps:
- run: echo policy
apply:
needs: conftest
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: echo apply
You should see apply skipped whenever conftest fails.
Verify it works
Run a known-bad plan locally. Example Terraform for AWS:
resource "aws_security_group_rule" "allow_ssh" {
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = "sg-1234567890abcdef0"
}
Then run:
terraform plan -out=tfplan -input=false -lock=false
terraform show -json tfplan > tfplan.json
conftest test tfplan.json --policy policy --data policy/data --output table
printf 'exit_code=%s\n' "$?"
Expected result:
FAIL - tfplan.json - main - AWS security group rule aws_security_group_rule.allow_ssh allows SSH from 0.0.0.0/0
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions
exit_code=1
In CI, the pull request status should be red for the policy job, and the apply job should not run.
View as text
[Developer PR] ---- 1. terraform plan ----> [tfplan.json] [tfplan.json] ---- 2. conftest test ----> [Policy Rules] [Policy Rules] ---- 3. pass/fail ----> [CI Status] [CI Status] ---- 4. pass only ----> [terraform apply]
Common pitfalls
Conftest passes because you tested raw .tf files instead of the plan JSON
Mistake: running conftest test . with rules written for Terraform plan structure.
Symptom: 0 tests, 0 failures even though the change is obviously unsafe.
Fix: run exactly:
terraform show -json tfplan > tfplan.json && conftest test tfplan.json --policy policy
Rules miss deletes or unknown values in plan output
Mistake: assuming every resource_changes[].change.after field is populated and final.
Symptom: Rego errors like undefined ref or a policy that silently misses resources with computed values.
Fix: scope rules to create/update cases and guard field access, for example after := rc.change.after; after != null.
Azure and AWS provider schemas differ from your rule assumptions
Mistake: writing one generic rule for all network resources.
Symptom: failures like rego_type_error or no match on resources that should be blocked.
Fix: write provider-specific rules keyed on rc.type, then test against real tfplan.json from each provider.
CI can plan locally but fails in pull requests from forks
Mistake: relying on cloud credentials that are not exposed to forked PRs.
Symptom: Terraform init/plan fails before policy runs, often with NoCredentialProviders or Azure login errors.
Fix: run policy on trusted branches only, or use a separate validation path for forks that does not require live provider access.
Permanent exceptions become your real policy
Mistake: adding broad allowlists and never removing them.
Symptom: the policy job is green, but risky resources keep getting through.
Fix: store exceptions in policy/data/exceptions.json with one resource address per entry and an expiry date reviewed in code review.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI