Policy as Code: How to Secure Secrets and Enforce Infrastructure Policies
How to turn security rules into tested, versioned policies that run on every Terraform plan and pull request, with working Rego examples for secrets, network exposure and AWS guardrails.
Most teams already have security rules. They live in a wiki page, a reviewer's head or a spreadsheet from the last audit: no databases open to the internet, no passwords in Terraform, encryption on by default, nothing deployed outside approved regions. The problem is not the rules. It is that they are checked by people, inconsistently, after the change has already been written.
Policy as code moves those rules into version-controlled files that a machine evaluates on every change. A pull request that opens SSH to 0.0.0.0/0 fails the same way a failing unit test does, with a clear message, before anyone has to spot it in review.
This guide covers how to do that for two areas where manual review fails most often: secrets and infrastructure configuration. If you want the shorter, phased migration view, our earlier post From Secrets Sprawl to Policy-as-Code in 30 Days lays out a 30-day roadmap. This article is the implementation detail behind it.
What policy as code actually means
A policy is a rule that returns allow or deny (or a warning) for a given input. "Policy as code" means:
- The rule is written in a machine-readable language, not prose.
- It lives in Git, is reviewed like any other change and has a history.
- It has tests, so you know a refactor did not silently weaken it.
- It runs automatically at a defined point: pre-commit, CI, admission to a cluster or the cloud control plane.
The input differs by enforcement point. For Terraform it is the JSON plan. For Kubernetes it is the admission request. For AWS Organizations it is the API call itself. The idea is the same in each case: evaluate the proposed change against written rules before it takes effect.
Where to enforce policies
Enforce as early as is useful and as late as is safe. Each layer catches something the others miss.
| Layer | Tooling | What it catches | Limitation |
|---|---|---|---|
| Developer machine | pre-commit hooks, gitleaks | Secrets and obvious mistakes before they are committed | Easy to skip; advisory only |
| Pull request / CI | Conftest (OPA), Checkov, Trivy config | Misconfigurations in the Terraform plan or manifests | Only sees what goes through the pipeline |
| Kubernetes admission | Kyverno, OPA Gatekeeper | Workloads that bypass CI (kubectl, Helm from a laptop) | Cluster scope only |
| Cloud control plane | AWS SCPs, resource control policies, AWS Config | Anything, including console changes | Coarse; mistakes here can break production access |
A practical rule: CI is where you put most of your rules because the feedback is fast and specific. The control plane is where you put the handful of rules that must never be broken, even by an administrator in the console.
Secrets: the policies that matter most
Secrets policies fail in a predictable order: a credential is committed to Git, ends up in Terraform state, or is passed to an application in a way that leaks into logs. Write a policy for each.
1. Block secrets before they reach Git
Secret scanning is the simplest policy you will run, and the one with the highest payoff. With gitleaks as a pre-commit hook and again in CI:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: vX.Y.Z # pin to the gitleaks release you have reviewed
hooks:
- id: gitleaks
Run the same scan in CI so a skipped hook does not matter. If a secret is ever detected in history, rotate it. Rewriting Git history does not un-leak a credential that has already been pushed.
2. Keep secrets out of Terraform state
Terraform stores resource attributes in state, including values marked sensitive. The sensitive flag only hides values from CLI output; it does not encrypt or omit them from state. Two patterns avoid the problem for databases on AWS:
# Option A: let RDS generate and store the password in Secrets Manager
resource "aws_db_instance" "app" {
identifier = "app-prod"
engine = "postgres"
instance_class = "db.t4g.medium"
allocated_storage = 50
username = "app_admin"
manage_master_user_password = true
storage_encrypted = true
}
# Option B (Terraform 1.11+): write-only argument, never stored in state
ephemeral "random_password" "db" {
length = 32
}
resource "aws_db_instance" "reporting" {
# ...
password_wo = ephemeral.random_password.db.result
password_wo_version = 1 # bump to rotate
}
Write-only arguments (the _wo suffix) arrived in Terraform 1.11 alongside ephemeral values from 1.10. Provider support varies by resource, so check the provider docs before relying on it.
3. Turn the rule into a policy
Now enforce it. Generate the plan as JSON and evaluate it with Conftest, which runs Open Policy Agent (OPA) policies written in Rego:
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json --policy policy/
A policy that rejects plaintext database passwords:
# policy/secrets.rego (Rego v1 syntax, the default since OPA 1.0)
package main
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_db_instance"
some action in rc.change.actions
action in {"create", "update"}
object.get(rc.change.after, "password", null) != null
msg := sprintf("%s sets a plaintext password. Use manage_master_user_password or password_wo.", [rc.address])
}
One important caveat: terraform show -json includes sensitive values in plain text. Treat tfplan.json as a secret: do not upload it as a public CI artifact and delete it at the end of the job.
Infrastructure policies worth writing first
Resist the urge to import hundreds of rules on day one. Start with rules that map to real incidents or audit findings. These five cover a large share of the risk in a typical AWS account:
- No administrative ports open to the internet. SSH (22) and RDP (3389) from
0.0.0.0/0or::/0. - No publicly accessible databases.
publicly_accessible = trueon RDS. - Encryption at rest is on. EBS volumes, RDS storage, EFS.
- Required tags exist.
owner,environmentanddata-classification, so findings can be routed to someone. - Approved regions only. A cheap way to stop shadow infrastructure.
Here is the network rule:
package main
admin_ports := {22, 3389}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_vpc_security_group_ingress_rule"
rc.change.after.cidr_ipv4 == "0.0.0.0/0"
some port in admin_ports
rc.change.after.from_port <= port
rc.change.after.to_port >= port
msg := sprintf("%s exposes port %d to the internet", [rc.address, port])
}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_vpc_security_group_ingress_rule"
rc.change.after.cidr_ipv4 == "0.0.0.0/0"
rc.change.after.ip_protocol == "-1" # all traffic: ports are null
msg := sprintf("%s allows all traffic from the internet", [rc.address])
}
If your code still uses inline ingress blocks or aws_security_group_rule, write the equivalent rule for those resource types too. A policy that only checks the resource type you use today is easy to bypass by accident.
Test your policies
Policies are code, so they get unit tests. Conftest and OPA both support _test.rego files:
# policy/secrets_test.rego
package main
test_plaintext_password_denied if {
count(deny) > 0 with input as {"resource_changes": [{
"address": "aws_db_instance.x",
"type": "aws_db_instance",
"change": {"actions": ["create"], "after": {"password": "hunter2"}}
}]}
}
test_managed_password_allowed if {
count(deny) == 0 with input as {"resource_changes": [{
"address": "aws_db_instance.x",
"type": "aws_db_instance",
"change": {"actions": ["create"], "after": {"manage_master_user_password": true}}
}]}
}
Run them with conftest verify --policy policy/ in the same pipeline that runs the policies.
Guardrails at the AWS Organizations level
Some rules should hold even if someone bypasses CI entirely. Service control policies (SCPs) apply to every principal in member accounts, including administrators. Keep them few and blunt:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ProtectAuditTrail",
"Effect": "Deny",
"Action": ["cloudtrail:StopLogging", "cloudtrail:DeleteTrail", "cloudtrail:UpdateTrail"],
"Resource": "*"
},
{
"Sid": "StayInTheOrganization",
"Effect": "Deny",
"Action": "organizations:LeaveOrganization",
"Resource": "*"
}
]
}
Test SCPs on a non-production organizational unit first. An SCP mistake does not fail a pipeline. It can lock teams out of production.
Rolling out enforcement without blocking delivery
The fastest way to kill a policy as code programme is to switch on hard failures across every repository at once. A rollout that works:
- Report only. Run policies with
warnrules, or in a CI step that cannot fail the build, for two to four weeks. Collect what fires. - Fix or except. For each finding, either fix the code or record a documented exception with an owner and an expiry date.
- Enforce new violations. Fail the build only for changes that introduce a violation, not for pre-existing ones.
- Enforce everything. Once the backlog is cleared, make the rule a hard
deny.
Exceptions should be data, not comments buried in code. A small exceptions.json keyed by resource address, read by the policy and reviewed by security, keeps them visible and stops them becoming permanent.
Common mistakes
- Writing policies nobody can read. If an engineer cannot tell from the failure message what to change, they will look for a way around the check. Every
denymessage should name the resource and the fix. - Checking HCL text instead of the plan. Static checks on
.tffiles are useful, but modules, variables andcounthide the real values. The plan shows what will actually be created. - No owner for the policy repository. Policies drift out of date like any other code. Someone has to own them.
- Policies that duplicate a managed control. If AWS already blocks public S3 access at the account level, you do not need five policies for it. Spend the effort elsewhere.
- Leaking the evidence. Plan files, policy reports and CI logs can contain sensitive values. Apply the same access controls to them as to the secrets themselves.
Where policy as code fits
Policy as code is one part of a secure delivery pipeline, alongside secret scanning, dependency and image scanning, and least-privilege CI credentials. If you are designing that wider pipeline, see our guide to building a DevSecOps pipeline, and for the Terraform-specific side, Terraform security best practices.
Getting help
Our Cloud Security & DevSecOps work often starts exactly here: inventorying the rules a team already has, encoding the important ones, and rolling enforcement out in stages so delivery does not stall. If you are not sure which policies matter most for your environment, a Free Engineering Assessment is a low-commitment way to get a prioritised list.