diff --git a/examples/forwarder-s3/README.md b/examples/forwarder-s3/README.md
new file mode 100644
index 0000000..7e26aad
--- /dev/null
+++ b/examples/forwarder-s3/README.md
@@ -0,0 +1,74 @@
+# Subscribing objects via EventBridge
+
+This module demonstrates how to forward objects from a source bucket using
+EventBridge to trigger the Forwarder module.
+
+To run this example you need to execute:
+
+```
+$ terraform init
+$ terraform plan
+$ terraform apply
+```
+
+The module will output three source buckets, one for each subscription type.
+It will also output a destination bucket, where objects will be copied to by the forwarder.
+
+```
+→ aws s3 cp ./main.tf s3://`terraform output -raw direct_source`
+upload: ./main.tf to s3://forwarder-s3-bucket-notification-src-20240617155010087500000002/main.tf
+→ aws s3 ls s3://`terraform output -raw destination`
+2024-06-17 08:52:55 869 main.tf
+```
+
+Note that this example may create resources which can cost money. Run terraform destroy when you don't need these resources.
+
+
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.3 |
+| [aws](#requirement\_aws) | >= 5.0 |
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| [aws](#provider\_aws) | >= 5.0 |
+
+## Modules
+
+| Name | Source | Version |
+|------|--------|---------|
+| [forwarder](#module\_forwarder) | ../..//modules/forwarder | n/a |
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [aws_s3_bucket.destination](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource |
+| [aws_s3_bucket.direct](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource |
+| [aws_s3_bucket.eventbridge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource |
+| [aws_s3_bucket.sns](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource |
+| [aws_s3_bucket_notification.direct](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_notification) | resource |
+| [aws_s3_bucket_notification.eventbridge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_notification) | resource |
+| [aws_s3_bucket_notification.sns](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_notification) | resource |
+| [aws_sns_topic.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic) | resource |
+| [aws_sns_topic_policy.s3_to_sns](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic_policy) | resource |
+| [aws_iam_policy_document.s3_to_sns](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+
+## Inputs
+
+No inputs.
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [destination](#output\_destination) | Destination bucket objects are copied to. |
+| [direct\_source](#output\_direct\_source) | Source bucket subscribed directly |
+| [eventbridge\_source](#output\_eventbridge\_source) | Source bucket subscribed via eventbridge |
+| [sns\_source](#output\_sns\_source) | Source bucket subscribed via SNS |
+
diff --git a/examples/forwarder-s3/direct.tf b/examples/forwarder-s3/direct.tf
new file mode 100644
index 0000000..37f896e
--- /dev/null
+++ b/examples/forwarder-s3/direct.tf
@@ -0,0 +1,12 @@
+resource "aws_s3_bucket" "direct" {
+ bucket_prefix = "${local.name_prefix}direct-"
+ force_destroy = true
+}
+
+resource "aws_s3_bucket_notification" "direct" {
+ bucket = aws_s3_bucket.direct.id
+ queue {
+ queue_arn = module.forwarder.queue_arn
+ events = ["s3:ObjectCreated:*"]
+ }
+}
diff --git a/examples/forwarder-s3/eventbridge.tf b/examples/forwarder-s3/eventbridge.tf
new file mode 100644
index 0000000..eb0b966
--- /dev/null
+++ b/examples/forwarder-s3/eventbridge.tf
@@ -0,0 +1,9 @@
+resource "aws_s3_bucket" "eventbridge" {
+ bucket_prefix = "${local.name_prefix}eventbridge-"
+ force_destroy = true
+}
+
+resource "aws_s3_bucket_notification" "eventbridge" {
+ bucket = aws_s3_bucket.eventbridge.id
+ eventbridge = true
+}
diff --git a/examples/forwarder-s3/main.tf b/examples/forwarder-s3/main.tf
new file mode 100644
index 0000000..90bc71f
--- /dev/null
+++ b/examples/forwarder-s3/main.tf
@@ -0,0 +1,26 @@
+locals {
+ name = basename(abspath(path.root))
+ name_prefix = "${local.name}-"
+}
+
+# we'll write data to this bucket
+resource "aws_s3_bucket" "destination" {
+ bucket_prefix = "${local.name_prefix}dst-"
+ force_destroy = true
+}
+
+module "forwarder" {
+ # Prefer using the hashicorp registry:
+ # source = "observeinc/collection/aws//modules/forwarder"
+ # For validation purposes we will instead refer to a local version of the
+ # module:
+ source = "../..//modules/forwarder"
+
+ name = local.name
+ destination = {
+ bucket = aws_s3_bucket.destination.id
+ prefix = ""
+ }
+ source_bucket_names = ["${local.name_prefix}*"]
+ source_object_keys = ["*"]
+}
diff --git a/examples/forwarder-s3/outputs.tf b/examples/forwarder-s3/outputs.tf
new file mode 100644
index 0000000..38aabf3
--- /dev/null
+++ b/examples/forwarder-s3/outputs.tf
@@ -0,0 +1,20 @@
+output "direct_source" {
+ description = "Source bucket subscribed directly"
+ value = aws_s3_bucket.direct.id
+}
+
+output "sns_source" {
+ description = "Source bucket subscribed via SNS"
+ value = aws_s3_bucket.sns.id
+}
+
+output "eventbridge_source" {
+ description = "Source bucket subscribed via eventbridge"
+ value = aws_s3_bucket.eventbridge.id
+}
+
+
+output "destination" {
+ description = "Destination bucket objects are copied to."
+ value = aws_s3_bucket.destination.id
+}
diff --git a/examples/forwarder-s3/sns.tf b/examples/forwarder-s3/sns.tf
new file mode 100644
index 0000000..138be40
--- /dev/null
+++ b/examples/forwarder-s3/sns.tf
@@ -0,0 +1,34 @@
+resource "aws_s3_bucket" "sns" {
+ bucket_prefix = "${local.name_prefix}sns-"
+ force_destroy = true
+}
+
+resource "aws_sns_topic" "this" {
+ name_prefix = local.name_prefix
+}
+
+data "aws_iam_policy_document" "s3_to_sns" {
+ statement {
+ actions = ["SNS:Publish"]
+ resources = [aws_sns_topic.this.arn]
+ principals {
+ type = "Service"
+ identifiers = ["s3.amazonaws.com"]
+ }
+ }
+}
+
+resource "aws_sns_topic_policy" "s3_to_sns" {
+ arn = aws_sns_topic.this.arn
+ policy = data.aws_iam_policy_document.s3_to_sns.json
+}
+
+resource "aws_s3_bucket_notification" "sns" {
+ bucket = aws_s3_bucket.sns.id
+ topic {
+ topic_arn = aws_sns_topic.this.arn
+ events = ["s3:ObjectCreated:*"]
+ }
+
+ depends_on = [aws_sns_topic_policy.s3_to_sns]
+}
diff --git a/examples/forwarder-s3/tests/example.tftest.hcl b/examples/forwarder-s3/tests/example.tftest.hcl
new file mode 100644
index 0000000..db42804
--- /dev/null
+++ b/examples/forwarder-s3/tests/example.tftest.hcl
@@ -0,0 +1,2 @@
+# only verifies module can be installed and removed correctly
+run "install" {}
diff --git a/examples/forwarder-s3/variables.tf b/examples/forwarder-s3/variables.tf
new file mode 100644
index 0000000..e69de29
diff --git a/examples/forwarder-s3/versions.tf b/examples/forwarder-s3/versions.tf
new file mode 100644
index 0000000..29ec41d
--- /dev/null
+++ b/examples/forwarder-s3/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_version = ">= 1.3"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 5.0"
+ }
+ }
+}
diff --git a/modules/forwarder/README.md b/modules/forwarder/README.md
index bf759d2..ee98d53 100644
--- a/modules/forwarder/README.md
+++ b/modules/forwarder/README.md
@@ -118,6 +118,7 @@ module "forwarder" {
| [sam\_release\_version](#input\_sam\_release\_version) | Release version for SAM apps as defined on github.com/observeinc/aws-sam-apps. | `string` | `""` | no |
| [source\_bucket\_names](#input\_source\_bucket\_names) | A list of bucket names which the forwarder is allowed to read from. This
list only affects permissions, and supports wildcards. In order to have
files copied to Filedrop, you must also subscribe S3 Bucket Notifications
to the forwarder. | `list(string)` | `[]` | no |
| [source\_kms\_key\_arns](#input\_source\_kms\_key\_arns) | A list of KMS Key ARNs the forwarder is allowed to use to decrypt objects in S3. | `list(string)` | `[]` | no |
+| [source\_object\_keys](#input\_source\_object\_keys) | A list of object key patterns the forwarder is allowed to read from for
provided source buckets. | `list(string)` |
[| no | | [source\_topic\_arns](#input\_source\_topic\_arns) | A list of SNS topics the forwarder is allowed to be subscribed to. | `list(string)` | `[]` | no | | [verbosity](#input\_verbosity) | Logging verbosity for Lambda. Highest log verbosity is 9. | `number` | `1` | no | diff --git a/modules/forwarder/eventbridge.tf b/modules/forwarder/eventbridge.tf index ac837f3..e6e5d10 100644 --- a/modules/forwarder/eventbridge.tf +++ b/modules/forwarder/eventbridge.tf @@ -17,6 +17,9 @@ resource "aws_cloudwatch_event_rule" "this" { # list must have elements, so we introduce an empty match for name in concat([""], var.source_bucket_names) : { "wildcard" : name } ], + "detail.object.key" = [ + for key in concat([""], var.source_object_keys) : { "wildcard" : key } + ], }) } diff --git a/modules/forwarder/iam.tf b/modules/forwarder/iam.tf index 4444fc9..113b3c7 100644 --- a/modules/forwarder/iam.tf +++ b/modules/forwarder/iam.tf @@ -80,7 +80,11 @@ data "aws_iam_policy_document" "reader" { "s3:GetObject", "s3:GetObjectTagging", ] - resources = [for name in var.source_bucket_names : "arn:aws:s3:::${name}/*"] + # NOTE: this is a deviation from our CloudFormation template. In terraform + # we can compute the product of two lists, allowing us to be strict as to what buckets and keys we grant the function read access for. + resources = [ + for pair in setproduct(var.source_bucket_names, var.source_object_keys) : "arn:aws:s3:::${pair[0]}/${pair[1]}" + ] } } diff --git a/modules/forwarder/lambda.tf b/modules/forwarder/lambda.tf index 038ed04..7598adf 100644 --- a/modules/forwarder/lambda.tf +++ b/modules/forwarder/lambda.tf @@ -15,6 +15,7 @@ resource "aws_lambda_function" "this" { MAX_FILE_SIZE = var.max_file_size != null ? var.max_file_size : local.default_limits.max_file_size CONTENT_TYPE_OVERRIDES = join(",", [for o in var.content_type_overrides : "${o["pattern"]}=${o["content_type"]}"]) SOURCE_BUCKET_NAMES = join(",", var.source_bucket_names) + SOURCE_OBJECT_KEYS = join(",", var.source_object_keys) OTEL_EXPORTER_OTLP_ENDPOINT = var.debug_endpoint OTEL_TRACES_EXPORTER = var.debug_endpoint == "" ? "none" : "otlp" VERBOSITY = var.verbosity diff --git a/modules/forwarder/variables.tf b/modules/forwarder/variables.tf index 16a9f96..6a2eacd 100644 --- a/modules/forwarder/variables.tf +++ b/modules/forwarder/variables.tf @@ -64,6 +64,21 @@ variable "source_bucket_names" { } } +variable "source_object_keys" { + description = <<-EOF + A list of object key patterns the forwarder is allowed to read from for + provided source buckets. + EOF + type = list(string) + nullable = false + default = ["*"] + + validation { + condition = length(var.source_object_keys) > 0 + error_message = "At least one S3 object key match must be provided." + } +} + variable "source_topic_arns" { description = <<-EOF A list of SNS topics the forwarder is allowed to be subscribed to. @@ -220,3 +235,4 @@ variable "code_uri" { default = "" nullable = false } + diff --git a/modules/stack/README.md b/modules/stack/README.md index de7623e..76a5f0e 100644 --- a/modules/stack/README.md +++ b/modules/stack/README.md @@ -170,7 +170,7 @@ You can additionally configure other submodules in this manner: | [configsubscription](#input\_configsubscription) | Variables for AWS Config subscription. |
"*"
]
object({| `null` | no | | [debug\_endpoint](#input\_debug\_endpoint) | Endpoint to send debugging telemetry to. Sets OTEL\_EXPORTER\_OTLP\_ENDPOINT environment variable for supported lambda functions. | `string` | `null` | no | | [destination](#input\_destination) | Destination filedrop |
delivery_bucket_name = string
})
object({| n/a | yes | -| [forwarder](#input\_forwarder) | Variables for forwarder module. |
arn = optional(string, "")
bucket = optional(string, "")
prefix = optional(string, "")
# exclusively for backward compatible HTTP endpoint
uri = optional(string, "")
})
object({| `{}` | no | +| [forwarder](#input\_forwarder) | Variables for forwarder module. |
source_bucket_names = optional(list(string), [])
source_topic_arns = optional(list(string), [])
content_type_overrides = optional(list(object({ pattern = string, content_type = string })), [])
max_file_size = optional(number)
lambda_memory_size = optional(number)
lambda_timeout = optional(number)
lambda_env_vars = optional(map(string))
retention_in_days = optional(number)
queue_max_receive_count = optional(number)
queue_delay_seconds = optional(number)
queue_message_retention_seconds = optional(number)
queue_batch_size = optional(number)
queue_maximum_batching_window_in_seconds = optional(number)
code_uri = optional(string)
sam_release_version = optional(string)
})
object({| `{}` | no | | [logwriter](#input\_logwriter) | Variables for AWS CloudWatch Logs collection. |
source_bucket_names = optional(list(string), [])
source_object_keys = optional(list(string))
source_topic_arns = optional(list(string), [])
content_type_overrides = optional(list(object({ pattern = string, content_type = string })), [])
max_file_size = optional(number)
lambda_memory_size = optional(number)
lambda_timeout = optional(number)
lambda_env_vars = optional(map(string))
retention_in_days = optional(number)
queue_max_receive_count = optional(number)
queue_delay_seconds = optional(number)
queue_message_retention_seconds = optional(number)
queue_batch_size = optional(number)
queue_maximum_batching_window_in_seconds = optional(number)
code_uri = optional(string)
sam_release_version = optional(string)
})
object({| `null` | no | | [metricstream](#input\_metricstream) | Variables for AWS CloudWatch Metrics Stream collection. |
log_group_name_patterns = optional(list(string))
log_group_name_prefixes = optional(list(string))
exclude_log_group_name_prefixes = optional(list(string))
buffering_interval = optional(number)
buffering_size = optional(number)
filter_name = optional(string)
filter_pattern = optional(string)
num_workers = optional(number)
discovery_rate = optional(string, "24 hours")
lambda_memory_size = optional(number)
lambda_timeout = optional(number)
code_uri = optional(string)
sam_release_version = optional(string)
})
object({| `null` | no | | [name](#input\_name) | Name of role. Since this name must be unique within the
include_filters = optional(list(object({ namespace = string, metric_names = optional(list(string)) })))
exclude_filters = optional(list(object({ namespace = string, metric_names = optional(list(string)) })))
buffering_interval = optional(number)
buffering_size = optional(number)
sam_release_version = optional(string)
})