diff --git a/README.md b/README.md index 383a3887..c9d7b25e 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Using a [canned ACL](https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overvie module "s3_bucket" { source = "cloudposse/s3-bucket/aws" # Cloud Posse recommends pinning every module to a specific version - # version = "x.x.x" + # version = "x.x.x" acl = "private" enabled = true user_enabled = true @@ -129,7 +129,7 @@ Using [grants](https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html module "s3_bucket" { source = "cloudposse/s3-bucket/aws" # Cloud Posse recommends pinning every module to a specific version - # version = "x.x.x" + # version = "x.x.x" acl = "" enabled = true user_enabled = true @@ -156,6 +156,39 @@ module "s3_bucket" { } ``` +Allowing specific principal ARNs to perform actions on the bucket: + +```hcl +module "s3_bucket" { + source = "cloudposse/s3-bucket/aws" + # Cloud Posse recommends pinning every module to a specific version + # version = "x.x.x" + acl = "private" + enabled = true + user_enabled = true + versioning_enabled = false + allowed_bucket_actions = ["s3:GetObject", "s3:ListBucket", "s3:GetBucketLocation"] + name = "app" + stage = "test" + namespace = "eg" + + privileged_principal_arns = { + "arn:aws:iam::123456789012:role/principal1" = ["prefix1/", "prefix2/"] + "arn:aws:iam::123456789012:role/principal2" = [""] + } + privileged_principal_actions = [ + "s3:PutObject", + "s3:PutObjectAcl", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:GetBucketLocation", + "s3:AbortMultipartUpload" + ] +} +``` + @@ -244,6 +277,8 @@ Available targets: | [namespace](#input\_namespace) | Namespace, which could be your organization name or abbreviation, e.g. 'eg' or 'cp' | `string` | `null` | no | | [object\_lock\_configuration](#input\_object\_lock\_configuration) | A configuration for S3 object locking. With S3 Object Lock, you can store objects using a `write once, read many` (WORM) model. Object Lock can help prevent objects from being deleted or overwritten for a fixed amount of time or indefinitely. |
object({
mode = string # Valid values are GOVERNANCE and COMPLIANCE.
days = number
years = number
})
| `null` | no | | [policy](#input\_policy) | A valid bucket policy JSON document. Note that if the policy document is not specific enough (but still valid), Terraform may view the policy as constantly changing in a terraform plan. In this case, please make sure you use the verbose/specific version of the policy | `string` | `""` | no | +| [privileged\_principal\_actions](#input\_privileged\_principal\_actions) | List of actions to permit `privileged_principal_arns` to perform on bucket and bucket prefixes (see `privileged_principal_arns`) | `list(string)` | `[]` | no | +| [privileged\_principal\_arns](#input\_privileged\_principal\_arns) | (Optional) Map of IAM Principal ARNs to lists of S3 path prefixes to grant `privileged_principal_actions` permissions.
Resource list will include the bucket itself along with all the prefixes. Prefixes should not begin with '/'. | `map(list(string))` | `{}` | no | | [regex\_replace\_chars](#input\_regex\_replace\_chars) | Regex to replace chars with empty string in `namespace`, `environment`, `stage` and `name`.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | | [replication\_rules](#input\_replication\_rules) | DEPRECATED: Use s3\_replication\_rules instead. | `list(any)` | `null` | no | | [restrict\_public\_buckets](#input\_restrict\_public\_buckets) | Set to `false` to disable the restricting of making the bucket public | `bool` | `true` | no | @@ -427,8 +462,8 @@ Check out [our other projects][github], [follow us on twitter][twitter], [apply ### Contributors -| [![Erik Osterman][osterman_avatar]][osterman_homepage]
[Erik Osterman][osterman_homepage] | [![Andriy Knysh][aknysh_avatar]][aknysh_homepage]
[Andriy Knysh][aknysh_homepage] | [![Maxim Mironenko][maximmi_avatar]][maximmi_homepage]
[Maxim Mironenko][maximmi_homepage] | [![Josh Myers][joshmyers_avatar]][joshmyers_homepage]
[Josh Myers][joshmyers_homepage] | -|---|---|---|---| +| [![Erik Osterman][osterman_avatar]][osterman_homepage]
[Erik Osterman][osterman_homepage] | [![Andriy Knysh][aknysh_avatar]][aknysh_homepage]
[Andriy Knysh][aknysh_homepage] | [![Maxim Mironenko][maximmi_avatar]][maximmi_homepage]
[Maxim Mironenko][maximmi_homepage] | [![Josh Myers][joshmyers_avatar]][joshmyers_homepage]
[Josh Myers][joshmyers_homepage] | [![Yonatan Koren][korenyoni_avatar]][korenyoni_homepage]
[Yonatan Koren][korenyoni_homepage] | +|---|---|---|---|---| [osterman_homepage]: https://github.com/osterman @@ -439,6 +474,8 @@ Check out [our other projects][github], [follow us on twitter][twitter], [apply [maximmi_avatar]: https://img.cloudposse.com/150x150/https://github.com/maximmi.png [joshmyers_homepage]: https://github.com/joshmyers [joshmyers_avatar]: https://img.cloudposse.com/150x150/https://github.com/joshmyers.png + [korenyoni_homepage]: https://github.com/korenyoni + [korenyoni_avatar]: https://img.cloudposse.com/150x150/https://github.com/korenyoni.png [![README Footer][readme_footer_img]][readme_footer_link] [![Beacon][beacon]][website] diff --git a/README.yaml b/README.yaml index 7f6a8e73..2d29189f 100644 --- a/README.yaml +++ b/README.yaml @@ -88,7 +88,7 @@ usage: |- module "s3_bucket" { source = "cloudposse/s3-bucket/aws" # Cloud Posse recommends pinning every module to a specific version - # version = "x.x.x" + # version = "x.x.x" acl = "private" enabled = true user_enabled = true @@ -106,7 +106,7 @@ usage: |- module "s3_bucket" { source = "cloudposse/s3-bucket/aws" # Cloud Posse recommends pinning every module to a specific version - # version = "x.x.x" + # version = "x.x.x" acl = "" enabled = true user_enabled = true @@ -133,6 +133,39 @@ usage: |- } ``` + Allowing specific principal ARNs to perform actions on the bucket: + + ```hcl + module "s3_bucket" { + source = "cloudposse/s3-bucket/aws" + # Cloud Posse recommends pinning every module to a specific version + # version = "x.x.x" + acl = "private" + enabled = true + user_enabled = true + versioning_enabled = false + allowed_bucket_actions = ["s3:GetObject", "s3:ListBucket", "s3:GetBucketLocation"] + name = "app" + stage = "test" + namespace = "eg" + + privileged_principal_arns = { + "arn:aws:iam::123456789012:role/principal1" = ["prefix1/", "prefix2/"] + "arn:aws:iam::123456789012:role/principal2" = [""] + } + privileged_principal_actions = [ + "s3:PutObject", + "s3:PutObjectAcl", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:GetBucketLocation", + "s3:AbortMultipartUpload" + ] + } + ``` + include: - "docs/targets.md" - "docs/terraform.md" @@ -147,3 +180,5 @@ contributors: github: "maximmi" - name: "Josh Myers" github: "joshmyers" + - name: "Yonatan Koren" + github: "korenyoni" diff --git a/docs/terraform.md b/docs/terraform.md index d38c3c49..12f25ddd 100644 --- a/docs/terraform.md +++ b/docs/terraform.md @@ -68,6 +68,8 @@ | [namespace](#input\_namespace) | Namespace, which could be your organization name or abbreviation, e.g. 'eg' or 'cp' | `string` | `null` | no | | [object\_lock\_configuration](#input\_object\_lock\_configuration) | A configuration for S3 object locking. With S3 Object Lock, you can store objects using a `write once, read many` (WORM) model. Object Lock can help prevent objects from being deleted or overwritten for a fixed amount of time or indefinitely. |
object({
mode = string # Valid values are GOVERNANCE and COMPLIANCE.
days = number
years = number
})
| `null` | no | | [policy](#input\_policy) | A valid bucket policy JSON document. Note that if the policy document is not specific enough (but still valid), Terraform may view the policy as constantly changing in a terraform plan. In this case, please make sure you use the verbose/specific version of the policy | `string` | `""` | no | +| [privileged\_principal\_actions](#input\_privileged\_principal\_actions) | List of actions to permit `privileged_principal_arns` to perform on bucket and bucket prefixes (see `privileged_principal_arns`) | `list(string)` | `[]` | no | +| [privileged\_principal\_arns](#input\_privileged\_principal\_arns) | (Optional) Map of IAM Principal ARNs to lists of S3 path prefixes to grant `privileged_principal_actions` permissions.
Resource list will include the bucket itself along with all the prefixes. Prefixes should not begin with '/'. | `map(list(string))` | `{}` | no | | [regex\_replace\_chars](#input\_regex\_replace\_chars) | Regex to replace chars with empty string in `namespace`, `environment`, `stage` and `name`.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | | [replication\_rules](#input\_replication\_rules) | DEPRECATED: Use s3\_replication\_rules instead. | `list(any)` | `null` | no | | [restrict\_public\_buckets](#input\_restrict\_public\_buckets) | Set to `false` to disable the restricting of making the bucket public | `bool` | `true` | no | diff --git a/examples/complete/fixtures.us-east-2.tfvars b/examples/complete/fixtures.us-east-2.tfvars index d9022d6b..39768da2 100644 --- a/examples/complete/fixtures.us-east-2.tfvars +++ b/examples/complete/fixtures.us-east-2.tfvars @@ -12,8 +12,19 @@ acl = "private" force_destroy = true +user_enabled = true + versioning_enabled = false allow_encrypted_uploads_only = true -allowed_bucket_actions = ["s3:PutObject", "s3:PutObjectAcl", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket", "s3:ListBucketMultipartUploads", "s3:GetBucketLocation", "s3:AbortMultipartUpload"] +allowed_bucket_actions = [ + "s3:PutObject", + "s3:PutObjectAcl", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:GetBucketLocation", + "s3:AbortMultipartUpload" +] diff --git a/examples/complete/grants.us-east-2.tfvars b/examples/complete/grants.us-east-2.tfvars index 3df51ded..ae61c275 100644 --- a/examples/complete/grants.us-east-2.tfvars +++ b/examples/complete/grants.us-east-2.tfvars @@ -23,4 +23,13 @@ versioning_enabled = false allow_encrypted_uploads_only = true -allowed_bucket_actions = ["s3:PutObject", "s3:PutObjectAcl", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket", "s3:ListBucketMultipartUploads", "s3:GetBucketLocation", "s3:AbortMultipartUpload"] +allowed_bucket_actions = [ + "s3:PutObject", + "s3:PutObjectAcl", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:GetBucketLocation", + "s3:AbortMultipartUpload" +] diff --git a/examples/complete/lifecycle.us-east-2.tfvars b/examples/complete/lifecycle.us-east-2.tfvars index cf8f6434..c1037646 100644 --- a/examples/complete/lifecycle.us-east-2.tfvars +++ b/examples/complete/lifecycle.us-east-2.tfvars @@ -57,4 +57,13 @@ versioning_enabled = false allow_encrypted_uploads_only = true -allowed_bucket_actions = ["s3:PutObject", "s3:PutObjectAcl", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket", "s3:ListBucketMultipartUploads", "s3:GetBucketLocation", "s3:AbortMultipartUpload"] +allowed_bucket_actions = [ + "s3:PutObject", + "s3:PutObjectAcl", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:GetBucketLocation", + "s3:AbortMultipartUpload" +] diff --git a/examples/complete/main.tf b/examples/complete/main.tf index 8f7b8d80..31ac8d81 100644 --- a/examples/complete/main.tf +++ b/examples/complete/main.tf @@ -1,15 +1,3 @@ -locals { - replication_enabled = length(var.s3_replication_rules) > 0 - extra_rule = local.replication_enabled ? { - id = "replication-test-explicit-bucket" - status = "Enabled" - prefix = "/extra" - priority = 5 - destination_bucket = module.s3_bucket_replication_target_extra[0].bucket_arn - } : null - s3_replication_rules = local.replication_enabled ? concat(var.s3_replication_rules, [local.extra_rule]) : null -} - provider "aws" { region = var.region } @@ -17,7 +5,7 @@ provider "aws" { module "s3_bucket" { source = "../../" - user_enabled = true + user_enabled = var.user_enabled acl = var.acl force_destroy = var.force_destroy grants = var.grants @@ -30,36 +18,8 @@ module "s3_bucket" { s3_replication_enabled = local.replication_enabled s3_replica_bucket_arn = join("", module.s3_bucket_replication_target.*.bucket_arn) s3_replication_rules = local.s3_replication_rules + privileged_principal_actions = var.privileged_principal_actions + privileged_principal_arns = local.privileged_principal_arns context = module.this.context } - -module "s3_bucket_replication_target" { - count = local.replication_enabled ? 1 : 0 - - source = "../../" - - user_enabled = true - acl = "private" - force_destroy = true - versioning_enabled = true - s3_replication_source_roles = [module.s3_bucket.replication_role_arn] - - attributes = ["target"] - context = module.this.context -} - -module "s3_bucket_replication_target_extra" { - count = local.replication_enabled ? 1 : 0 - - source = "../../" - - user_enabled = true - acl = "private" - force_destroy = true - versioning_enabled = true - s3_replication_source_roles = [module.s3_bucket.replication_role_arn] - - attributes = ["target", "extra"] - context = module.this.context -} diff --git a/examples/complete/privileged-principals.tf b/examples/complete/privileged-principals.tf new file mode 100644 index 00000000..7a9d5905 --- /dev/null +++ b/examples/complete/privileged-principals.tf @@ -0,0 +1,88 @@ +locals { + account_id = data.aws_caller_identity.current.account_id + principal_names = [ + "arn:aws:iam::${local.account_id}:role/${join("", module.deployment_principal_label.*.id)}", + "arn:aws:iam::${local.account_id}:role/${join("", module.additional_deployment_principal_label.*.id)}" + ] + privileged_principal_arns = var.privileged_principal_enabled ? { + (local.principal_names[0]) = [""] + (local.principal_names[1]) = ["prefix1/", "prefix2/"] + } : {} +} + +data "aws_caller_identity" "current" {} + +data "aws_iam_policy_document" "deployment_assume_role" { + count = var.privileged_principal_enabled ? 1 : 0 + + statement { + actions = ["sts:AssumeRole"] + effect = "Allow" + principals { + identifiers = ["ec2.amazonaws.com"] # example: this role can be used in an IAM Instance Profile + type = "Service" + } + } +} + +data "aws_iam_policy_document" "deployment_iam_policy" { + count = var.privileged_principal_enabled ? 1 : 0 + + statement { + actions = var.privileged_principal_actions + effect = "Allow" + resources = ["arn:aws:s3:::${module.this.id}*"] + } +} + +resource "aws_iam_policy" "deployment_iam_policy" { + count = var.privileged_principal_enabled ? 1 : 0 + + policy = join("", data.aws_iam_policy_document.deployment_iam_policy.*.json) +} + +module "deployment_principal_label" { + source = "cloudposse/label/null" + version = "0.24.1" + enabled = var.privileged_principal_enabled + + attributes = ["deployment"] + + context = module.this.context +} + +resource "aws_iam_role" "deployment_iam_role" { + count = var.privileged_principal_enabled ? 1 : 0 + + name = join("", module.deployment_principal_label.*.id) + assume_role_policy = join("", data.aws_iam_policy_document.deployment_assume_role.*.json) + + tags = module.deployment_principal_label.tags +} + + +module "additional_deployment_principal_label" { + source = "cloudposse/label/null" + version = "0.24.1" + enabled = var.privileged_principal_enabled + + attributes = ["deployment", "additional"] + + context = module.this.context +} + +resource "aws_iam_role" "additional_deployment_iam_role" { + count = var.privileged_principal_enabled ? 1 : 0 + + name = join("", module.additional_deployment_principal_label.*.id) + assume_role_policy = join("", data.aws_iam_policy_document.deployment_assume_role.*.json) + + tags = module.additional_deployment_principal_label.tags +} + +resource "aws_iam_role_policy_attachment" "additional_deployment_role_attachment" { + count = var.privileged_principal_enabled ? 1 : 0 + + policy_arn = join("", aws_iam_policy.deployment_iam_policy.*.arn) + role = join("", aws_iam_role.deployment_iam_role.*.name) +} diff --git a/examples/complete/privileged-principals.us-east-2.tfvars b/examples/complete/privileged-principals.us-east-2.tfvars new file mode 100644 index 00000000..e2421ee0 --- /dev/null +++ b/examples/complete/privileged-principals.us-east-2.tfvars @@ -0,0 +1,32 @@ +enabled = true + +region = "us-east-2" + +namespace = "eg" + +stage = "test" + +name = "s3-principals-test" # s3-privileged-principals-test will hit the name length limit + +acl = "private" + +force_destroy = true + +allow_encrypted_uploads_only = true + +privileged_principal_actions = [ + "s3:PutObject", + "s3:PutObjectAcl", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:GetBucketLocation", + "s3:AbortMultipartUpload" +] + +privileged_principal_enabled = true + +versioning_enabled = false + +user_enabled = false diff --git a/examples/complete/replication.tf b/examples/complete/replication.tf new file mode 100644 index 00000000..3fe2037b --- /dev/null +++ b/examples/complete/replication.tf @@ -0,0 +1,41 @@ +locals { + replication_enabled = length(var.s3_replication_rules) > 0 + extra_rule = local.replication_enabled ? { + id = "replication-test-explicit-bucket" + status = "Enabled" + prefix = "/extra" + priority = 5 + destination_bucket = module.s3_bucket_replication_target_extra[0].bucket_arn + } : null + s3_replication_rules = local.replication_enabled ? concat(var.s3_replication_rules, [local.extra_rule]) : null +} + +module "s3_bucket_replication_target" { + count = local.replication_enabled ? 1 : 0 + + source = "../../" + + user_enabled = true + acl = "private" + force_destroy = true + versioning_enabled = true + s3_replication_source_roles = [module.s3_bucket.replication_role_arn] + + attributes = ["target"] + context = module.this.context +} + +module "s3_bucket_replication_target_extra" { + count = local.replication_enabled ? 1 : 0 + + source = "../../" + + user_enabled = true + acl = "private" + force_destroy = true + versioning_enabled = true + s3_replication_source_roles = [module.s3_bucket.replication_role_arn] + + attributes = ["target", "extra"] + context = module.this.context +} diff --git a/examples/complete/variables.tf b/examples/complete/variables.tf index b5fc93c0..74ceb5a9 100644 --- a/examples/complete/variables.tf +++ b/examples/complete/variables.tf @@ -237,4 +237,16 @@ variable "object_lock_configuration" { }) default = null description = "A configuration for S3 object locking. With S3 Object Lock, you can store objects using a `write once, read many` (WORM) model. Object Lock can help prevent objects from being deleted or overwritten for a fixed amount of time or indefinitely." -} \ No newline at end of file +} + +variable "privileged_principal_enabled" { + type = bool + default = false + description = "Whether or not to allow Privileged Principals to perform actions on the bucket" +} + +variable "privileged_principal_actions" { + type = list(string) + default = [] + description = "List of actions to permit `privileged_principal_arns` to perform on bucket and bucket prefixes (see `privileged_principal_arns`)" +} diff --git a/main.tf b/main.tf index 7155bda1..ab007152 100644 --- a/main.tf +++ b/main.tf @@ -322,6 +322,7 @@ data "aws_iam_policy_document" "bucket_policy" { } } } + dynamic "statement" { for_each = length(var.s3_replication_source_roles) > 0 ? [1] : [] @@ -335,6 +336,23 @@ data "aws_iam_policy_document" "bucket_policy" { } } } + + dynamic "statement" { + for_each = keys(var.privileged_principal_arns) + + content { + sid = "AllowPrivilegedPrincipal[${statement.key}]" # add indices to Sid + actions = var.privileged_principal_actions + resources = distinct(flatten([ + "arn:${data.aws_partition.current.partition}:s3:::${join("", aws_s3_bucket.default.*.id)}", + formatlist("arn:${data.aws_partition.current.partition}:s3:::${join("", aws_s3_bucket.default.*.id)}/%s*", var.privileged_principal_arns[statement.value]), + ])) + principals { + type = "AWS" + identifiers = [statement.value] + } + } + } } data "aws_iam_policy_document" "aggregated_policy" { diff --git a/outputs.tf b/outputs.tf index 31681bb7..b3e9c62c 100644 --- a/outputs.tf +++ b/outputs.tf @@ -1,30 +1,30 @@ output "bucket_domain_name" { - value = module.this.enabled ? join("", aws_s3_bucket.default.*.bucket_domain_name) : "" + value = local.enabled ? join("", aws_s3_bucket.default.*.bucket_domain_name) : "" description = "FQDN of bucket" } output "bucket_regional_domain_name" { - value = module.this.enabled ? join("", aws_s3_bucket.default.*.bucket_regional_domain_name) : "" + value = local.enabled ? join("", aws_s3_bucket.default.*.bucket_regional_domain_name) : "" description = "The bucket region-specific domain name" } output "bucket_id" { - value = module.this.enabled ? join("", aws_s3_bucket.default.*.id) : "" + value = local.enabled ? join("", aws_s3_bucket.default.*.id) : "" description = "Bucket Name (aka ID)" } output "bucket_arn" { - value = module.this.enabled ? join("", aws_s3_bucket.default.*.arn) : "" + value = local.enabled ? join("", aws_s3_bucket.default.*.arn) : "" description = "Bucket ARN" } output "bucket_region" { - value = module.this.enabled ? join("", aws_s3_bucket.default.*.region) : "" + value = local.enabled ? join("", aws_s3_bucket.default.*.region) : "" description = "Bucket region" } output "enabled" { - value = module.this.enabled + value = local.enabled description = "Is module enabled" } @@ -49,7 +49,7 @@ output "user_unique_id" { } output "replication_role_arn" { - value = module.this.enabled && local.replication_enabled ? join("", aws_iam_role.replication.*.arn) : "" + value = local.enabled && local.replication_enabled ? join("", aws_iam_role.replication.*.arn) : "" description = "The ARN of the replication IAM Role" } diff --git a/test/src/examples_complete_test.go b/test/src/examples_complete_test.go index 4450a978..99fb3768 100644 --- a/test/src/examples_complete_test.go +++ b/test/src/examples_complete_test.go @@ -1,11 +1,15 @@ package test import ( + "bytes" + "encoding/json" "math/rand" "strconv" + "strings" "testing" "time" + "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/terraform" test_structure "github.com/gruntwork-io/terratest/modules/test-structure" "github.com/stretchr/testify/assert" @@ -58,7 +62,7 @@ func TestExamplesComplete(t *testing.T) { // Test the Terraform module in examples/complete using Terratest for grants. func TestExamplesCompleteWithGrants(t *testing.T) { t.Parallel() - rand.Seed(time.Now().UnixNano()+1) + rand.Seed(time.Now().UnixNano() + 1) attributes := []string{strconv.Itoa(rand.Intn(100000))} rootFolder := "../../" @@ -84,13 +88,6 @@ func TestExamplesCompleteWithGrants(t *testing.T) { // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) - // Run `terraform output` to get the value of an output variable - userName := terraform.Output(t, terraformOptions, "user_name") - - expectedUserName := "eg-test-s3-grants-test-" + attributes[0] - // Verify we're getting back the outputs we expect - assert.Equal(t, expectedUserName, userName) - // Run `terraform output` to get the value of an output variable s3BucketId := terraform.Output(t, terraformOptions, "bucket_id") @@ -102,7 +99,7 @@ func TestExamplesCompleteWithGrants(t *testing.T) { // Test the Terraform module in examples/complete using Terratest for grants. func TestExamplesCompleteWithObjectLock(t *testing.T) { t.Parallel() - rand.Seed(time.Now().UnixNano()+2) + rand.Seed(time.Now().UnixNano() + 2) attributes := []string{strconv.Itoa(rand.Intn(100000))} rootFolder := "../../" @@ -128,14 +125,6 @@ func TestExamplesCompleteWithObjectLock(t *testing.T) { // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) - // Run `terraform output` to get the value of an output variable - userName := terraform.Output(t, terraformOptions, "user_name") - - expectedUserName := "eg-test-s3-object-lock-test-" + attributes[0] - - // Verify we're getting back the outputs we expect - assert.Equal(t, expectedUserName, userName) - // Run `terraform output` to get the value of an output variable s3BucketId := terraform.Output(t, terraformOptions, "bucket_id") expectedS3BucketId := "eg-test-s3-object-lock-test-" + attributes[0] @@ -145,7 +134,7 @@ func TestExamplesCompleteWithObjectLock(t *testing.T) { func TestExamplesCompleteWithLifecycleRules(t *testing.T) { t.Parallel() - rand.Seed(time.Now().UnixNano()+3) + rand.Seed(time.Now().UnixNano() + 3) attributes := []string{strconv.Itoa(rand.Intn(100000))} rootFolder := "../../" @@ -171,13 +160,6 @@ func TestExamplesCompleteWithLifecycleRules(t *testing.T) { // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) - // Run `terraform output` to get the value of an output variable - userName := terraform.Output(t, terraformOptions, "user_name") - - expectedUserName := "eg-test-s3-lifecycle-test-" + attributes[0] - // Verify we're getting back the outputs we expect - assert.Equal(t, expectedUserName, userName) - // Run `terraform output` to get the value of an output variable s3BucketId := terraform.Output(t, terraformOptions, "bucket_id") @@ -186,10 +168,9 @@ func TestExamplesCompleteWithLifecycleRules(t *testing.T) { assert.Equal(t, expectedS3BucketId, s3BucketId) } - func TestExamplesCompleteWithReplication(t *testing.T) { t.Parallel() - rand.Seed(time.Now().UnixNano()+4) + rand.Seed(time.Now().UnixNano() + 4) attributes := []string{strconv.Itoa(rand.Intn(100000))} rootFolder := "../../" @@ -206,7 +187,7 @@ func TestExamplesCompleteWithReplication(t *testing.T) { VarFiles: varFiles, Vars: map[string]interface{}{ "attributes": attributes, - "enabled": "true", + "enabled": "true", }, } @@ -216,13 +197,6 @@ func TestExamplesCompleteWithReplication(t *testing.T) { // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) - // Run `terraform output` to get the value of an output variable - userName := terraform.Output(t, terraformOptions, "user_name") - - expectedUserName := "eg-test-s3-replication-test-" + attributes[0] - // Verify we're getting back the outputs we expect - assert.Equal(t, expectedUserName, userName) - // Run `terraform output` to get the value of an output variable s3BucketId := terraform.Output(t, terraformOptions, "bucket_id") @@ -245,9 +219,117 @@ func TestExamplesCompleteWithReplication(t *testing.T) { } +func TestExamplesCompleteWithPrivilegedPrincipals(t *testing.T) { + t.Parallel() + rand.Seed(time.Now().UnixNano() + 5) + + awsRegion := "us-east-2" + attributes := []string{strconv.Itoa(rand.Intn(100000))} + rootFolder := "../../" + terraformFolderRelativeToRoot := "examples/complete" + varFiles := []string{"privileged-principals.us-east-2.tfvars"} + + tempTestFolder := test_structure.CopyTerraformFolderToTemp(t, rootFolder, terraformFolderRelativeToRoot) + + terraformOptions := &terraform.Options{ + // The path to where our Terraform code is located + TerraformDir: tempTestFolder, + Upgrade: true, + // Variables to pass to our Terraform code using -var-file options + VarFiles: varFiles, + Vars: map[string]interface{}{ + "attributes": attributes, + "enabled": "true", + }, + } + + // At the end of the test, run `terraform destroy` to clean up any resources that were created + defer terraform.Destroy(t, terraformOptions) + + // This will run `terraform init` and `terraform apply` and fail the test if there are any errors + terraform.InitAndApply(t, terraformOptions) + + // Run `terraform output` to get the value of an output variable + s3BucketId := terraform.Output(t, terraformOptions, "bucket_id") + + expectedS3BucketId := "eg-test-s3-principals-test-" + attributes[0] + // Verify we're getting back the outputs we expect + assert.Equal(t, expectedS3BucketId, s3BucketId) + + // Run `terraform output` to get the value of an output variable + bucketID := terraform.Output(t, terraformOptions, "bucket_id") + + // Verify that our Bucket has a policy attached + aws.AssertS3BucketPolicyExists(t, awsRegion, bucketID) + + // Verify that our bucket's bucket policy contains the expected statements allowing actions made by privileged principals + bucketPolicy := aws.GetS3BucketPolicy(t, awsRegion, bucketID) + expectedBucketPolicyStatementsTemplate := ` + [{ + "Sid": "AllowPrivilegedPrincipal[0]", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::AWS_ACCOUNT_ID:role/eg-test-s3-principals-test-RANDOM_ID-deployment" + }, + "Action": [ + "s3:PutObjectAcl", + "s3:PutObject", + "s3:ListBucketMultipartUploads", + "s3:ListBucket", + "s3:GetObject", + "s3:GetBucketLocation", + "s3:DeleteObject", + "s3:AbortMultipartUpload" + ], + "Resource": [ + "arn:aws:s3:::eg-test-s3-principals-test-RANDOM_ID/*", + "arn:aws:s3:::eg-test-s3-principals-test-RANDOM_ID" + ] + }, + { + "Sid": "AllowPrivilegedPrincipal[1]", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::AWS_ACCOUNT_ID:role/eg-test-s3-principals-test-RANDOM_ID-deployment-additional" + }, + "Action": [ + "s3:PutObjectAcl", + "s3:PutObject", + "s3:ListBucketMultipartUploads", + "s3:ListBucket", + "s3:GetObject", + "s3:GetBucketLocation", + "s3:DeleteObject", + "s3:AbortMultipartUpload" + ], + "Resource": [ + "arn:aws:s3:::eg-test-s3-principals-test-RANDOM_ID/prefix2/*", + "arn:aws:s3:::eg-test-s3-principals-test-RANDOM_ID/prefix1/*", + "arn:aws:s3:::eg-test-s3-principals-test-RANDOM_ID" + ] + }] + ` + expectedBucketPolicyStatements := strings.ReplaceAll( + strings.ReplaceAll( + expectedBucketPolicyStatementsTemplate, + "AWS_ACCOUNT_ID", + aws.GetAccountId(t), + ), + "RANDOM_ID", + attributes[0], + ) + expectedBucketPolicyStatementsJSON := new(bytes.Buffer) + err := json.Compact(expectedBucketPolicyStatementsJSON, []byte(expectedBucketPolicyStatements)) + if err != nil { + t.Errorf("Unexpected error when compacting JSON: %v.", err) + } + expectedBucketPolicySnippet := strings.Trim(expectedBucketPolicyStatementsJSON.String(), "[]") + assert.Contains(t, bucketPolicy, expectedBucketPolicySnippet) +} + func TestExamplesCompleteDisabled(t *testing.T) { t.Parallel() - rand.Seed(time.Now().UnixNano()+5) + rand.Seed(time.Now().UnixNano() + 6) attributes := []string{strconv.Itoa(rand.Intn(100000))} rootFolder := "../../" @@ -264,7 +346,7 @@ func TestExamplesCompleteDisabled(t *testing.T) { VarFiles: varFiles, Vars: map[string]interface{}{ "attributes": attributes, - "enabled": "false", + "enabled": "false", }, } diff --git a/variables.tf b/variables.tf index 9a1a3366..3692d76a 100644 --- a/variables.tf +++ b/variables.tf @@ -257,3 +257,18 @@ variable "website_inputs" { description = "Specifies the static website hosting configuration object." } + +variable "privileged_principal_arns" { + type = map(list(string)) + default = {} + description = <<-EOT + (Optional) Map of IAM Principal ARNs to lists of S3 path prefixes to grant `privileged_principal_actions` permissions. + Resource list will include the bucket itself along with all the prefixes. Prefixes should not begin with '/'. + EOT +} + +variable "privileged_principal_actions" { + type = list(string) + default = [] + description = "List of actions to permit `privileged_principal_arns` to perform on bucket and bucket prefixes (see `privileged_principal_arns`)" +}