The problem: production changes nobody had to justify
- Infrastructure ran through Terraform Cloud across 200+ workspaces.
- Nothing technical tied a production apply to change control: an engineer could apply to production without a change record.
- Nothing technical tied it to a security scan: a misconfigured resource could land before anyone looked at it.
- In a regulated bank, that's an audit finding waiting to happen, and an incident waiting for a misconfiguration.
The approach: put the controls inside the pipeline, not beside it
Policies that live in a wiki depend on people remembering them. Controls inside the run can't be skipped:
terraform plan
→ HashiCorp Sentinel policies (is this configuration allowed?)
→ Wiz scan (is it secure?)
→ ServiceNow change record (is it traceable?)
→ apply
→ post-apply record (what actually happened?)
What I built
1. Change control as a mandatory run task
Every production workspace runs a ServiceNow run task that auto-creates a standard change record before the apply, from a set of domain templates (account, IAM, network, security, application).
The run tasks are mandatory at both pre-apply and post-apply: a workspace can't skip them, and the record captures what was intended and what actually happened.
2. Security scanning as a pre-apply gate
A Wiz run task is attached to every workspace. If a planned resource fails Wiz policy, the apply is blocked.
Illustrative: attaching a mandatory run task with the tfe provider.
resource "tfe_organization_run_task" "wiz" {
organization = var.organization
name = "wiz-scan"
url = var.wiz_run_task_url
hmac_key = var.wiz_hmac_key
enabled = true
}
resource "tfe_workspace_run_task" "wiz" {
workspace_id = tfe_workspace.app.id
task_id = tfe_organization_run_task.wiz.id
enforcement_level = "mandatory" # a failed scan fails the run
stages = ["pre_apply"]
}
3. A HashiCorp Sentinel policy library
Applied organization-wide, it evaluates every plan before any API call reaches the cloud provider. What it enforces:
- S3 buckets must be private and KMS-encrypted
- No
0.0.0.0/0ingress on security groups - No wildcard IAM actions
- Only approved AMI owners and approved availability zones
- Lambda functions must run in a VPC with KMS encryption
- Each workspace may assume only its own designated IAM role, which blocks privilege escalation from one workspace into another's permissions
Illustrative: a Sentinel policy that blocks public S3 ACLs.
import "tfplan/v2" as tfplan
forbidden = ["public-read", "public-read-write", "authenticated-read"]
acls = filter tfplan.resource_changes as _, rc {
rc.type is "aws_s3_bucket_acl" and
(rc.change.actions contains "create" or rc.change.actions contains "update")
}
main = rule {
all acls as _, a {
a.change.after.acl not in forbidden
}
}
And its enforcement level, in sentinel.hcl:
policy "restrict-s3-public-acl" {
source = "./restrict-s3-public-acl.sentinel"
enforcement_level = "hard-mandatory"
}
4. Separation of duties
20+ teams on tiered permission levels (plan-only, apply, output-read), so the people who propose a change aren't automatically the people who apply it.
5. The gates themselves as code
Workspaces, teams, run tasks and policy sets are all defined in Terraform. Nobody can quietly detach a gate through the UI; removing one is a reviewed code change.
Things to watch
- A control can break production too. A Sentinel policy once failed to match correctly and blocked applies across the entire organization. I found the root cause and fixed it. Treat a policy library like code: test it against real plans and roll out changes in advisory mode first.
- Fail open or fail closed? A mandatory run task fails the run if the service doesn't answer. Decide in advance who can override that, and how.
- Emergencies still need a fast path, one that leaves the same audit trail as a normal change.
- Standard changes need honest templates. Auto-created standard changes only make sense for genuinely low-risk, repeatable work. Anything else should go through normal change review.
- Enforcement levels matter.
advisorywarns,soft-mandatorycan be overridden by an authorized user,hard-mandatorycan't be. Choose per policy.
What it solves
- Production changes went from ungated to 100% gated: every production apply now needs a change record and a clean security scan.
- Misconfigurations are blocked before deployment, not found afterwards.
- A full audit trail for every infrastructure change, created automatically.
- Closed-loop fixes: when Wiz flagged an overly permissive role, the fix shipped in a patch release of the shared IAM module in the same sprint.
What it doesn't solve
- Changes that never pass through Terraform. Console changes, drift and newly disclosed vulnerabilities in running resources are invisible to a pre-deploy gate. Post-deployment posture scanning and drift detection are still needed.
- "Zero findings." A gate stops new misconfigurations; it doesn't make an estate finding-free. Don't claim it does.
- Bad policy logic. A policy that matches the wrong thing either blocks everything (see "Things to watch") or silently allows what it should block. Test both directions.
Checklist
- [ ] Every production workspace has mandatory change-control and security run tasks
- [ ] Run tasks and policy sets defined as code, not toggled in the UI
- [ ] Sentinel policies tested against real plans, rolled out advisory first
- [ ] Enforcement level chosen deliberately for each policy
- [ ] Workspaces restricted to their own IAM role
- [ ] Plan, apply and output-read permissions split across teams
- [ ] A documented emergency path that still leaves an audit trail
- [ ] Post-deployment scanning for everything Terraform can't see
Further reading
- HCP Terraform run tasks: https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/run-tasks
- Sentinel documentation: https://developer.hashicorp.com/sentinel/docs
- Terraform
tfeprovider:tfe_organization_run_task,tfe_workspace_run_task(Terraform Registry) - ITIL standard changes: your ServiceNow change-management documentation