From 93d3b132f919b870d3c589f8017496b886cf50b9 Mon Sep 17 00:00:00 2001 From: Nuru Date: Fri, 25 Aug 2023 19:00:00 -0700 Subject: [PATCH] Update large inputs to objects with optional attributes --- README.md | 90 +++++-------- README.yaml | 76 ++++------- docs/terraform.md | 16 +-- examples/complete/versions.tf | 2 +- lifecycle.tf | 6 +- main.tf | 96 +++++++------- outputs.tf | 7 +- variables.tf | 230 ++++++++++++++++++++-------------- versions.tf | 2 +- 9 files changed, 268 insertions(+), 257 deletions(-) diff --git a/README.md b/README.md index 7e3c7c74..fc176001 100644 --- a/README.md +++ b/README.md @@ -31,18 +31,29 @@ This module creates an S3 bucket with support for versioning, lifecycles, object locks, replication, encryption, ACL, bucket object policies, and static website hosting. -If `user_enabled` variable is set to `true`, the module will provision a basic IAM user with permissions to access the bucket. -This basic IAM system user is suitable for CI/CD systems (_e.g._ TravisCI, CircleCI) or systems which are *external* to AWS that cannot leverage -[AWS IAM Instance Profiles](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2_instance-profiles.html) -or [AWS OIDC](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) to authenticate and -do not already have IAM credentials. Users or systems that have IAM credentials should either be granted access directly based on -their IAM identity via `privileged_principal_arns` or be allowed to assume an IAM role with access. - -We do not recommend creating IAM users this way for any other purpose. +For backward compatibility, it sets the S3 bucket ACL to `private` and the `s3_object_ownership` +to `ObjectWriter`. Moving forward, setting `s3_object_ownership` to `BucketOwnerEnforced` is recommended, +and doing so automatically disables the ACL. This module blocks public access to the bucket by default. See `block_public_acls`, `block_public_policy`, `ignore_public_acls`, and `restrict_public_buckets` to change the settings. See [AWS documentation](https://docs.aws.amazon.com/AmazonS3/latest/dev/access-control-block-public-access.html) -for more details. +for more details. + +This module can optionally create an IAM User with access to the S3 bucket. This is inherently insecure in that +to enable anyone to become the User, access keys must be generated, and anything generated by Terraform is stored +unencrypted in the Terraform state. See the [Terraform documentation](https://www.terraform.io/docs/state/sensitive-data.html) for more details + +The best way to grant access to the bucket is to grant one or more IAM Roles access to the bucket via `privileged_principal_arns`. +This IAM Role can be assumed by EC2 instances via their Instance Profile, or Kubernetes (EKS) services using +[IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html). +Entities outside of AWS can assume the Role via [OIDC](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html). +(See [this example of connecting GitHub](https://aws.amazon.com/blogs/security/use-iam-roles-to-connect-github-actions-to-actions-in-aws/) +to enable GitHub actions to assume AWS IAM roles, or use [this Cloud Posse component](https://github.com/cloudposse/terraform-aws-components/tree/main/modules/github-oidc-provider) +if you are already using the Cloud Posse reference architecture.) + +If neither of those approaches work, then as a last resort you can set `user_enabled = true` and +this module will provision a basic IAM user with permissions to access the bucket. +We do not recommend creating IAM users this way for any other purpose. If an IAM user is created, the IAM user name is constructed using [terraform-null-label](https://github.com/cloudposse/terraform-null-label) and some input is required. The simplest input is `name`. By default the name will be converted to lower case @@ -50,8 +61,8 @@ and all non-alphanumeric characters except for hyphen will be removed. See the d to learn how to override these defaults if desired. If an AWS Access Key is created, it is stored either in SSM Parameter Store or is provided as a module output, -but not both. Using SSM Parameter Store is recommended because module outputs are stored in plaintext in -the Terraform state file. +but not both. Using SSM Parameter Store is recommended because that will keep the secret from being easily accessible +via Terraform remote state lookup, but the key will still be stored unencrypted in the Terraform state in any case. --- @@ -112,7 +123,7 @@ using so that your infrastructure remains stable, and update versions in a systematic way so that they do not catch you by surprise. -Using a [canned ACL](https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html). +Using [BucketOwnerEnforced](https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html#object-ownership-overview) ```hcl module "s3_bucket" { @@ -123,7 +134,7 @@ module "s3_bucket" { stage = "test" namespace = "eg" - acl = "private" + s3_object_ownership = "BucketOwnerEnforced" enabled = true user_enabled = false versioning_enabled = false @@ -175,41 +186,6 @@ locals { } ``` -Using [grants](https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html) to enable access -to another account and for logging, and incorporating the above lifecycle configuration. - -```hcl -module "s3_bucket" { - source = "cloudposse/s3-bucket/aws" - # Cloud Posse recommends pinning every module to a specific version - # version = "x.x.x" - name = "app" - stage = "test" - namespace = "eg" - - acl = "" - enabled = true - user_enabled = false - versioning_enabled = true - - lifecycle_configuration_rules = local.lifecycle_configuration_rules - - grants = [ - { - id = "012abc345def678ghi901" # Canonical user or account id - type = "CanonicalUser" - permissions = ["FULL_CONTROL"] - uri = null - }, - { - id = null - type = "Group" - permissions = ["READ", "WRITE"] - uri = "http://acs.amazonaws.com/groups/s3/LogDelivery" - }, - ] -} -``` Allowing specific principal ARNs to perform actions on the bucket: @@ -218,7 +194,7 @@ module "s3_bucket" { source = "cloudposse/s3-bucket/aws" # Cloud Posse recommends pinning every module to a specific version # version = "x.x.x" - acl = "private" + s3_object_ownership = "BucketOwnerEnforced" enabled = true user_enabled = true versioning_enabled = false @@ -269,7 +245,7 @@ Available targets: | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.0 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 4.9.0 | | [time](#requirement\_time) | >= 0.7 | @@ -330,10 +306,10 @@ Available targets: | [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | | [block\_public\_acls](#input\_block\_public\_acls) | Set to `false` to disable the blocking of new public access lists on the bucket | `bool` | `true` | no | | [block\_public\_policy](#input\_block\_public\_policy) | Set to `false` to disable the blocking of new public policies on the bucket | `bool` | `true` | no | -| [bucket\_key\_enabled](#input\_bucket\_key\_enabled) | Set this to true to use Amazon S3 Bucket Keys for SSE-KMS, which may reduce the number of AWS KMS requests.
For more information, see: https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-key.html | `bool` | `false` | no | +| [bucket\_key\_enabled](#input\_bucket\_key\_enabled) | Set this to true to use Amazon S3 Bucket Keys for SSE-KMS, which may or may not reduce the number of AWS KMS requests.
For more information, see: https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-key.html | `bool` | `false` | no | | [bucket\_name](#input\_bucket\_name) | Bucket name. If provided, the bucket will be created with this name instead of generating the name from the context | `string` | `null` | no | | [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"namespace": null,
"regex_replace_chars": null,
"stage": null,
"tags": {},
"tenant": null
}
| no | -| [cors\_configuration](#input\_cors\_configuration) | Specifies the allowed headers, methods, origins and exposed headers when using CORS on this bucket |
list(object({
allowed_headers = list(string)
allowed_methods = list(string)
allowed_origins = list(string)
expose_headers = list(string)
max_age_seconds = number
}))
| `[]` | no | +| [cors\_configuration](#input\_cors\_configuration) | Specifies the allowed headers, methods, origins and exposed headers when using CORS on this bucket |
list(object({
id = optional(string)
allowed_headers = optional(list(string))
allowed_methods = optional(list(string))
allowed_origins = optional(list(string))
expose_headers = optional(list(string))
max_age_seconds = optional(number)
}))
| `[]` | no | | [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | | [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | @@ -347,10 +323,10 @@ Available targets: | [label\_order](#input\_label\_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | | [label\_value\_case](#input\_label\_value\_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | | [labels\_as\_tags](#input\_labels\_as\_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | -| [lifecycle\_configuration\_rules](#input\_lifecycle\_configuration\_rules) | A list of lifecycle V2 rules |
list(object({
enabled = bool
id = string

abort_incomplete_multipart_upload_days = number

# `filter_and` is the `and` configuration block inside the `filter` configuration.
# This is the only place you should specify a prefix.
filter_and = any
expiration = any
transition = list(any)

noncurrent_version_expiration = any
noncurrent_version_transition = list(any)
}))
| `[]` | no | +| [lifecycle\_configuration\_rules](#input\_lifecycle\_configuration\_rules) | A list of lifecycle V2 rules |
list(object({
enabled = optional(bool, true)
id = string

abort_incomplete_multipart_upload_days = optional(number)

# `filter_and` is the `and` configuration block inside the `filter` configuration.
# This is the only place you should specify a prefix.
filter_and = optional(object({
object_size_greater_than = optional(number) # integer >= 0
object_size_less_than = optional(number) # integer >= 1
prefix = optional(string)
tags = optional(map(string), {})
}))
expiration = optional(object({
date = optional(string) # string, RFC3339 time format, GMT
days = optional(number) # integer > 0
expired_object_delete_marker = optional(bool)
}))
noncurrent_version_expiration = optional(object({
newer_noncurrent_versions = optional(number) # integer > 0
noncurrent_days = optional(number) # integer >= 0
}))
transition = optional(list(object({
date = optional(string) # string, RFC3339 time format, GMT
days = optional(number) # integer > 0
storage_class = optional(string)
# string/enum, one of GLACIER, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, DEEP_ARCHIVE, GLACIER_IR.
})), [])

noncurrent_version_transition = optional(list(object({
newer_noncurrent_versions = optional(number) # integer >= 0
noncurrent_days = optional(number) # integer >= 0
storage_class = optional(string)
# string/enum, one of GLACIER, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, DEEP_ARCHIVE, GLACIER_IR.
})), [])
}))
| `[]` | no | | [lifecycle\_rule\_ids](#input\_lifecycle\_rule\_ids) | DEPRECATED (use `lifecycle_configuration_rules`): A list of IDs to assign to corresponding `lifecycle_rules` | `list(string)` | `[]` | no | | [lifecycle\_rules](#input\_lifecycle\_rules) | DEPRECATED (`use lifecycle_configuration_rules`): A list of lifecycle rules |
list(object({
prefix = string
enabled = bool
tags = map(string)

enable_glacier_transition = bool
enable_deeparchive_transition = bool
enable_standard_ia_transition = bool
enable_current_object_expiration = bool
enable_noncurrent_version_expiration = bool

abort_incomplete_multipart_upload_days = number
noncurrent_version_glacier_transition_days = number
noncurrent_version_deeparchive_transition_days = number
noncurrent_version_expiration_days = number

standard_transition_days = number
glacier_transition_days = number
deeparchive_transition_days = number
expiration_days = number
}))
| `null` | no | -| [logging](#input\_logging) | Bucket access logging configuration. |
object({
bucket_name = string
prefix = string
})
| `null` | no | +| [logging](#input\_logging) | Bucket access logging configuration. Empty list for no logging, list of 1 to enable logging. |
list(object({
bucket_name = string
prefix = string
}))
| `[]` | no | | [name](#input\_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | | [namespace](#input\_namespace) | ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique | `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 | @@ -363,9 +339,9 @@ Available targets: | [s3\_replica\_bucket\_arn](#input\_s3\_replica\_bucket\_arn) | A single S3 bucket ARN to use for all replication rules.
Note: The destination bucket can be specified in the replication rule itself
(which allows for multiple destinations), in which case it will take precedence over this variable. | `string` | `""` | no | | [s3\_replication\_enabled](#input\_s3\_replication\_enabled) | Set this to true and specify `s3_replication_rules` to enable replication. `versioning_enabled` must also be `true`. | `bool` | `false` | no | | [s3\_replication\_permissions\_boundary\_arn](#input\_s3\_replication\_permissions\_boundary\_arn) | Permissions boundary ARN for the created IAM replication role. | `string` | `null` | no | -| [s3\_replication\_rules](#input\_s3\_replication\_rules) | Specifies the replication rules for S3 bucket replication if enabled. You must also set s3\_replication\_enabled to true. | `list(any)` | `null` | no | +| [s3\_replication\_rules](#input\_s3\_replication\_rules) | Specifies the replication rules for S3 bucket replication if enabled. You must also set s3\_replication\_enabled to true. |
list(object({
id = optional(string)
priority = optional(number)
prefix = optional(string)
status = optional(string, "Enabled")
# delete_marker_replication { status } had been flattened for convenience
delete_marker_replication_status = optional(string, "Disabled")
# Add the configuration as it appears in the resource, for consistency
# this nested version takes precedence if both are provided.
delete_marker_replication = optional(object({
status = string
}))

# destination_bucket is specified here rather than inside the destination object because before optional
# attributes, it made it easier to work with the Terraform type system and create a list of consistent type.
# It is preserved for backward compatibility, but the nested version takes priority if both are provided.
destination_bucket = optional(string) # destination bucket ARN, overrides s3_replica_bucket_arn

destination = object({
bucket = optional(string) # destination bucket ARN, overrides s3_replica_bucket_arn
storage_class = optional(string, "STANDARD")
# replica_kms_key_id at this level is for backward compatibility, and is overridden by the one in `encryption_configuration`
replica_kms_key_id = optional(string, "")
encryption_configuration = optional(object({
replica_kms_key_id = string
}))
access_control_translation = optional(object({
owner = string
}))
# account_id is for backward compatibility, overridden by account
account_id = optional(string)
account = optional(string)
# For convenience, specifying either metrics or replication_time enables both
metrics = optional(object({
event_threshold = optional(object({
minutes = optional(number, 15) # Currently 15 is the only valid number
}), { minutes = 15 })
status = optional(string, "Enabled")
}), { status = "Disabled" })
# To preserve backward compatibility, Replication Time Control (RTC) is automatically enabled
# when metrics are enabled. To enable metrics without RTC, you must explicitly configure
# replication_time.status = "Disabled".
replication_time = optional(object({
time = optional(object({
minutes = optional(number, 15) # Currently 15 is the only valid number
}), { minutes = 15 })
status = optional(string)
}))
})

source_selection_criteria = optional(object({
replica_modifications = optional(object({
status = string # Either Enabled or Disabled
}))
sse_kms_encrypted_objects = optional(object({
status = optional(string)
}))
}))
# filter.prefix overrides top level prefix
filter = optional(object({
prefix = optional(string)
tags = optional(map(string), {})
}))
}))
| `null` | no | | [s3\_replication\_source\_roles](#input\_s3\_replication\_source\_roles) | Cross-account IAM Role ARNs that will be allowed to perform S3 replication to this bucket (for replication within the same AWS account, it's not necessary to adjust the bucket policy). | `list(string)` | `[]` | no | -| [source\_policy\_documents](#input\_source\_policy\_documents) | List of IAM policy documents that are merged together into the exported document.
Statements defined in source\_policy\_documents or source\_json must have unique SIDs.
Statement having SIDs that match policy SIDs generated by this module will override them. | `list(string)` | `[]` | no | +| [source\_policy\_documents](#input\_source\_policy\_documents) | List of IAM policy documents (in JSON) that are merged together into the exported document.
Statements defined in source\_policy\_documents must have unique SIDs.
Statement having SIDs that match policy SIDs generated by this module will override them. | `list(string)` | `[]` | no | | [sse\_algorithm](#input\_sse\_algorithm) | The server-side encryption algorithm to use. Valid values are `AES256` and `aws:kms` | `string` | `"AES256"` | no | | [ssm\_base\_path](#input\_ssm\_base\_path) | The base path for SSM parameters where created IAM user's access key is stored | `string` | `"/s3_user/"` | no | | [stage](#input\_stage) | ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | @@ -394,7 +370,7 @@ Available targets: | [bucket\_website\_endpoint](#output\_bucket\_website\_endpoint) | The bucket website endpoint, if website is enabled | | [enabled](#output\_enabled) | Is module enabled | | [replication\_role\_arn](#output\_replication\_role\_arn) | The ARN of the replication IAM Role | -| [secret\_access\_key](#output\_secret\_access\_key) | The secret access key, if `var.user_enabled && var.access_key_enabled && !var.store_access_key_in_ssm`.
This will be written to the state file unencrypted, so using `store_access_key_in_ssm` is recommended" | +| [secret\_access\_key](#output\_secret\_access\_key) | The secret access key will be output if created and not stored in SSM. However, the secret access key, if created,
will be written to the Terraform state file unencrypted, regardless of any other settings.
See the [Terraform documentation](https://www.terraform.io/docs/state/sensitive-data.html) for more details. | | [secret\_access\_key\_ssm\_path](#output\_secret\_access\_key\_ssm\_path) | The SSM Path under which the S3 User's secret access key is stored | | [user\_arn](#output\_user\_arn) | The ARN assigned by AWS for the user | | [user\_enabled](#output\_user\_enabled) | Is user creation enabled | diff --git a/README.yaml b/README.yaml index 9ee33de2..1c6b0a42 100644 --- a/README.yaml +++ b/README.yaml @@ -69,18 +69,29 @@ description: |- This module creates an S3 bucket with support for versioning, lifecycles, object locks, replication, encryption, ACL, bucket object policies, and static website hosting. - If `user_enabled` variable is set to `true`, the module will provision a basic IAM user with permissions to access the bucket. - This basic IAM system user is suitable for CI/CD systems (_e.g._ TravisCI, CircleCI) or systems which are *external* to AWS that cannot leverage - [AWS IAM Instance Profiles](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2_instance-profiles.html) - or [AWS OIDC](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) to authenticate and - do not already have IAM credentials. Users or systems that have IAM credentials should either be granted access directly based on - their IAM identity via `privileged_principal_arns` or be allowed to assume an IAM role with access. - - We do not recommend creating IAM users this way for any other purpose. - + For backward compatibility, it sets the S3 bucket ACL to `private` and the `s3_object_ownership` + to `ObjectWriter`. Moving forward, setting `s3_object_ownership` to `BucketOwnerEnforced` is recommended, + and doing so automatically disables the ACL. + This module blocks public access to the bucket by default. See `block_public_acls`, `block_public_policy`, `ignore_public_acls`, and `restrict_public_buckets` to change the settings. See [AWS documentation](https://docs.aws.amazon.com/AmazonS3/latest/dev/access-control-block-public-access.html) - for more details. + for more details. + + This module can optionally create an IAM User with access to the S3 bucket. This is inherently insecure in that + to enable anyone to become the User, access keys must be generated, and anything generated by Terraform is stored + unencrypted in the Terraform state. See the [Terraform documentation](https://www.terraform.io/docs/state/sensitive-data.html) for more details + + The best way to grant access to the bucket is to grant one or more IAM Roles access to the bucket via `privileged_principal_arns`. + This IAM Role can be assumed by EC2 instances via their Instance Profile, or Kubernetes (EKS) services using + [IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html). + Entities outside of AWS can assume the Role via [OIDC](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html). + (See [this example of connecting GitHub](https://aws.amazon.com/blogs/security/use-iam-roles-to-connect-github-actions-to-actions-in-aws/) + to enable GitHub actions to assume AWS IAM roles, or use [this Cloud Posse component](https://github.com/cloudposse/terraform-aws-components/tree/main/modules/github-oidc-provider) + if you are already using the Cloud Posse reference architecture.) + + If neither of those approaches work, then as a last resort you can set `user_enabled = true` and + this module will provision a basic IAM user with permissions to access the bucket. + We do not recommend creating IAM users this way for any other purpose. If an IAM user is created, the IAM user name is constructed using [terraform-null-label](https://github.com/cloudposse/terraform-null-label) and some input is required. The simplest input is `name`. By default the name will be converted to lower case @@ -88,12 +99,12 @@ description: |- to learn how to override these defaults if desired. If an AWS Access Key is created, it is stored either in SSM Parameter Store or is provided as a module output, - but not both. Using SSM Parameter Store is recommended because module outputs are stored in plaintext in - the Terraform state file. + but not both. Using SSM Parameter Store is recommended because that will keep the secret from being easily accessible + via Terraform remote state lookup, but the key will still be stored unencrypted in the Terraform state in any case. # How to use this project usage: |- - Using a [canned ACL](https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html). + Using [BucketOwnerEnforced](https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html#object-ownership-overview) ```hcl module "s3_bucket" { @@ -104,7 +115,7 @@ usage: |- stage = "test" namespace = "eg" - acl = "private" + s3_object_ownership = "BucketOwnerEnforced" enabled = true user_enabled = false versioning_enabled = false @@ -156,41 +167,6 @@ usage: |- } ``` - Using [grants](https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html) to enable access - to another account and for logging, and incorporating the above lifecycle configuration. - - ```hcl - module "s3_bucket" { - source = "cloudposse/s3-bucket/aws" - # Cloud Posse recommends pinning every module to a specific version - # version = "x.x.x" - name = "app" - stage = "test" - namespace = "eg" - - acl = "" - enabled = true - user_enabled = false - versioning_enabled = true - - lifecycle_configuration_rules = local.lifecycle_configuration_rules - - grants = [ - { - id = "012abc345def678ghi901" # Canonical user or account id - type = "CanonicalUser" - permissions = ["FULL_CONTROL"] - uri = null - }, - { - id = null - type = "Group" - permissions = ["READ", "WRITE"] - uri = "http://acs.amazonaws.com/groups/s3/LogDelivery" - }, - ] - } - ``` Allowing specific principal ARNs to perform actions on the bucket: @@ -199,7 +175,7 @@ usage: |- source = "cloudposse/s3-bucket/aws" # Cloud Posse recommends pinning every module to a specific version # version = "x.x.x" - acl = "private" + s3_object_ownership = "BucketOwnerEnforced" enabled = true user_enabled = true versioning_enabled = false diff --git a/docs/terraform.md b/docs/terraform.md index 01359f0c..3ff1e53a 100644 --- a/docs/terraform.md +++ b/docs/terraform.md @@ -3,7 +3,7 @@ | Name | Version | |------|---------| -| [terraform](#requirement\_terraform) | >= 1.0 | +| [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 4.9.0 | | [time](#requirement\_time) | >= 0.7 | @@ -64,10 +64,10 @@ | [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | | [block\_public\_acls](#input\_block\_public\_acls) | Set to `false` to disable the blocking of new public access lists on the bucket | `bool` | `true` | no | | [block\_public\_policy](#input\_block\_public\_policy) | Set to `false` to disable the blocking of new public policies on the bucket | `bool` | `true` | no | -| [bucket\_key\_enabled](#input\_bucket\_key\_enabled) | Set this to true to use Amazon S3 Bucket Keys for SSE-KMS, which may reduce the number of AWS KMS requests.
For more information, see: https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-key.html | `bool` | `false` | no | +| [bucket\_key\_enabled](#input\_bucket\_key\_enabled) | Set this to true to use Amazon S3 Bucket Keys for SSE-KMS, which may or may not reduce the number of AWS KMS requests.
For more information, see: https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-key.html | `bool` | `false` | no | | [bucket\_name](#input\_bucket\_name) | Bucket name. If provided, the bucket will be created with this name instead of generating the name from the context | `string` | `null` | no | | [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"namespace": null,
"regex_replace_chars": null,
"stage": null,
"tags": {},
"tenant": null
}
| no | -| [cors\_configuration](#input\_cors\_configuration) | Specifies the allowed headers, methods, origins and exposed headers when using CORS on this bucket |
list(object({
allowed_headers = list(string)
allowed_methods = list(string)
allowed_origins = list(string)
expose_headers = list(string)
max_age_seconds = number
}))
| `[]` | no | +| [cors\_configuration](#input\_cors\_configuration) | Specifies the allowed headers, methods, origins and exposed headers when using CORS on this bucket |
list(object({
id = optional(string)
allowed_headers = optional(list(string))
allowed_methods = optional(list(string))
allowed_origins = optional(list(string))
expose_headers = optional(list(string))
max_age_seconds = optional(number)
}))
| `[]` | no | | [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | | [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | @@ -81,10 +81,10 @@ | [label\_order](#input\_label\_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | | [label\_value\_case](#input\_label\_value\_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | | [labels\_as\_tags](#input\_labels\_as\_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | -| [lifecycle\_configuration\_rules](#input\_lifecycle\_configuration\_rules) | A list of lifecycle V2 rules |
list(object({
enabled = bool
id = string

abort_incomplete_multipart_upload_days = number

# `filter_and` is the `and` configuration block inside the `filter` configuration.
# This is the only place you should specify a prefix.
filter_and = any
expiration = any
transition = list(any)

noncurrent_version_expiration = any
noncurrent_version_transition = list(any)
}))
| `[]` | no | +| [lifecycle\_configuration\_rules](#input\_lifecycle\_configuration\_rules) | A list of lifecycle V2 rules |
list(object({
enabled = optional(bool, true)
id = string

abort_incomplete_multipart_upload_days = optional(number)

# `filter_and` is the `and` configuration block inside the `filter` configuration.
# This is the only place you should specify a prefix.
filter_and = optional(object({
object_size_greater_than = optional(number) # integer >= 0
object_size_less_than = optional(number) # integer >= 1
prefix = optional(string)
tags = optional(map(string), {})
}))
expiration = optional(object({
date = optional(string) # string, RFC3339 time format, GMT
days = optional(number) # integer > 0
expired_object_delete_marker = optional(bool)
}))
noncurrent_version_expiration = optional(object({
newer_noncurrent_versions = optional(number) # integer > 0
noncurrent_days = optional(number) # integer >= 0
}))
transition = optional(list(object({
date = optional(string) # string, RFC3339 time format, GMT
days = optional(number) # integer > 0
storage_class = optional(string)
# string/enum, one of GLACIER, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, DEEP_ARCHIVE, GLACIER_IR.
})), [])

noncurrent_version_transition = optional(list(object({
newer_noncurrent_versions = optional(number) # integer >= 0
noncurrent_days = optional(number) # integer >= 0
storage_class = optional(string)
# string/enum, one of GLACIER, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, DEEP_ARCHIVE, GLACIER_IR.
})), [])
}))
| `[]` | no | | [lifecycle\_rule\_ids](#input\_lifecycle\_rule\_ids) | DEPRECATED (use `lifecycle_configuration_rules`): A list of IDs to assign to corresponding `lifecycle_rules` | `list(string)` | `[]` | no | | [lifecycle\_rules](#input\_lifecycle\_rules) | DEPRECATED (`use lifecycle_configuration_rules`): A list of lifecycle rules |
list(object({
prefix = string
enabled = bool
tags = map(string)

enable_glacier_transition = bool
enable_deeparchive_transition = bool
enable_standard_ia_transition = bool
enable_current_object_expiration = bool
enable_noncurrent_version_expiration = bool

abort_incomplete_multipart_upload_days = number
noncurrent_version_glacier_transition_days = number
noncurrent_version_deeparchive_transition_days = number
noncurrent_version_expiration_days = number

standard_transition_days = number
glacier_transition_days = number
deeparchive_transition_days = number
expiration_days = number
}))
| `null` | no | -| [logging](#input\_logging) | Bucket access logging configuration. |
object({
bucket_name = string
prefix = string
})
| `null` | no | +| [logging](#input\_logging) | Bucket access logging configuration. Empty list for no logging, list of 1 to enable logging. |
list(object({
bucket_name = string
prefix = string
}))
| `[]` | no | | [name](#input\_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | | [namespace](#input\_namespace) | ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique | `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 | @@ -97,9 +97,9 @@ | [s3\_replica\_bucket\_arn](#input\_s3\_replica\_bucket\_arn) | A single S3 bucket ARN to use for all replication rules.
Note: The destination bucket can be specified in the replication rule itself
(which allows for multiple destinations), in which case it will take precedence over this variable. | `string` | `""` | no | | [s3\_replication\_enabled](#input\_s3\_replication\_enabled) | Set this to true and specify `s3_replication_rules` to enable replication. `versioning_enabled` must also be `true`. | `bool` | `false` | no | | [s3\_replication\_permissions\_boundary\_arn](#input\_s3\_replication\_permissions\_boundary\_arn) | Permissions boundary ARN for the created IAM replication role. | `string` | `null` | no | -| [s3\_replication\_rules](#input\_s3\_replication\_rules) | Specifies the replication rules for S3 bucket replication if enabled. You must also set s3\_replication\_enabled to true. | `list(any)` | `null` | no | +| [s3\_replication\_rules](#input\_s3\_replication\_rules) | Specifies the replication rules for S3 bucket replication if enabled. You must also set s3\_replication\_enabled to true. |
list(object({
id = optional(string)
priority = optional(number)
prefix = optional(string)
status = optional(string, "Enabled")
# delete_marker_replication { status } had been flattened for convenience
delete_marker_replication_status = optional(string, "Disabled")
# Add the configuration as it appears in the resource, for consistency
# this nested version takes precedence if both are provided.
delete_marker_replication = optional(object({
status = string
}))

# destination_bucket is specified here rather than inside the destination object because before optional
# attributes, it made it easier to work with the Terraform type system and create a list of consistent type.
# It is preserved for backward compatibility, but the nested version takes priority if both are provided.
destination_bucket = optional(string) # destination bucket ARN, overrides s3_replica_bucket_arn

destination = object({
bucket = optional(string) # destination bucket ARN, overrides s3_replica_bucket_arn
storage_class = optional(string, "STANDARD")
# replica_kms_key_id at this level is for backward compatibility, and is overridden by the one in `encryption_configuration`
replica_kms_key_id = optional(string, "")
encryption_configuration = optional(object({
replica_kms_key_id = string
}))
access_control_translation = optional(object({
owner = string
}))
# account_id is for backward compatibility, overridden by account
account_id = optional(string)
account = optional(string)
# For convenience, specifying either metrics or replication_time enables both
metrics = optional(object({
event_threshold = optional(object({
minutes = optional(number, 15) # Currently 15 is the only valid number
}), { minutes = 15 })
status = optional(string, "Enabled")
}), { status = "Disabled" })
# To preserve backward compatibility, Replication Time Control (RTC) is automatically enabled
# when metrics are enabled. To enable metrics without RTC, you must explicitly configure
# replication_time.status = "Disabled".
replication_time = optional(object({
time = optional(object({
minutes = optional(number, 15) # Currently 15 is the only valid number
}), { minutes = 15 })
status = optional(string)
}))
})

source_selection_criteria = optional(object({
replica_modifications = optional(object({
status = string # Either Enabled or Disabled
}))
sse_kms_encrypted_objects = optional(object({
status = optional(string)
}))
}))
# filter.prefix overrides top level prefix
filter = optional(object({
prefix = optional(string)
tags = optional(map(string), {})
}))
}))
| `null` | no | | [s3\_replication\_source\_roles](#input\_s3\_replication\_source\_roles) | Cross-account IAM Role ARNs that will be allowed to perform S3 replication to this bucket (for replication within the same AWS account, it's not necessary to adjust the bucket policy). | `list(string)` | `[]` | no | -| [source\_policy\_documents](#input\_source\_policy\_documents) | List of IAM policy documents that are merged together into the exported document.
Statements defined in source\_policy\_documents or source\_json must have unique SIDs.
Statement having SIDs that match policy SIDs generated by this module will override them. | `list(string)` | `[]` | no | +| [source\_policy\_documents](#input\_source\_policy\_documents) | List of IAM policy documents (in JSON) that are merged together into the exported document.
Statements defined in source\_policy\_documents must have unique SIDs.
Statement having SIDs that match policy SIDs generated by this module will override them. | `list(string)` | `[]` | no | | [sse\_algorithm](#input\_sse\_algorithm) | The server-side encryption algorithm to use. Valid values are `AES256` and `aws:kms` | `string` | `"AES256"` | no | | [ssm\_base\_path](#input\_ssm\_base\_path) | The base path for SSM parameters where created IAM user's access key is stored | `string` | `"/s3_user/"` | no | | [stage](#input\_stage) | ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | @@ -128,7 +128,7 @@ | [bucket\_website\_endpoint](#output\_bucket\_website\_endpoint) | The bucket website endpoint, if website is enabled | | [enabled](#output\_enabled) | Is module enabled | | [replication\_role\_arn](#output\_replication\_role\_arn) | The ARN of the replication IAM Role | -| [secret\_access\_key](#output\_secret\_access\_key) | The secret access key, if `var.user_enabled && var.access_key_enabled && !var.store_access_key_in_ssm`.
This will be written to the state file unencrypted, so using `store_access_key_in_ssm` is recommended" | +| [secret\_access\_key](#output\_secret\_access\_key) | The secret access key will be output if created and not stored in SSM. However, the secret access key, if created,
will be written to the Terraform state file unencrypted, regardless of any other settings.
See the [Terraform documentation](https://www.terraform.io/docs/state/sensitive-data.html) for more details. | | [secret\_access\_key\_ssm\_path](#output\_secret\_access\_key\_ssm\_path) | The SSM Path under which the S3 User's secret access key is stored | | [user\_arn](#output\_user\_arn) | The ARN assigned by AWS for the user | | [user\_enabled](#output\_user\_enabled) | Is user creation enabled | diff --git a/examples/complete/versions.tf b/examples/complete/versions.tf index 011c9d4f..375eb585 100644 --- a/examples/complete/versions.tf +++ b/examples/complete/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.0" + required_version = ">= 1.3.0" required_providers { aws = { diff --git a/lifecycle.tf b/lifecycle.tf index b3882cd3..90918fc3 100644 --- a/lifecycle.tf +++ b/lifecycle.tf @@ -1,4 +1,5 @@ locals { + /* # full_lifecycle_rule_schema is just for documentation, not actually used. full_lifecycle_rule_schema = { enabled = true # bool @@ -23,7 +24,7 @@ locals { } transition = [{ date = null # string - days = null # integer >= 0 + days = null # integer > 0 storage_class = null # string/enum, one of GLACIER, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, DEEP_ARCHIVE, GLACIER_IR. }] noncurrent_version_transition = [{ @@ -32,6 +33,7 @@ locals { storage_class = null # string/enum, one of GLACIER, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, DEEP_ARCHIVE, GLACIER_IR. }] } + */ lifecycle_configuration_rules = var.lifecycle_configuration_rules == null ? [] : var.lifecycle_configuration_rules # Normalize the input, filling in missing fields @@ -156,7 +158,7 @@ locals { resource "aws_s3_bucket_lifecycle_configuration" "default" { count = local.enabled && length(local.lc_rules) > 0 ? 1 : 0 - bucket = join("", aws_s3_bucket.default[*].id) + bucket = local.bucket_id dynamic "rule" { for_each = local.lc_rules diff --git a/main.tf b/main.tf index ae27936e..c6e14132 100644 --- a/main.tf +++ b/main.tf @@ -7,8 +7,12 @@ locals { versioning_enabled = local.enabled && var.versioning_enabled transfer_acceleration_enabled = local.enabled && var.transfer_acceleration_enabled + # Remember, everything has to work with enabled == false, + # so we cannot use coalesce() because it errors if all its arguments are empty, + # and we cannot use one() because it returns null, which does not work in templates and lists. bucket_name = var.bucket_name != null && var.bucket_name != "" ? var.bucket_name : module.this.id - bucket_arn = "arn:${local.partition}:s3:::${join("", aws_s3_bucket.default[*].id)}" + bucket_id = join("", aws_s3_bucket.default[*].id) + bucket_arn = "arn:${local.partition}:s3:::${local.bucket_id}" acl_grants = var.grants == null ? [] : flatten( [ @@ -27,13 +31,6 @@ data "aws_partition" "current" { count = local.enabled ? 1 : 0 } data "aws_canonical_user_id" "default" { count = local.enabled ? 1 : 0 } resource "aws_s3_bucket" "default" { - # The following Bridgecrew rules are suppressed by Cloud Posse when analyzing this module with default inputs - # BC_AWS_S3_13:Skipping `Enable S3 Bucket Logging` because some buckets, like buckets receiving logs, do not need logging - # CKV_AWS_52:Skipping `Ensure S3 bucket has MFA delete enabled` due to issue in terraform (https://github.com/hashicorp/terraform-provider-aws/issues/629). - # BC_AWS_S3_16:Skipping `Ensure S3 bucket versioning is enabled` because this is often not required or even helpful - # BC_AWS_S3_14:Skipping `Ensure all data stored in the S3 bucket is securely encrypted at rest` because that is now enforced automatically by AWS - # BC_AWS_GENERAL_56:Skipping `Ensure that S3 buckets are encrypted with KMS by default` because we do not agree that this is required - # BC_AWS_GENERAL_72:We do not agree that cross-region replication must be enabled count = local.enabled ? 1 : 0 bucket = local.bucket_name force_destroy = var.force_destroy @@ -46,7 +43,7 @@ resource "aws_s3_bucket" "default" { resource "aws_s3_bucket_accelerate_configuration" "default" { count = local.transfer_acceleration_enabled ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id status = "Enabled" } @@ -54,7 +51,7 @@ resource "aws_s3_bucket_accelerate_configuration" "default" { resource "aws_s3_bucket_versioning" "default" { count = local.enabled ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id versioning_configuration { status = local.versioning_enabled ? "Enabled" : "Suspended" @@ -62,12 +59,12 @@ resource "aws_s3_bucket_versioning" "default" { } resource "aws_s3_bucket_logging" "default" { - count = local.enabled && var.logging != null ? 1 : 0 + count = local.enabled && try(length(var.logging), 0) > 0 ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id - target_bucket = var.logging["bucket_name"] - target_prefix = var.logging["prefix"] + target_bucket = var.logging[0]["bucket_name"] + target_prefix = var.logging[0]["prefix"] } # https://docs.aws.amazon.com/AmazonS3/latest/dev/bucket-encryption.html @@ -75,7 +72,7 @@ resource "aws_s3_bucket_logging" "default" { resource "aws_s3_bucket_server_side_encryption_configuration" "default" { count = local.enabled ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id rule { bucket_key_enabled = var.bucket_key_enabled @@ -90,7 +87,7 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "default" { resource "aws_s3_bucket_website_configuration" "default" { count = local.enabled && (try(length(var.website_configuration), 0) > 0) ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id dynamic "index_document" { for_each = try(length(var.website_configuration[0].index_document), 0) > 0 ? [true] : [] @@ -134,7 +131,7 @@ resource "aws_s3_bucket_website_configuration" "default" { resource "aws_s3_bucket_website_configuration" "redirect" { count = local.enabled && (try(length(var.website_redirect_all_requests_to), 0) > 0) ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id redirect_all_requests_to { host_name = var.website_redirect_all_requests_to[0].host_name @@ -145,12 +142,13 @@ resource "aws_s3_bucket_website_configuration" "redirect" { resource "aws_s3_bucket_cors_configuration" "default" { count = local.enabled && try(length(var.cors_configuration), 0) > 0 ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id dynamic "cors_rule" { for_each = var.cors_configuration content { + id = cors_rule.value.id allowed_headers = cors_rule.value.allowed_headers allowed_methods = cors_rule.value.allowed_methods allowed_origins = cors_rule.value.allowed_origins @@ -163,7 +161,7 @@ resource "aws_s3_bucket_cors_configuration" "default" { resource "aws_s3_bucket_acl" "default" { count = local.enabled && var.s3_object_ownership != "BucketOwnerEnforced" ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id # Conflicts with access_control_policy so this is enabled if no grants acl = try(length(local.acl_grants), 0) == 0 ? var.acl : null @@ -196,7 +194,7 @@ resource "aws_s3_bucket_acl" "default" { resource "aws_s3_bucket_replication_configuration" "default" { count = local.replication_enabled ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id role = aws_iam_role.replication[0].arn dynamic "rule" { @@ -214,47 +212,58 @@ resource "aws_s3_bucket_replication_configuration" "default" { # This is only relevant when "filter" is used delete_marker_replication { - status = try(rule.value.delete_marker_replication_status, "Disabled") + status = try(rule.value.delete_marker_replication.status, try(rule.value.delete_marker_replication_status, "Disabled")) } destination { # Prefer newer system of specifying bucket in rule, but maintain backward compatibility with # s3_replica_bucket_arn to specify single destination for all rules - bucket = try(length(rule.value.destination_bucket), 0) > 0 ? rule.value.destination_bucket : var.s3_replica_bucket_arn - storage_class = try(rule.value.destination.storage_class, "STANDARD") + bucket = coalesce(rule.value.destination.bucket, rule.value.destination_bucket, var.s3_replica_bucket_arn) + storage_class = rule.value.destination.storage_class dynamic "encryption_configuration" { - for_each = try(rule.value.destination.replica_kms_key_id, null) != null ? [1] : [] + for_each = try(compact(concat( + [try(rule.value.destination.encryption_configuration.replica_kms_key_id, "")], + [try(rule.value.destination.replica_kms_key_id, "")] + ))[0], []) content { - replica_kms_key_id = try(rule.value.destination.replica_kms_key_id, null) + replica_kms_key_id = encryption_configuration } } - account = try(rule.value.destination.account_id, null) + account = try(coalesce(rule.value.destination.account, rule.value.destination.account_id), null) - # https://docs.aws.amazon.com/AmazonS3/latest/userguide/replication-walkthrough-5.html dynamic "metrics" { - for_each = try(rule.value.destination.metrics.status, "") == "Enabled" ? [1] : [] + # Metrics are required if Replication Time Control is enabled, so automatically enable them + for_each = ( + try(rule.value.destination.metrics.status, "") == "Enabled" || + try(rule.value.destination.replication_time.status, "") == "Enabled" + ) ? [1] : [] content { status = "Enabled" event_threshold { - # Minutes can only have 15 as a valid value. - minutes = 15 + # Minutes can only have 15 as a valid value, but we allow it to be configured anyway + minutes = coalesce(try(rule.value.destination.metrics.event_threshold.minutes, null), 15) } } } - # This block is required when replication metrics are enabled. + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/replication-walkthrough-5.html dynamic "replication_time" { - for_each = try(rule.value.destination.metrics.status, "") == "Enabled" ? [1] : [] + for_each = ( + # Preserving the old behavior of this module: if metrics are enabled, + # replication is automatically enabled unless explicitly disabled. + (try(rule.value.destination.metrics.status, "") == "Enabled" && !(try(rule.value.destination.replication_time.status, "") == "Disabled")) || + try(rule.value.destination.replication_time.status, "") == "Enabled" + ) ? [1] : [] content { status = "Enabled" time { - # Minutes can only have 15 as a valid value. - minutes = 15 + # Minutes can only have 15 as a valid value, but we allow it to be configured anyway + minutes = coalesce(try(rule.value.destination.replication_time.time.minutes, null), 15) } } } @@ -269,11 +278,14 @@ resource "aws_s3_bucket_replication_configuration" "default" { } dynamic "source_selection_criteria" { - for_each = try(rule.value.source_selection_criteria.sse_kms_encrypted_objects.enabled, null) == null ? [] : [rule.value.source_selection_criteria.sse_kms_encrypted_objects.enabled] + for_each = try(rule.value.source_selection_criteria, null) == null ? [] : [rule.value.source_selection_criteria] content { + replica_modifications { + status = try(source_selection_criteria.value.replica_modifications.status, null) + } sse_kms_encrypted_objects { - status = source_selection_criteria.value + status = try(source_selection_criteria.value.sse_kms_encrypted_objects.status, null) } } } @@ -308,7 +320,7 @@ resource "aws_s3_bucket_replication_configuration" "default" { resource "aws_s3_bucket_object_lock_configuration" "default" { count = local.object_lock_enabled ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id object_lock_enabled = "Enabled" @@ -447,8 +459,8 @@ data "aws_iam_policy_document" "bucket_policy" { sid = "AllowPrivilegedPrincipal[${statement.key}]" # add indices to Sid actions = var.privileged_principal_actions resources = distinct(flatten([ - "arn:${local.partition}:s3:::${join("", aws_s3_bucket.default[*].id)}", - formatlist("arn:${local.partition}:s3:::${join("", aws_s3_bucket.default[*].id)}/%s*", values(statement.value)[0]), + "arn:${local.partition}:s3:::${local.bucket_id}", + formatlist("arn:${local.partition}:s3:::${local.bucket_id}/%s*", values(statement.value)[0]), ])) principals { type = "AWS" @@ -474,7 +486,7 @@ resource "aws_s3_bucket_policy" "default" { length(var.source_policy_documents) > 0 ) ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id policy = one(data.aws_iam_policy_document.aggregated_policy[*].json) depends_on = [aws_s3_bucket_public_access_block.default] } @@ -485,7 +497,7 @@ resource "aws_s3_bucket_policy" "default" { resource "aws_s3_bucket_public_access_block" "default" { count = module.this.enabled ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id block_public_acls = var.block_public_acls block_public_policy = var.block_public_policy @@ -497,7 +509,7 @@ resource "aws_s3_bucket_public_access_block" "default" { resource "aws_s3_bucket_ownership_controls" "default" { count = local.enabled ? 1 : 0 - bucket = one(aws_s3_bucket.default[*].id) + bucket = local.bucket_id rule { object_ownership = var.s3_object_ownership diff --git a/outputs.tf b/outputs.tf index fad7cdd2..88ab0f0d 100644 --- a/outputs.tf +++ b/outputs.tf @@ -19,7 +19,7 @@ output "bucket_website_endpoint" { } output "bucket_id" { - value = local.enabled ? join("", aws_s3_bucket.default[*].id) : "" + value = local.enabled ? local.bucket_id : "" description = "Bucket Name (aka ID)" } @@ -76,8 +76,9 @@ output "secret_access_key" { sensitive = true value = module.s3_user.secret_access_key description = <<-EOT - The secret access key, if `var.user_enabled && var.access_key_enabled && !var.store_access_key_in_ssm`. - This will be written to the state file unencrypted, so using `store_access_key_in_ssm` is recommended" + The secret access key will be output if created and not stored in SSM. However, the secret access key, if created, + will be written to the Terraform state file unencrypted, regardless of any other settings. + See the [Terraform documentation](https://www.terraform.io/docs/state/sensitive-data.html) for more details. EOT } diff --git a/variables.tf b/variables.tf index 3fc20b9e..f9511615 100644 --- a/variables.tf +++ b/variables.tf @@ -24,16 +24,18 @@ variable "grants" { Deprecated by AWS in favor of bucket policies. Automatically disabled if `s3_object_ownership` is set to "BucketOwnerEnforced". EOT + nullable = false } variable "source_policy_documents" { type = list(string) default = [] description = <<-EOT - List of IAM policy documents that are merged together into the exported document. - Statements defined in source_policy_documents or source_json must have unique SIDs. + List of IAM policy documents (in JSON) that are merged together into the exported document. + Statements defined in source_policy_documents must have unique SIDs. Statement having SIDs that match policy SIDs generated by this module will override them. EOT + nullable = false } variable "force_destroy" { @@ -43,39 +45,45 @@ variable "force_destroy" { When `true`, permits a non-empty S3 bucket to be deleted by first deleting all objects in the bucket. THESE OBJECTS ARE NOT RECOVERABLE even if they were versioned and stored in Glacier. EOT + nullable = false } variable "versioning_enabled" { type = bool default = true description = "A state of versioning. Versioning is a means of keeping multiple variants of an object in the same bucket" + nullable = false } variable "logging" { - type = object({ + type = list(object({ bucket_name = string prefix = string - }) - default = null - description = "Bucket access logging configuration." + })) + default = [] + description = "Bucket access logging configuration. Empty list for no logging, list of 1 to enable logging." + nullable = false } variable "sse_algorithm" { type = string default = "AES256" description = "The server-side encryption algorithm to use. Valid values are `AES256` and `aws:kms`" + nullable = false } variable "kms_master_key_arn" { type = string default = "" description = "The AWS KMS master key ARN used for the `SSE-KMS` encryption. This can only be used when you set the value of `sse_algorithm` as `aws:kms`. The default aws/s3 AWS KMS master key is used if this element is absent while the `sse_algorithm` is `aws:kms`" + nullable = false } variable "user_enabled" { type = bool default = false description = "Set to `true` to create an IAM user with permission to access the bucket" + nullable = false } variable "user_permissions_boundary_arn" { @@ -88,6 +96,7 @@ variable "access_key_enabled" { type = bool default = true description = "Set to `true` to create an IAM Access Key for the created IAM user" + nullable = false } variable "store_access_key_in_ssm" { @@ -99,132 +108,129 @@ variable "store_access_key_in_ssm" { Since Terraform state would contain the secrets in plaintext, use of SSM Parameter Store is recommended. EOT + nullable = false } variable "ssm_base_path" { type = string description = "The base path for SSM parameters where created IAM user's access key is stored" default = "/s3_user/" + nullable = false } variable "allowed_bucket_actions" { type = list(string) default = ["s3:PutObject", "s3:PutObjectAcl", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket", "s3:ListBucketMultipartUploads", "s3:GetBucketLocation", "s3:AbortMultipartUpload"] description = "List of actions the user is permitted to perform on the S3 bucket" + nullable = false } variable "allow_encrypted_uploads_only" { type = bool default = false description = "Set to `true` to prevent uploads of unencrypted objects to S3 bucket" + nullable = false } variable "allow_ssl_requests_only" { type = bool default = false description = "Set to `true` to require requests to use Secure Socket Layer (HTTPS/SSL). This will explicitly deny access to HTTP requests" + nullable = false } -/* -Schema for lifecycle_configuration_rules -{ - enabled = true # bool - id = string - - abort_incomplete_multipart_upload_days = null # number - - filter_and = { - object_size_greater_than = null # integer >= 0 - object_size_less_than = null # integer >= 1 - prefix = null # string - tags = {} # map(string) - } - expiration = { - date = null # string, RFC3339 time format, GMT - days = null # integer > 0 - expired_object_delete_marker = null # bool - } - noncurrent_version_expiration = { - newer_noncurrent_versions = null # integer > 0 - noncurrent_days = null # integer >= 0 - } - transition = [{ - date = null # string, RFC3339 time format, GMT - days = null # integer >= 0 - storage_class = null # string/enum, one of GLACIER, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, DEEP_ARCHIVE, GLACIER_IR. - }] - noncurrent_version_transition = [{ - newer_noncurrent_versions = null # integer >= 0 - noncurrent_days = null # integer >= 0 - storage_class = null # string/enum, one of GLACIER, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, DEEP_ARCHIVE, GLACIER_IR. - }] -} - -We only partly specify the object to allow for compatible future extension. -*/ - variable "lifecycle_configuration_rules" { type = list(object({ - enabled = bool + enabled = optional(bool, true) id = string - abort_incomplete_multipart_upload_days = number + abort_incomplete_multipart_upload_days = optional(number) # `filter_and` is the `and` configuration block inside the `filter` configuration. # This is the only place you should specify a prefix. - filter_and = any - expiration = any - transition = list(any) - - noncurrent_version_expiration = any - noncurrent_version_transition = list(any) + filter_and = optional(object({ + object_size_greater_than = optional(number) # integer >= 0 + object_size_less_than = optional(number) # integer >= 1 + prefix = optional(string) + tags = optional(map(string), {}) + })) + expiration = optional(object({ + date = optional(string) # string, RFC3339 time format, GMT + days = optional(number) # integer > 0 + expired_object_delete_marker = optional(bool) + })) + noncurrent_version_expiration = optional(object({ + newer_noncurrent_versions = optional(number) # integer > 0 + noncurrent_days = optional(number) # integer >= 0 + })) + transition = optional(list(object({ + date = optional(string) # string, RFC3339 time format, GMT + days = optional(number) # integer > 0 + storage_class = optional(string) + # string/enum, one of GLACIER, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, DEEP_ARCHIVE, GLACIER_IR. + })), []) + + noncurrent_version_transition = optional(list(object({ + newer_noncurrent_versions = optional(number) # integer >= 0 + noncurrent_days = optional(number) # integer >= 0 + storage_class = optional(string) + # string/enum, one of GLACIER, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, DEEP_ARCHIVE, GLACIER_IR. + })), []) })) default = [] description = "A list of lifecycle V2 rules" + nullable = false } -# See lifecycle.tf for conversion of deprecated `lifecyle_rules` to `lifecycle_configuration_rules` +# See lifecycle.tf for conversion of deprecated `lifecycle_rules` to `lifecycle_configuration_rules` variable "cors_configuration" { type = list(object({ - allowed_headers = list(string) - allowed_methods = list(string) - allowed_origins = list(string) - expose_headers = list(string) - max_age_seconds = number + id = optional(string) + allowed_headers = optional(list(string)) + allowed_methods = optional(list(string)) + allowed_origins = optional(list(string)) + expose_headers = optional(list(string)) + max_age_seconds = optional(number) })) description = "Specifies the allowed headers, methods, origins and exposed headers when using CORS on this bucket" default = [] + nullable = false } variable "block_public_acls" { type = bool default = true description = "Set to `false` to disable the blocking of new public access lists on the bucket" + nullable = false } variable "block_public_policy" { type = bool default = true description = "Set to `false` to disable the blocking of new public policies on the bucket" + nullable = false } variable "ignore_public_acls" { type = bool default = true description = "Set to `false` to disable the ignoring of public access lists on the bucket" + nullable = false } variable "restrict_public_buckets" { type = bool default = true description = "Set to `false` to disable the restricting of making the bucket public" + nullable = false } variable "s3_replication_enabled" { type = bool default = false description = "Set this to true and specify `s3_replication_rules` to enable replication. `versioning_enabled` must also be `true`." + nullable = false } variable "s3_replica_bucket_arn" { @@ -238,40 +244,70 @@ variable "s3_replica_bucket_arn" { } variable "s3_replication_rules" { - # type = list(object({ - # id = string - # priority = number - # prefix = string - # status = string - # delete_marker_replication_status = string - # # destination_bucket is specified here rather than inside the destination object - # # to make it easier to work with the Terraform type system and create a list of consistent type. - # destination_bucket = string # destination bucket ARN, overrides s3_replica_bucket_arn - # - # destination = object({ - # storage_class = string - # replica_kms_key_id = string - # access_control_translation = object({ - # owner = string - # }) - # account_id = string - # metrics = object({ - # status = string - # }) - # }) - # source_selection_criteria = object({ - # sse_kms_encrypted_objects = object({ - # enabled = bool - # }) - # }) - # # filter.prefix overrides top level prefix - # filter = object({ - # prefix = string - # tags = map(string) - # }) - # })) - - type = list(any) + type = list(object({ + id = optional(string) + priority = optional(number) + prefix = optional(string) + status = optional(string, "Enabled") + # delete_marker_replication { status } had been flattened for convenience + delete_marker_replication_status = optional(string, "Disabled") + # Add the configuration as it appears in the resource, for consistency + # this nested version takes precedence if both are provided. + delete_marker_replication = optional(object({ + status = string + })) + + # destination_bucket is specified here rather than inside the destination object because before optional + # attributes, it made it easier to work with the Terraform type system and create a list of consistent type. + # It is preserved for backward compatibility, but the nested version takes priority if both are provided. + destination_bucket = optional(string) # destination bucket ARN, overrides s3_replica_bucket_arn + + destination = object({ + bucket = optional(string) # destination bucket ARN, overrides s3_replica_bucket_arn + storage_class = optional(string, "STANDARD") + # replica_kms_key_id at this level is for backward compatibility, and is overridden by the one in `encryption_configuration` + replica_kms_key_id = optional(string, "") + encryption_configuration = optional(object({ + replica_kms_key_id = string + })) + access_control_translation = optional(object({ + owner = string + })) + # account_id is for backward compatibility, overridden by account + account_id = optional(string) + account = optional(string) + # For convenience, specifying either metrics or replication_time enables both + metrics = optional(object({ + event_threshold = optional(object({ + minutes = optional(number, 15) # Currently 15 is the only valid number + }), { minutes = 15 }) + status = optional(string, "Enabled") + }), { status = "Disabled" }) + # To preserve backward compatibility, Replication Time Control (RTC) is automatically enabled + # when metrics are enabled. To enable metrics without RTC, you must explicitly configure + # replication_time.status = "Disabled". + replication_time = optional(object({ + time = optional(object({ + minutes = optional(number, 15) # Currently 15 is the only valid number + }), { minutes = 15 }) + status = optional(string) + })) + }) + + source_selection_criteria = optional(object({ + replica_modifications = optional(object({ + status = string # Either Enabled or Disabled + })) + sse_kms_encrypted_objects = optional(object({ + status = optional(string) + })) + })) + # filter.prefix overrides top level prefix + filter = optional(object({ + prefix = optional(string) + tags = optional(map(string), {}) + })) + })) default = null description = "Specifies the replication rules for S3 bucket replication if enabled. You must also set s3_replication_enabled to true." } @@ -285,6 +321,7 @@ variable "s3_replication_source_roles" { type = list(string) default = [] description = "Cross-account IAM Role ARNs that will be allowed to perform S3 replication to this bucket (for replication within the same AWS account, it's not necessary to adjust the bucket policy)." + nullable = false } variable "s3_replication_permissions_boundary_arn" { @@ -321,6 +358,7 @@ variable "website_redirect_all_requests_to" { condition = length(var.website_redirect_all_requests_to) < 2 error_message = "Only 1 website_redirect_all_requests_to is allowed." } + nullable = false } variable "website_configuration" { @@ -348,6 +386,7 @@ variable "website_configuration" { condition = length(var.website_configuration) < 2 error_message = "Only 1 website_configuration is allowed." } + nullable = false } # Need input to be a list to fix https://github.com/cloudposse/terraform-aws-s3-bucket/issues/102 @@ -362,12 +401,14 @@ variable "privileged_principal_arns" { a list of S3 path prefixes to grant `privileged_principal_actions` permissions for that principal, in addition to the bucket itself, which is automatically included. Prefixes should not begin with '/'. EOT + nullable = false } 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`)" + nullable = false } variable "transfer_acceleration_enabled" { @@ -380,6 +421,7 @@ variable "transfer_acceleration_enabled" { To disable it via Terraform, you must set this to `true` and then to `false`. Note: not all regions support Transfer Acceleration. EOT + nullable = false } variable "s3_object_ownership" { @@ -390,13 +432,15 @@ variable "s3_object_ownership" { Valid values are `ObjectWriter`, `BucketOwnerPreferred`, and 'BucketOwnerEnforced'. Defaults to "ObjectWriter" for backwards compatibility, but we recommend setting "BucketOwnerEnforced" instead. EOT + nullable = false } variable "bucket_key_enabled" { type = bool default = false description = <<-EOT - Set this to true to use Amazon S3 Bucket Keys for SSE-KMS, which may reduce the number of AWS KMS requests. + Set this to true to use Amazon S3 Bucket Keys for SSE-KMS, which may or may not reduce the number of AWS KMS requests. For more information, see: https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-key.html EOT + nullable = false } diff --git a/versions.tf b/versions.tf index 97b6fef4..c3c0b2b8 100644 --- a/versions.tf +++ b/versions.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.0" + required_version = ">= 1.3.0" required_providers { aws = {