├── .gitignore ├── policy ├── tags.rego ├── main.rego ├── tags_test.rego └── main_test.rego ├── README.md └── main.tf /.gitignore: -------------------------------------------------------------------------------- 1 | # generic ignores 2 | .DS_Store 3 | 4 | # vscode 5 | .vscode 6 | 7 | # terraform 8 | **/.terraform/* 9 | 10 | # .tfstate files 11 | *.tfstate 12 | *.tfstate.* 13 | 14 | # Ignore test output locally 15 | tfplan 16 | tfplan.json -------------------------------------------------------------------------------- /policy/tags.rego: -------------------------------------------------------------------------------- 1 | package tags_validation 2 | 3 | minimum_tags = {"ApplicationRole", "Owner", "Project"} 4 | 5 | key_val_valid_pascal_case(key, val) { 6 | is_pascal_case(key) 7 | is_pascal_case(val) 8 | } 9 | 10 | is_pascal_case(string) { 11 | re_match(`^([A-Z][a-z0-9]+)+`, string) 12 | } 13 | 14 | tags_contain_proper_keys(tags) { 15 | keys := {key | tags[key]} 16 | leftover := minimum_tags - keys 17 | leftover == set() 18 | } -------------------------------------------------------------------------------- /policy/main.rego: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import data.tags_validation 4 | 5 | module_address[i] = address { 6 | changeset := input.resource_changes[i] 7 | address := changeset.address 8 | } 9 | 10 | tags_pascal_case[i] = resources { 11 | changeset := input.resource_changes[i] 12 | tags := changeset.change.after.tags 13 | resources := [resource | resource := module_address[i]; val := tags[key]; not tags_validation.key_val_valid_pascal_case(key, val)] 14 | } 15 | 16 | tags_contain_minimum_set[i] = resources { 17 | changeset := input.resource_changes[i] 18 | tags := changeset.change.after.tags 19 | resources := [resource | resource := module_address[i]; not tags_validation.tags_contain_proper_keys(changeset.change.after.tags)] 20 | } 21 | 22 | deny[msg] { 23 | resources := tags_contain_minimum_set[_] 24 | resources != [] 25 | msg := sprintf("Invalid tags (missing minimum required tags) for the following resources: %v", [resources]) 26 | } 27 | 28 | deny[msg] { 29 | resources := tags_pascal_case[_] 30 | resources != [] 31 | msg := sprintf("Invalid tags (not pascal case) for the following resources: %v", [resources]) 32 | } -------------------------------------------------------------------------------- /policy/tags_test.rego: -------------------------------------------------------------------------------- 1 | package tags_validation 2 | 3 | test_tags_valid_pascal_case { 4 | tags := { "ApplicationRole": "ArtifactRepository" } 5 | val := tags[key] 6 | key_val_valid_pascal_case(key, val) 7 | } 8 | 9 | test_tags_valid_pascal_case_lower_case_key { 10 | tags := { "applicationRole": "ArtifactRepository" } 11 | val := tags[key] 12 | not key_val_valid_pascal_case(key, val) 13 | } 14 | 15 | test_tags_valid_pascal_case_lower_case_value { 16 | tags := { "ApplicationRole": "artifactRepository" } 17 | val := tags[key] 18 | not key_val_valid_pascal_case(key, val) 19 | } 20 | 21 | 22 | test_tags_valid_pascal_case_lower_case_value_multiple_tags { 23 | tags := { "ApplicationRole": "artifactRepository", "Project": "Artifacts" } 24 | val := tags[key] 25 | not key_val_valid_pascal_case(key, val) 26 | } 27 | 28 | test_tags_contain_proper_keys { 29 | tags := { "ApplicationRole": "ArtifactRepository", "Project": "Artifacts", "Owner": "Ssi", "Country": "Ng" } 30 | tags_contain_proper_keys(tags) 31 | } 32 | 33 | test_tags_contain_proper_keys_missing_key { 34 | tags := { "ApplicationRole": "ArtifactRepository", "Project": "Artifacts", "Country": "Ng" } 35 | not tags_contain_proper_keys(tags) 36 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Validating Terraform plans using the Open Policy Agent 2 | 3 | This repository contains the code for the blog post here: https://blokje5.dev/posts/validating-terraform-plans/ 4 | 5 | ## Requirements 6 | 7 | The following tools are needed in order to execute the code: 8 | 9 | - [Terraform](https://www.terraform.io/) 10 | - [Conftest](https://github.com/instrumenta/conftest) 11 | 12 | Additionally, if you want to execute the [unit tests](https://www.openpolicyagent.org/docs/latest/how-do-i-test-policies/) for the policies, the [OPA binary](https://github.com/open-policy-agent/opa/releases) needs to be installed. 13 | 14 | ## Generating a terraform plan 15 | 16 | execute the following commands (note that valid AWS credentials need to be available, as we are deploying AWS resources). 17 | 18 | ```bash 19 | terraform init 20 | terraform plan -out=tfplan 21 | terraform show -json ./tfplan > tfplan.json 22 | ``` 23 | 24 | ## Evaluating the plan 25 | 26 | ```bash 27 | conftest test ./tfplan.json 28 | ``` 29 | 30 | Which returns the following output: 31 | 32 | ```bash 33 | ./tfplan.json 34 | Invalid tags (missing minimum required tags) for the following resources: ["aws_s3_bucket.helm_repo"] 35 | Invalid tags (not pascal case) for the following resources: ["aws_s3_bucket.terraform_state_bucket"] 36 | ``` 37 | 38 | ## Unit testing Rego policies 39 | 40 | ```bash 41 | cd policy 42 | opa test -v *.rego 43 | ``` 44 | -------------------------------------------------------------------------------- /policy/main_test.rego: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | test_tags_pascal_case { 4 | deny == set() with input as {"resource_changes": [{ "address": "module.one", "change": { "after": { "tags": { "ApplicationRole": "ArtifactRepository", "Owner": "Ssi", "Project": "Artifacts", "Country": "Ng" }}}}]} 5 | } 6 | 7 | test_tags_pascal_case_with_wrong_value_format { 8 | deny with input as { "resource_changes": [{ "address": "module.one", "change": { "after": { "tags": { "ApplicationRole": "artifactRepository", "Owner": "Ssi", "Project": "Artifacts", "Country": "Ng" }}}}]} 9 | } 10 | 11 | test_tags_pascal_case_with_wrong_key_format { 12 | deny with input as { "resource_changes": [{ "address": "module.one", "change": { "after": { "tags": { "ApplicationRole": "ArtifactRepository", "owner": "Ssi", "Project": "Artifacts", "Country": "Ng" }}}}]} 13 | } 14 | 15 | test_tags_contain_minimum_set { 16 | deny == set() with input as { "resource_changes": [{ "address": "module.one", "change": { "after": { "tags": { "ApplicationRole": "ArtifactRepository", "Owner": "Ssi", "Project": "Artifacts" }}}}]} 17 | } 18 | 19 | test_tags_contain_minimum_set_with_extra_tags { 20 | deny == set() with input as { "resource_changes": [{ "address": "module.one", "change": { "after": { "tags": { "ApplicationRole": "ArtifactRepository", "Owner": "Ssi", "Project": "Artifacts", "Country": "Ng" }}}}]} 21 | } 22 | 23 | test_tags_contain_minimum_set_without_minimum { 24 | deny with input as { "resource_changes": [{ "address": "module.one", "change": { "after": { "tags": { "ApplicationRole": "ArtifactRepository", "Project": "Artifacts", "Country": "Ng" }}}}]} 25 | } 26 | -------------------------------------------------------------------------------- /main.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = "eu-west-1" 3 | } 4 | 5 | resource "aws_s3_bucket" "helm_repo" { 6 | bucket_prefix = "helm-chart-repository" 7 | acl = "private" 8 | 9 | server_side_encryption_configuration { 10 | rule { 11 | apply_server_side_encryption_by_default { 12 | sse_algorithm = "AES256" 13 | } 14 | } 15 | } 16 | 17 | versioning { 18 | enabled = true 19 | } 20 | 21 | tags = { 22 | Owner = "MyTeam" 23 | ApplicationRole = "ArtifactRepository" 24 | } 25 | } 26 | 27 | resource "aws_s3_bucket_public_access_block" "helm_repo_access_rules" { 28 | bucket = aws_s3_bucket.helm_repo.id 29 | 30 | block_public_acls = true 31 | block_public_policy = true 32 | ignore_public_acls = true 33 | restrict_public_buckets = true 34 | } 35 | 36 | 37 | resource "aws_s3_bucket" "terraform_state_bucket" { 38 | bucket_prefix = "terraform-state-bucket" 39 | acl = "private" 40 | 41 | server_side_encryption_configuration { 42 | rule { 43 | apply_server_side_encryption_by_default { 44 | sse_algorithm = "AES256" 45 | } 46 | } 47 | } 48 | 49 | versioning { 50 | enabled = true 51 | } 52 | 53 | tags = { 54 | Owner = "MyTeam" 55 | Project = "Infra" 56 | ApplicationRole = "ArtifactRepository" 57 | stage = "test" 58 | } 59 | } 60 | 61 | resource "aws_s3_bucket_public_access_block" "terraform_state_bucket_access_rules" { 62 | bucket = aws_s3_bucket.terraform_state_bucket.id 63 | 64 | block_public_acls = true 65 | block_public_policy = true 66 | ignore_public_acls = true 67 | restrict_public_buckets = true 68 | } 69 | --------------------------------------------------------------------------------