1--- 2title: Terraform 3kind: tutorial 4weight: 1 5--- 6 7Terraform lets you describe the infrastructure you want and automatically creates, deletes, and modifies 8your existing infrastructure to match. OPA makes it possible to write policies that test the changes 9Terraform is about to make before it makes them. Such tests help in different ways: 10 11* tests help individual developers sanity check their Terraform changes 12* tests can auto-approve run-of-the-mill infrastructure changes and reduce the burden of peer-review 13* tests can help catch problems that arise when applying Terraform to production after applying it to staging 14 15 16## Goals 17 18In this tutorial, you'll learn how to use OPA to implement unit tests for Terraform plans that create 19and delete auto-scaling groups and servers. 20 21## Prerequisites 22 23This tutorial requires 24 25* [Terraform 0.12.6](https://releases.hashicorp.com/terraform/0.12.6/) 26* [OPA](https://github.com/open-policy-agent/opa/releases) 27 28(This tutorial *should* also work with the [latest version of Terraform](https://www.terraform.io/downloads.html), but 29it is untested. Contributions welcome!) 30 31## Steps 32 33### 1. Create and save a Terraform plan 34 35Create a [Terraform](https://www.terraform.io/docs/index.html) file that includes an 36auto-scaling group and a server on AWS. (You will need to modify the `shared_credentials_file` 37to point to your AWS credentials.) 38 39```shell 40cat >main.tf <<EOF 41provider "aws" { 42 region = "us-west-1" 43} 44resource "aws_instance" "web" { 45 instance_type = "t2.micro" 46 ami = "ami-09b4b74c" 47} 48resource "aws_autoscaling_group" "my_asg" { 49 availability_zones = ["us-west-1a"] 50 name = "my_asg" 51 max_size = 5 52 min_size = 1 53 health_check_grace_period = 300 54 health_check_type = "ELB" 55 desired_capacity = 4 56 force_delete = true 57 launch_configuration = "my_web_config" 58} 59resource "aws_launch_configuration" "my_web_config" { 60 name = "my_web_config" 61 image_id = "ami-09b4b74c" 62 instance_type = "t2.micro" 63} 64EOF 65``` 66 67Then initialize Terraform and ask it to calculate what changes it will make and store the output in `plan.binary`. 68 69```shell 70terraform init 71terraform plan --out tfplan.binary 72``` 73 74### 2. Convert the Terraform plan into JSON 75 76Use the command [terraform show](https://www.terraform.io/docs/commands/show.html) to convert the Terraform plan into 77JSON so that OPA can read the plan. 78 79```shell 80terraform show -json tfplan.binary > tfplan.json 81``` 82 83Here is the expected contents of `tfplan.json`. 84 85```live:terraform:input 86{ 87 "format_version": "0.1", 88 "terraform_version": "0.12.6", 89 "planned_values": { 90 "root_module": { 91 "resources": [ 92 { 93 "address": "aws_autoscaling_group.my_asg", 94 "mode": "managed", 95 "type": "aws_autoscaling_group", 96 "name": "my_asg", 97 "provider_name": "aws", 98 "schema_version": 0, 99 "values": { 100 "availability_zones": [ 101 "us-west-1a" 102 ], 103 "desired_capacity": 4, 104 "enabled_metrics": null, 105 "force_delete": true, 106 "health_check_grace_period": 300, 107 "health_check_type": "ELB", 108 "initial_lifecycle_hook": [], 109 "launch_configuration": "my_web_config", 110 "launch_template": [], 111 "max_size": 5, 112 "metrics_granularity": "1Minute", 113 "min_elb_capacity": null, 114 "min_size": 1, 115 "mixed_instances_policy": [], 116 "name": "my_asg", 117 "name_prefix": null, 118 "placement_group": null, 119 "protect_from_scale_in": false, 120 "suspended_processes": null, 121 "tag": [], 122 "tags": null, 123 "termination_policies": null, 124 "timeouts": null, 125 "wait_for_capacity_timeout": "10m", 126 "wait_for_elb_capacity": null 127 } 128 }, 129 { 130 "address": "aws_instance.web", 131 "mode": "managed", 132 "type": "aws_instance", 133 "name": "web", 134 "provider_name": "aws", 135 "schema_version": 1, 136 "values": { 137 "ami": "ami-09b4b74c", 138 "credit_specification": [], 139 "disable_api_termination": null, 140 "ebs_optimized": null, 141 "get_password_data": false, 142 "iam_instance_profile": null, 143 "instance_initiated_shutdown_behavior": null, 144 "instance_type": "t2.micro", 145 "monitoring": null, 146 "source_dest_check": true, 147 "tags": null, 148 "timeouts": null, 149 "user_data": null, 150 "user_data_base64": null 151 } 152 }, 153 { 154 "address": "aws_launch_configuration.my_web_config", 155 "mode": "managed", 156 "type": "aws_launch_configuration", 157 "name": "my_web_config", 158 "provider_name": "aws", 159 "schema_version": 0, 160 "values": { 161 "associate_public_ip_address": false, 162 "enable_monitoring": true, 163 "ephemeral_block_device": [], 164 "iam_instance_profile": null, 165 "image_id": "ami-09b4b74c", 166 "instance_type": "t2.micro", 167 "name": "my_web_config", 168 "name_prefix": null, 169 "placement_tenancy": null, 170 "security_groups": null, 171 "spot_price": null, 172 "user_data": null, 173 "user_data_base64": null, 174 "vpc_classic_link_id": null, 175 "vpc_classic_link_security_groups": null 176 } 177 } 178 ] 179 } 180 }, 181 "resource_changes": [ 182 { 183 "address": "aws_autoscaling_group.my_asg", 184 "mode": "managed", 185 "type": "aws_autoscaling_group", 186 "name": "my_asg", 187 "provider_name": "aws", 188 "change": { 189 "actions": [ 190 "create" 191 ], 192 "before": null, 193 "after": { 194 "availability_zones": [ 195 "us-west-1a" 196 ], 197 "desired_capacity": 4, 198 "enabled_metrics": null, 199 "force_delete": true, 200 "health_check_grace_period": 300, 201 "health_check_type": "ELB", 202 "initial_lifecycle_hook": [], 203 "launch_configuration": "my_web_config", 204 "launch_template": [], 205 "max_size": 5, 206 "metrics_granularity": "1Minute", 207 "min_elb_capacity": null, 208 "min_size": 1, 209 "mixed_instances_policy": [], 210 "name": "my_asg", 211 "name_prefix": null, 212 "placement_group": null, 213 "protect_from_scale_in": false, 214 "suspended_processes": null, 215 "tag": [], 216 "tags": null, 217 "termination_policies": null, 218 "timeouts": null, 219 "wait_for_capacity_timeout": "10m", 220 "wait_for_elb_capacity": null 221 }, 222 "after_unknown": { 223 "arn": true, 224 "availability_zones": [ 225 false 226 ], 227 "default_cooldown": true, 228 "id": true, 229 "initial_lifecycle_hook": [], 230 "launch_template": [], 231 "load_balancers": true, 232 "mixed_instances_policy": [], 233 "service_linked_role_arn": true, 234 "tag": [], 235 "target_group_arns": true, 236 "vpc_zone_identifier": true 237 } 238 } 239 }, 240 { 241 "address": "aws_instance.web", 242 "mode": "managed", 243 "type": "aws_instance", 244 "name": "web", 245 "provider_name": "aws", 246 "change": { 247 "actions": [ 248 "create" 249 ], 250 "before": null, 251 "after": { 252 "ami": "ami-09b4b74c", 253 "credit_specification": [], 254 "disable_api_termination": null, 255 "ebs_optimized": null, 256 "get_password_data": false, 257 "iam_instance_profile": null, 258 "instance_initiated_shutdown_behavior": null, 259 "instance_type": "t2.micro", 260 "monitoring": null, 261 "source_dest_check": true, 262 "tags": null, 263 "timeouts": null, 264 "user_data": null, 265 "user_data_base64": null 266 }, 267 "after_unknown": { 268 "arn": true, 269 "associate_public_ip_address": true, 270 "availability_zone": true, 271 "cpu_core_count": true, 272 "cpu_threads_per_core": true, 273 "credit_specification": [], 274 "ebs_block_device": true, 275 "ephemeral_block_device": true, 276 "host_id": true, 277 "id": true, 278 "instance_state": true, 279 "ipv6_address_count": true, 280 "ipv6_addresses": true, 281 "key_name": true, 282 "network_interface": true, 283 "network_interface_id": true, 284 "password_data": true, 285 "placement_group": true, 286 "primary_network_interface_id": true, 287 "private_dns": true, 288 "private_ip": true, 289 "public_dns": true, 290 "public_ip": true, 291 "root_block_device": true, 292 "security_groups": true, 293 "subnet_id": true, 294 "tenancy": true, 295 "volume_tags": true, 296 "vpc_security_group_ids": true 297 } 298 } 299 }, 300 { 301 "address": "aws_launch_configuration.my_web_config", 302 "mode": "managed", 303 "type": "aws_launch_configuration", 304 "name": "my_web_config", 305 "provider_name": "aws", 306 "change": { 307 "actions": [ 308 "create" 309 ], 310 "before": null, 311 "after": { 312 "associate_public_ip_address": false, 313 "enable_monitoring": true, 314 "ephemeral_block_device": [], 315 "iam_instance_profile": null, 316 "image_id": "ami-09b4b74c", 317 "instance_type": "t2.micro", 318 "name": "my_web_config", 319 "name_prefix": null, 320 "placement_tenancy": null, 321 "security_groups": null, 322 "spot_price": null, 323 "user_data": null, 324 "user_data_base64": null, 325 "vpc_classic_link_id": null, 326 "vpc_classic_link_security_groups": null 327 }, 328 "after_unknown": { 329 "ebs_block_device": true, 330 "ebs_optimized": true, 331 "ephemeral_block_device": [], 332 "id": true, 333 "key_name": true, 334 "root_block_device": true 335 } 336 } 337 } 338 ], 339 "configuration": { 340 "provider_config": { 341 "aws": { 342 "name": "aws", 343 "expressions": { 344 "region": { 345 "constant_value": "us-west-1" 346 } 347 } 348 } 349 }, 350 "root_module": { 351 "resources": [ 352 { 353 "address": "aws_autoscaling_group.my_asg", 354 "mode": "managed", 355 "type": "aws_autoscaling_group", 356 "name": "my_asg", 357 "provider_config_key": "aws", 358 "expressions": { 359 "availability_zones": { 360 "constant_value": [ 361 "us-west-1a" 362 ] 363 }, 364 "desired_capacity": { 365 "constant_value": 4 366 }, 367 "force_delete": { 368 "constant_value": true 369 }, 370 "health_check_grace_period": { 371 "constant_value": 300 372 }, 373 "health_check_type": { 374 "constant_value": "ELB" 375 }, 376 "launch_configuration": { 377 "constant_value": "my_web_config" 378 }, 379 "max_size": { 380 "constant_value": 5 381 }, 382 "min_size": { 383 "constant_value": 1 384 }, 385 "name": { 386 "constant_value": "my_asg" 387 } 388 }, 389 "schema_version": 0 390 }, 391 { 392 "address": "aws_instance.web", 393 "mode": "managed", 394 "type": "aws_instance", 395 "name": "web", 396 "provider_config_key": "aws", 397 "expressions": { 398 "ami": { 399 "constant_value": "ami-09b4b74c" 400 }, 401 "instance_type": { 402 "constant_value": "t2.micro" 403 } 404 }, 405 "schema_version": 1 406 }, 407 { 408 "address": "aws_launch_configuration.my_web_config", 409 "mode": "managed", 410 "type": "aws_launch_configuration", 411 "name": "my_web_config", 412 "provider_config_key": "aws", 413 "expressions": { 414 "image_id": { 415 "constant_value": "ami-09b4b74c" 416 }, 417 "instance_type": { 418 "constant_value": "t2.micro" 419 }, 420 "name": { 421 "constant_value": "my_web_config" 422 } 423 }, 424 "schema_version": 0 425 } 426 ] 427 } 428 } 429} 430``` 431 432The json plan output produced by terraform contains a lot of information. For this tutorial, we will be interested by: 433 434* `.resource_changes`: array containing all the actions that terraform will apply on the infrastructure. 435* `.resource_changes[].type`: the type of resource (eg `aws_instance` , `aws_iam` ...) 436* `.resource_changes[].change.actions`: array of actions applied on the resource (`create`, `update`, `delete`...) 437 438For more information about the json plan representation, please check the [terraform documentation](https://www.terraform.io/docs/internals/json-format.html#plan-representation) 439 440### 3. Write the OPA policy to check the plan 441 442The policy computes a score for a Terraform that combines 443 444* The number of deletions of each resource type 445* The number of creations of each resource type 446* The number of modifications of each resource type 447 448The policy authorizes the plan when the score for the plan is below a threshold 449and there are no changes made to any IAM resources. 450(For simplicity, the threshold in this tutorial is the same for everyone, but in 451practice you would vary the threshold depending on the user.) 452 453**terraform.rego**: 454 455```live:terraform:module:openable 456package terraform.analysis 457 458import input as tfplan 459 460######################## 461# Parameters for Policy 462######################## 463 464# acceptable score for automated authorization 465blast_radius = 30 466 467# weights assigned for each operation on each resource-type 468weights = { 469 "aws_autoscaling_group": {"delete": 100, "create": 10, "modify": 1}, 470 "aws_instance": {"delete": 10, "create": 1, "modify": 1} 471} 472 473# Consider exactly these resource types in calculations 474resource_types = {"aws_autoscaling_group", "aws_instance", "aws_iam", "aws_launch_configuration"} 475 476######### 477# Policy 478######### 479 480# Authorization holds if score for the plan is acceptable and no changes are made to IAM 481default authz = false 482authz { 483 score < blast_radius 484 not touches_iam 485} 486 487# Compute the score for a Terraform plan as the weighted sum of deletions, creations, modifications 488score = s { 489 all := [ x | 490 some resource_type 491 crud := weights[resource_type]; 492 del := crud["delete"] * num_deletes[resource_type]; 493 new := crud["create"] * num_creates[resource_type]; 494 mod := crud["modify"] * num_modifies[resource_type]; 495 x := del + new + mod 496 ] 497 s := sum(all) 498} 499 500# Whether there is any change to IAM 501touches_iam { 502 all := resources["aws_iam"] 503 count(all) > 0 504} 505 506#################### 507# Terraform Library 508#################### 509 510# list of all resources of a given type 511resources[resource_type] = all { 512 some resource_type 513 resource_types[resource_type] 514 all := [name | 515 name:= tfplan.resource_changes[_] 516 name.type == resource_type 517 ] 518} 519 520# number of creations of resources of a given type 521num_creates[resource_type] = num { 522 some resource_type 523 resource_types[resource_type] 524 all := resources[resource_type] 525 creates := [res | res:= all[_]; res.change.actions[_] == "create"] 526 num := count(creates) 527} 528 529 530# number of deletions of resources of a given type 531num_deletes[resource_type] = num { 532 some resource_type 533 resource_types[resource_type] 534 all := resources[resource_type] 535 deletions := [res | res:= all[_]; res.change.actions[_] == "delete"] 536 num := count(deletions) 537} 538 539# number of modifications to resources of a given type 540num_modifies[resource_type] = num { 541 some resource_type 542 resource_types[resource_type] 543 all := resources[resource_type] 544 modifies := [res | res:= all[_]; res.change.actions[_] == "update"] 545 num := count(modifies) 546} 547``` 548 549### 4. Evaluate the OPA policy on the Terraform plan 550 551To evaluate the policy against that plan, you hand OPA the policy, the Terraform plan as input, and 552ask it to evaluate `data.terraform.analysis.authz`. 553 554```shell 555opa eval --format pretty --data terraform.rego --input tfplan.json "data.terraform.analysis.authz" 556``` 557```live:terraform/authz:query:hidden 558data.terraform.analysis.authz 559``` 560```live:terraform/authz:output 561``` 562 563 564If you're curious, you can ask for the score that the policy used to make the authorization decision. 565In our example, it is 11 (10 for the creation of the auto-scaling group and 1 for the creation of the server). 566 567```shell 568opa eval --format pretty --data terraform.rego --input tfplan.json "data.terraform.analysis.score" 569``` 570 571```live:terraform/score:query:hidden 572data.terraform.analysis.score 573``` 574```live:terraform/score:output 575``` 576 577If as suggested in the previous step, you want to modify your policy to make an authorization decision 578based on both the user and the Terraform plan, the input you would give to OPA would take the form 579`{"user": <user>, "plan": <plan>}`, and your policy would reference the user with `input.user` and 580the plan with `input.plan`. You could even go so far as to provide the Terraform state file and the AWS 581EC2 data to OPA and write policy using all of that context. 582 583### 5. Create a Large Terraform plan and Evaluate it 584 585Create a Terraform plan that creates enough resources to exceed the blast-radius permitted 586by policy. 587 588```shell 589cat >main.tf <<EOF 590provider "aws" { 591 region = "us-west-1" 592} 593resource "aws_instance" "web" { 594 instance_type = "t2.micro" 595 ami = "ami-09b4b74c" 596} 597resource "aws_autoscaling_group" "my_asg" { 598 availability_zones = ["us-west-1a"] 599 name = "my_asg" 600 max_size = 5 601 min_size = 1 602 health_check_grace_period = 300 603 health_check_type = "ELB" 604 desired_capacity = 4 605 force_delete = true 606 launch_configuration = "my_web_config" 607} 608resource "aws_launch_configuration" "my_web_config" { 609 name = "my_web_config" 610 image_id = "ami-09b4b74c" 611 instance_type = "t2.micro" 612} 613resource "aws_autoscaling_group" "my_asg2" { 614 availability_zones = ["us-west-2a"] 615 name = "my_asg2" 616 max_size = 6 617 min_size = 1 618 health_check_grace_period = 300 619 health_check_type = "ELB" 620 desired_capacity = 4 621 force_delete = true 622 launch_configuration = "my_web_config" 623} 624resource "aws_autoscaling_group" "my_asg3" { 625 availability_zones = ["us-west-2b"] 626 name = "my_asg3" 627 max_size = 7 628 min_size = 1 629 health_check_grace_period = 300 630 health_check_type = "ELB" 631 desired_capacity = 4 632 force_delete = true 633 launch_configuration = "my_web_config" 634} 635EOF 636``` 637 638Generate the Terraform plan and convert it to JSON. 639 640```shell 641terraform init 642terraform plan --out tfplan_large.binary 643terraform show -json tfplan_large.binary > tfplan_large.json 644``` 645 646Evaluate the policy to see that it fails the policy tests and check the score. 647 648```shell 649opa eval --data terraform.rego --input tfplan_large.json "data.terraform.analysis.authz" 650opa eval --data terraform.rego --input tfplan_large.json "data.terraform.analysis.score" 651``` 652 653### 6. (Optional) Run OPA as a daemon and evaluate policy 654 655In addition to running OPA from the command-line, you can run it as a daemon loaded with the Terraform policy and 656then interact with it using its HTTP API. First, start the daemon: 657 658```shell 659opa run -s terraform.rego 660``` 661 662Then in a separate terminal, use OPA's HTTP API to evaluate the policy against the two Terraform plans. 663 664```shell 665curl localhost:8181/v0/data/terraform/analysis/authz -d @tfplan.json 666curl localhost:8181/v0/data/terraform/analysis/authz -d @tfplan_large.json 667``` 668 669 670## Wrap Up 671 672Congratulations for finishing the tutorial! 673 674You learned a number of things about Terraform Testing with OPA: 675 676* OPA gives you fine-grained policy control over Terraform plans. 677* You can use data other than the plan itself (e.g. the user) when writing authorization policies. 678 679Keep in mind that it's up to you to decide how to use OPA's Terraform tests and authorization decision. Here are some ideas. 680 681* Add it as part of your Terraform wrapper to implement unit tests on Terraform plans 682* Use it to automatically approve run-of-the-mill Terraform changes to reduce the burden of peer-review 683* Embed it into your deployment system to catch problems that arise when applying Terraform to production after applying it to staging 684