Command Palette

Search for a command to run...

Hectal

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.

45 min Free 5 steps 2 break-it drills

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.

Gates between a change and proddiagram
Rendering diagram…

Part 2

Your project after this mission · 3 files change

shoplite/
  • .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. 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/null
    terraform 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. 2

    Write the policies

    Two rules. First: deleting or replacing a resource of a protected type is denied. prevent_destroy covers 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 to 0.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. 3

    Run conftest locally

    The everyday deploy plan passes: an ECS task definition isn't a protected type.

    terminal
    $ terraform show -json tfplan > tfplan.json
    conftest test tfplan.json --policy ../policy --namespace terraform.plan
    ── expected output ──
    2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions
  4. 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. 5

    Take prod applies off laptops

    Update the prod shoplite-deployer role's trust policy so it trusts ONLY repo: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.rego denies 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.

terminal
$ conftest test tfplan.json --policy ../policy --namespace terraform.plan
── what you'll see ──
FAIL - tfplan.json - terraform.plan - aws_vpc_security_group_ingress_rule.ssh opens SSH (22) to the internet
 
2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions

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.

terminal
$ conftest test tfplan.json --policy ../policy --namespace terraform.plan
── what you'll see ──
FAIL - tfplan.json - terraform.plan - aws_s3_bucket.uploads (aws_s3_bucket) would be deleted or replaced — needs an explicit, reviewed exception
 
2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions

Part 5

Interview questions from this mission

01

What is policy as code for Terraform, and why evaluate plans rather than source?

02

How do you stop engineers from applying Terraform to prod from their laptops?

0/4 · 0%