Mission 7.3 · Stage 7 — Team Workflow
Policy as Code: Gate Plans, Not Just Code
Goal: Every plan is checked by OPA policies that fail the pipeline if it would delete a protected resource or open SSH to the internet, prod files need a code owner's approval, and prod can't be applied from a laptop at all.
By the end of this mission
- Evaluate the JSON form of a plan with OPA/Conftest
- Write policies for destructive changes and insecure rules
- Require code-owner review for prod configuration
- Restrict prod's write role to CI only
Part 1
Understand it first
Why check the plan, not the code
Static scanners read .tf files, but many dangerous things only show up in the PLAN: a harmless-looking rename that forces a database replacement, a module upgrade that recreates a VPC, a variable value that opens a port. terraform show -json tfplan gives the complete list of intended changes, with each resource's actions (create, update, delete, or ["delete","create"] for replacement) and its before/after values. Policies over that JSON judge what will actually happen.
OPA and Conftest
Open Policy Agent evaluates rules written in Rego against any JSON. Conftest is a small CLI that runs OPA policies against files and exits non-zero on violations, which makes it perfect for CI. A deny rule produces a message for every violation. HCP Terraform (Sentinel or OPA) and other platforms offer the same idea as a managed feature.
Defence in depth for prod
No single control is enough. Policies catch dangerous plans automatically. CODEOWNERS make sure the right people review changes to envs/prod.tfvars and modules. GitHub environment protection requires approval before prod applies. And the prod deployer role trusting ONLY the CI prod environment means that even someone with prod console access can't terraform apply prod from their laptop, so every change goes through all the other gates.
Part 2
Your project after this mission · 3 files change
- .github/
- workflows/
- terraform.ymlmodified
- CODEOWNERSnew
- bootstrap/
- main.tf
- infra/
- envs/
- dev.s3.tfbackend
- dev.tfvars
- prod.s3.tfbackend
- prod.tfvars
- database.tf
- ecs.tf
- main.tf
- providers.tf
- security.tf
- tf
- modules/
- ecs-service/
- main.tf
- variables.tf
- network/
- main.tf
- policy/
- terraform.regonew
Part 3
Build it, step by step
- 1
Look at a plan as JSON
Everything a policy needs is in
resource_changes. Here's the summary for a plan that replaces the task definition, the everyday deploy.terminal$ cd infra && ./tf dev plan -out=tfplan >/dev/nullterraform show -json tfplan | jq -c '.resource_changes[] | select(.change.actions != ["no-op"]) | {address, type, actions: .change.actions}'── expected output ──{"address":"module.service[\"api\"].aws_ecs_service.this","type":"aws_ecs_service","actions":["update"]}{"address":"module.service[\"api\"].aws_ecs_task_definition.this","type":"aws_ecs_task_definition","actions":["delete","create"]} - 2
Write the policies
Two rules. First: deleting or replacing a resource of a protected type is denied.
prevent_destroycovers some resources in code; this covers every resource of those types, including ones inside modules and ones someone forgot to protect. Second: no ingress rule may expose port 22 to0.0.0.0/0, checked against the AFTER values, so it catches variables and module inputs too. Rego v1 syntax (import rego.v1).policy/terraform.regowhole filerego package terraform.plan import rego.v1 protected_types := { "aws_db_instance", "aws_s3_bucket", "aws_vpc", "aws_ecr_repository", } deny contains msg if { some rc in input.resource_changes rc.type in protected_types "delete" in rc.change.actions msg := sprintf("%s (%s) would be deleted or replaced — needs an explicit, reviewed exception", [rc.address, rc.type]) } deny contains msg if { some rc in input.resource_changes rc.type == "aws_vpc_security_group_ingress_rule" after := rc.change.after after.cidr_ipv4 == "0.0.0.0/0" after.from_port <= 22 after.to_port >= 22 msg := sprintf("%s opens SSH (22) to the internet", [rc.address]) } - 3
Run conftest locally
The everyday deploy plan passes: an ECS task definition isn't a protected type.
terminal$ terraform show -json tfplan > tfplan.jsonconftest test tfplan.json --policy ../policy --namespace terraform.plan── expected output ──2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions - 4
Wire it into the PR job and add CODEOWNERS
After
plan, convert to JSON and run conftest. A failure blocks the merge (make the check required in branch protection). CODEOWNERS requires the platform team's review on anything prod-related or shared..github/workflows/terraform.yml + .github/CODEOWNERSadd to fileyaml # in the plan job, after "plan": - uses: instrumenta/conftest-action@master with: files: infra/tfplan.json policy: policy namespace: terraform.plan # (plan step also runs: terraform show -json tfplan > tfplan.json) # .github/CODEOWNERS /infra/envs/prod.* @you/platform-team /modules/ @you/platform-team /policy/ @you/platform-team @you/security - 5
Take prod applies off laptops
Update the prod
shoplite-deployerrole's trust policy so it trusts ONLYrepo:you/shoplite:environment:prod, removing your SSO identity. Humans keep a read-only role for investigating, plus a break-glass role for emergencies that alerts when used. Dev can keep trusting humans for experimentation.terminal$ ./tf prod plan── expected output ──╷│ Error: Cannot assume IAM Role││ with provider["registry.terraform.io/hashicorp/aws"],│ on providers.tf line 10, in provider "aws":│ 10: provider "aws" {││ IAM Role (arn:aws:iam::444455556666:role/shoplite-deployer) cannot be│ assumed. ... api error AccessDenied: User: arn:aws:sts::111122223333:│ assumed-role/AWSReservedSSO_Developer/priya is not authorized to perform:│ sts:AssumeRole on resource: arn:aws:iam::444455556666:role/shoplite-deployer╵This error is the goal: prod changes now only happen through the pipeline.
Checkpoint — you should now have
- ✓
policy/terraform.regodenies deleting/replacing protected types and SSH open to the world. - ✓Conftest runs on every PR plan, and its check is required for merging.
- ✓CODEOWNERS covers prod tfvars, modules, and policies.
- ✓Your own identity can no longer assume the prod deployer role.
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
Open SSH to the world in a PR
Add aws_vpc_security_group_ingress_rule "ssh" to the app group with cidr_ipv4 = "0.0.0.0/0", from_port = 22, to_port = 22, plan, and run conftest.
Break #2
A refactor that replaces a bucket
Rename the uploads bucket resource (aws_s3_bucket.uploads → aws_s3_bucket.user_uploads) without a moved block and run the policy.
Part 5
Interview questions from this mission
What is policy as code for Terraform, and why evaluate plans rather than source?
How do you stop engineers from applying Terraform to prod from their laptops?