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