From 3a38ef580677e36f92e48205c34372c0e9b8637b Mon Sep 17 00:00:00 2001
From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com>
Date: Mon, 27 Apr 2026 15:22:20 +0100
Subject: [PATCH 1/2] CCM-16644: Add the eventsub module
---
.../terraform/modules/eventsub/README.md | 52 ++++++
...udwatch_log_group_kinesis_data_firehose.tf | 14 ++
..._log_group_sns_delivery_logging_failure.tf | 9 +
..._log_group_sns_delivery_logging_success.tf | 9 +
...atch_metric_alarm_sns_delivery_failures.tf | 16 ++
...udwatch_metric_alarm_subscriber_anomaly.tf | 40 ++++
..._policy_sns_delivery_logging_cloudwatch.tf | 44 +++++
.../eventsub/iam_role_firehose_role.tf | 58 ++++++
.../modules/eventsub/iam_role_sns.tf | 51 ++++++
.../eventsub/iam_role_sns_delivery_logging.tf | 21 +++
.../kinesis_firehose_delivery_stream.tf | 25 +++
.../terraform/modules/eventsub/locals.tf | 34 ++++
.../eventsub/module_s3bucket_event_cache.tf | 173 ++++++++++++++++++
.../terraform/modules/eventsub/outputs.tf | 15 ++
.../terraform/modules/eventsub/sns_topic.tf | 24 +++
.../modules/eventsub/sns_topic_policy.tf | 63 +++++++
.../sns_topic_subscription_firehose.tf | 9 +
.../terraform/modules/eventsub/variables.tf | 160 ++++++++++++++++
.../terraform/modules/eventsub/versions.tf | 9 +
19 files changed, 826 insertions(+)
create mode 100644 infrastructure/terraform/modules/eventsub/README.md
create mode 100644 infrastructure/terraform/modules/eventsub/cloudwatch_log_group_kinesis_data_firehose.tf
create mode 100644 infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_failure.tf
create mode 100644 infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_success.tf
create mode 100644 infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf
create mode 100644 infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_subscriber_anomaly.tf
create mode 100644 infrastructure/terraform/modules/eventsub/iam_policy_sns_delivery_logging_cloudwatch.tf
create mode 100644 infrastructure/terraform/modules/eventsub/iam_role_firehose_role.tf
create mode 100644 infrastructure/terraform/modules/eventsub/iam_role_sns.tf
create mode 100644 infrastructure/terraform/modules/eventsub/iam_role_sns_delivery_logging.tf
create mode 100644 infrastructure/terraform/modules/eventsub/kinesis_firehose_delivery_stream.tf
create mode 100644 infrastructure/terraform/modules/eventsub/locals.tf
create mode 100644 infrastructure/terraform/modules/eventsub/module_s3bucket_event_cache.tf
create mode 100644 infrastructure/terraform/modules/eventsub/outputs.tf
create mode 100644 infrastructure/terraform/modules/eventsub/sns_topic.tf
create mode 100644 infrastructure/terraform/modules/eventsub/sns_topic_policy.tf
create mode 100644 infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf
create mode 100644 infrastructure/terraform/modules/eventsub/variables.tf
create mode 100644 infrastructure/terraform/modules/eventsub/versions.tf
diff --git a/infrastructure/terraform/modules/eventsub/README.md b/infrastructure/terraform/modules/eventsub/README.md
new file mode 100644
index 0000000..d00c4a7
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/README.md
@@ -0,0 +1,52 @@
+
+
+
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.9.0 |
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [access\_logging\_bucket](#input\_access\_logging\_bucket) | Name of S3 bucket to use for access logging | `string` | `""` | no |
+| [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes |
+| [component](#input\_component) | The name of the terraformscaffold component calling this module | `string` | n/a | yes |
+| [default\_tags](#input\_default\_tags) | Default tag map for application to all taggable resources in the module | `map(string)` | `{}` | no |
+| [enable\_event\_anomaly\_detection](#input\_enable\_event\_anomaly\_detection) | Enable CloudWatch anomaly detection alarm for SNS topic message publishing | `bool` | `true` | no |
+| [enable\_event\_cache](#input\_enable\_event\_cache) | Enable caching of events to an S3 bucket | `bool` | `true` | no |
+| [enable\_firehose\_raw\_message\_delivery](#input\_enable\_firehose\_raw\_message\_delivery) | Enables raw message delivery on firehose subscription | `bool` | `false` | no |
+| [enable\_sns\_delivery\_logging](#input\_enable\_sns\_delivery\_logging) | Enable SNS Delivery Failure Notifications | `bool` | `true` | no |
+| [environment](#input\_environment) | The name of the terraformscaffold environment the module is called for | `string` | n/a | yes |
+| [event\_anomaly\_band\_width](#input\_event\_anomaly\_band\_width) | The width of the anomaly detection band. Higher values (e.g. 4-6) reduce sensitivity and noise, lower values (e.g. 2-3) increase sensitivity. Recommended: 2-4. | `number` | `3` | no |
+| [event\_anomaly\_evaluation\_periods](#input\_event\_anomaly\_evaluation\_periods) | Number of evaluation periods for the anomaly alarm. Each period is defined by event\_anomaly\_period. | `number` | `2` | no |
+| [event\_anomaly\_period](#input\_event\_anomaly\_period) | The period in seconds over which the specified statistic is applied for anomaly detection. Minimum 300 seconds (5 minutes). Recommended: 300-600. | `number` | `300` | no |
+| [event\_cache\_buffer\_interval](#input\_event\_cache\_buffer\_interval) | The buffer interval for data firehose | `number` | `500` | no |
+| [event\_cache\_expiry\_days](#input\_event\_cache\_expiry\_days) | s3 archiving expiry in days | `number` | `30` | no |
+| [force\_destroy](#input\_force\_destroy) | When enabled will force destroy event-cache S3 bucket | `bool` | `false` | no |
+| [glue\_role\_arn](#input\_glue\_role\_arn) | ARN of the Glue execution role from the parent | `string` | n/a | yes |
+| [group](#input\_group) | The name of the tfscaffold group | `string` | `null` | no |
+| [kms\_key\_arn](#input\_kms\_key\_arn) | KMS key arn to use for this function | `string` | n/a | yes |
+| [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"WARN"` | no |
+| [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events generated by the lambda function | `number` | n/a | yes |
+| [name](#input\_name) | A unique name to distinguish this module invocation from others within the same CSI scope | `string` | n/a | yes |
+| [project](#input\_project) | The name of the terraformscaffold project calling the module | `string` | n/a | yes |
+| [region](#input\_region) | The AWS Region | `string` | n/a | yes |
+| [shared\_infra\_account\_id](#input\_shared\_infra\_account\_id) | The AWS Account ID of the shared infrastructure account | `string` | `"000000000000"` | no |
+| [sns\_success\_logging\_sample\_percent](#input\_sns\_success\_logging\_sample\_percent) | Enable SNS Delivery Successful Sample Percentage | `number` | `0` | no |
+## Modules
+
+| Name | Source | Version |
+|------|--------|---------|
+| [s3bucket\_event\_cache](#module\_s3bucket\_event\_cache) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.2/terraform-s3bucket.zip | n/a |
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [s3\_bucket\_event\_cache](#output\_s3\_bucket\_event\_cache) | S3 Bucket ARN and Name for event cache |
+| [sns\_topic](#output\_sns\_topic) | SNS Topic ARN and Name |
+
+
+
diff --git a/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_kinesis_data_firehose.tf b/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_kinesis_data_firehose.tf
new file mode 100644
index 0000000..952fe8b
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_kinesis_data_firehose.tf
@@ -0,0 +1,14 @@
+resource "aws_cloudwatch_log_group" "kinesis_data_firehose" {
+ count = var.enable_event_cache ? 1 : 0
+
+ name = "/aws/firehose/${local.csi}"
+ kms_key_id = var.kms_key_arn
+ retention_in_days = var.log_retention_in_days
+}
+
+resource "aws_cloudwatch_log_stream" "kinesis_data_firehose_extended_s3" {
+ count = var.enable_event_cache ? 1 : 0
+
+ name = "extended_s3"
+ log_group_name = aws_cloudwatch_log_group.kinesis_data_firehose[0].name
+}
diff --git a/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_failure.tf b/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_failure.tf
new file mode 100644
index 0000000..28a7ecf
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_failure.tf
@@ -0,0 +1,9 @@
+resource "aws_cloudwatch_log_group" "sns_delivery_logging_failure" {
+ count = var.enable_sns_delivery_logging ? 1 : 0
+
+ # SNS doesn't allow specifying a log group and is derived as: sns/${region}/${account_id}/${name_of_sns_topic}/Failure
+ # (for failure logs)
+ name = "sns/${var.region}/${var.aws_account_id}/${local.csi}/Failure"
+ kms_key_id = var.kms_key_arn
+ retention_in_days = var.log_retention_in_days
+}
diff --git a/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_success.tf b/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_success.tf
new file mode 100644
index 0000000..f760e85
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/cloudwatch_log_group_sns_delivery_logging_success.tf
@@ -0,0 +1,9 @@
+resource "aws_cloudwatch_log_group" "sns_delivery_logging_success" {
+ count = var.enable_sns_delivery_logging ? 1 : 0
+
+ # SNS doesn't allow specifying a log group and is derived as: sns/${region}/${account_id}/${name_of_sns_topic}
+ # (for success logs)
+ name = "sns/${var.region}/${var.aws_account_id}/${local.csi}"
+ kms_key_id = var.kms_key_arn
+ retention_in_days = var.log_retention_in_days
+}
diff --git a/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf b/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf
new file mode 100644
index 0000000..e8ef124
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_sns_delivery_failures.tf
@@ -0,0 +1,16 @@
+resource "aws_cloudwatch_metric_alarm" "sns_delivery_failures" {
+ alarm_name = "${local.csi}-sns-delivery-failures"
+ alarm_description = "RELIABILITY: Alarm for SNS topic delivery failures"
+ comparison_operator = "GreaterThanThreshold"
+ evaluation_periods = 1
+ metric_name = "NumberOfNotificationsFailed"
+ namespace = "AWS/SNS"
+ period = 300
+ statistic = "Sum"
+ threshold = 0
+ treat_missing_data = "notBreaching"
+
+ dimensions = {
+ TopicName = aws_sns_topic.main.name
+ }
+}
diff --git a/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_subscriber_anomaly.tf b/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_subscriber_anomaly.tf
new file mode 100644
index 0000000..012969b
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/cloudwatch_metric_alarm_subscriber_anomaly.tf
@@ -0,0 +1,40 @@
+resource "aws_cloudwatch_metric_alarm" "subscriber_anomaly" {
+ count = var.enable_event_anomaly_detection ? 1 : 0
+
+ alarm_name = "${local.csi}-subscriber-anomaly"
+ alarm_description = "ANOMALY: Detects anomalous patterns in messages published to the SNS fanout topic"
+ comparison_operator = "LessThanLowerOrGreaterThanUpperThreshold"
+ evaluation_periods = var.event_anomaly_evaluation_periods
+ threshold_metric_id = "ad1"
+ treat_missing_data = "notBreaching"
+
+ metric_query {
+ id = "m1"
+ return_data = true
+
+ metric {
+ metric_name = "NumberOfNotificationsDelivered"
+ namespace = "AWS/SNS"
+ period = var.event_anomaly_period
+ stat = "Sum"
+
+ dimensions = {
+ TopicName = aws_sns_topic.main.name
+ }
+ }
+ }
+
+ metric_query {
+ id = "ad1"
+ expression = "ANOMALY_DETECTION_BAND(m1, ${var.event_anomaly_band_width})"
+ label = "NumberOfNotificationsDelivered (expected)"
+ return_data = true
+ }
+
+ tags = merge(
+ var.default_tags,
+ {
+ Name = "${local.csi}-subscriber-anomaly"
+ }
+ )
+}
diff --git a/infrastructure/terraform/modules/eventsub/iam_policy_sns_delivery_logging_cloudwatch.tf b/infrastructure/terraform/modules/eventsub/iam_policy_sns_delivery_logging_cloudwatch.tf
new file mode 100644
index 0000000..d296da2
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/iam_policy_sns_delivery_logging_cloudwatch.tf
@@ -0,0 +1,44 @@
+resource "aws_iam_policy" "sns_delivery_logging_cloudwatch" {
+ count = var.enable_sns_delivery_logging ? 1 : 0
+
+ name = "${local.csi}-${var.name}-sns-delivery"
+ description = "Policy for ${local.csi}-${var.name} SNS Delivery Logging"
+ policy = data.aws_iam_policy_document.sns_delivery_logging_cloudwatch[0].json
+}
+
+data "aws_iam_policy_document" "sns_delivery_logging_cloudwatch" {
+ count = var.enable_sns_delivery_logging ? 1 : 0
+
+ statement {
+ sid = "KMSCloudwatchKeyAccess"
+ effect = "Allow"
+
+ actions = [
+ "kms:GenerateDataKey",
+ "kms:Decrypt",
+ ]
+
+ resources = [
+ var.kms_key_arn
+ ]
+ }
+
+ statement {
+ sid = "AllowSNSDeliveryNotifications"
+ effect = "Allow"
+
+ actions = [
+ "logs:CreateLogStream",
+ "logs:PutLogEvents",
+ "logs:PutMetricFilter",
+ "logs:PutRetentionPolicy",
+ ]
+
+ resources = [
+ aws_cloudwatch_log_group.sns_delivery_logging_success[0].arn,
+ "${aws_cloudwatch_log_group.sns_delivery_logging_success[0].arn}:log-stream:*",
+ aws_cloudwatch_log_group.sns_delivery_logging_failure[0].arn,
+ "${aws_cloudwatch_log_group.sns_delivery_logging_failure[0].arn}:log-stream:*",
+ ]
+ }
+}
diff --git a/infrastructure/terraform/modules/eventsub/iam_role_firehose_role.tf b/infrastructure/terraform/modules/eventsub/iam_role_firehose_role.tf
new file mode 100644
index 0000000..281bc98
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/iam_role_firehose_role.tf
@@ -0,0 +1,58 @@
+resource "aws_iam_role" "firehose_role" {
+ count = var.enable_event_cache ? 1 : 0
+
+ name = "${local.csi}-firehose-role"
+ assume_role_policy = data.aws_iam_policy_document.firehose_assume_role[0].json
+}
+
+data "aws_iam_policy_document" "firehose_assume_role" {
+ count = var.enable_event_cache ? 1 : 0
+
+ statement {
+ effect = "Allow"
+
+ principals {
+ type = "Service"
+ identifiers = ["firehose.amazonaws.com"]
+ }
+
+ actions = ["sts:AssumeRole"]
+ }
+}
+
+resource "aws_iam_role_policy_attachment" "s3_write_object" {
+ count = var.enable_event_cache ? 1 : 0
+
+ role = aws_iam_role.firehose_role[0].name
+ policy_arn = aws_iam_policy.s3_write_object[0].arn
+}
+
+resource "aws_iam_policy" "s3_write_object" {
+ count = var.enable_event_cache ? 1 : 0
+
+ name = "${local.csi}-${var.name}-s3-write-object"
+ description = "S3 Put Object policy for ${local.csi}-${var.name} Firehose"
+ policy = data.aws_iam_policy_document.s3_write_object[0].json
+}
+
+data "aws_iam_policy_document" "s3_write_object" {
+ count = var.enable_event_cache ? 1 : 0
+
+ statement {
+ sid = "AllowWriteObject"
+ effect = "Allow"
+
+ actions = [
+ "s3:AbortMultipartUpload",
+ "s3:GetBucketLocation",
+ "s3:GetObject",
+ "s3:ListBucket",
+ "s3:ListBucketMultipartUploads",
+ "s3:PutObject",
+ ]
+
+ resources = [
+ "${module.s3bucket_event_cache[0].arn}/*",
+ ]
+ }
+}
diff --git a/infrastructure/terraform/modules/eventsub/iam_role_sns.tf b/infrastructure/terraform/modules/eventsub/iam_role_sns.tf
new file mode 100644
index 0000000..97bdc99
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/iam_role_sns.tf
@@ -0,0 +1,51 @@
+resource "aws_iam_role" "sns_role" {
+ name = "${local.csi}-sns-role"
+ assume_role_policy = data.aws_iam_policy_document.sns_assume_role.json
+}
+
+resource "aws_iam_policy" "firehose_delivery" {
+ count = var.enable_event_cache ? 1 : 0
+
+ name = "${local.csi}-${var.name}-firehose-delivery"
+ description = "Delivery Policy for ${local.csi}-${var.name} Firehose"
+ policy = data.aws_iam_policy_document.firehose_delivery[0].json
+}
+
+resource "aws_iam_role_policy_attachment" "firehose_delivery" {
+ count = var.enable_event_cache ? 1 : 0
+
+ role = aws_iam_role.sns_role.name
+ policy_arn = aws_iam_policy.firehose_delivery[0].arn
+}
+
+
+data "aws_iam_policy_document" "sns_assume_role" {
+ statement {
+ effect = "Allow"
+
+ principals {
+ type = "Service"
+ identifiers = ["sns.amazonaws.com"]
+ }
+
+ actions = ["sts:AssumeRole"]
+ }
+}
+
+data "aws_iam_policy_document" "firehose_delivery" {
+ count = var.enable_event_cache ? 1 : 0
+
+ statement {
+ sid = "AllowFirehoseDelivery"
+ effect = "Allow"
+
+ actions = [
+ "firehose:PutRecord",
+ "firehose:PutRecordBatch"
+ ]
+
+ resources = [
+ "${aws_kinesis_firehose_delivery_stream.main[0].arn}",
+ ]
+ }
+}
diff --git a/infrastructure/terraform/modules/eventsub/iam_role_sns_delivery_logging.tf b/infrastructure/terraform/modules/eventsub/iam_role_sns_delivery_logging.tf
new file mode 100644
index 0000000..a952bfe
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/iam_role_sns_delivery_logging.tf
@@ -0,0 +1,21 @@
+resource "aws_iam_role" "sns_delivery_logging_role" {
+ count = var.enable_sns_delivery_logging ? 1 : 0
+
+ name = "${local.csi}-sns-delivery-logging"
+ assume_role_policy = data.aws_iam_policy_document.sns_delivery_logging_assume_role[0].json
+}
+
+data "aws_iam_policy_document" "sns_delivery_logging_assume_role" {
+ count = var.enable_sns_delivery_logging ? 1 : 0
+
+ statement {
+ effect = "Allow"
+
+ principals {
+ type = "Service"
+ identifiers = ["sns.amazonaws.com"]
+ }
+
+ actions = ["sts:AssumeRole"]
+ }
+}
diff --git a/infrastructure/terraform/modules/eventsub/kinesis_firehose_delivery_stream.tf b/infrastructure/terraform/modules/eventsub/kinesis_firehose_delivery_stream.tf
new file mode 100644
index 0000000..186372d
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/kinesis_firehose_delivery_stream.tf
@@ -0,0 +1,25 @@
+resource "aws_kinesis_firehose_delivery_stream" "main" {
+ count = var.enable_event_cache ? 1 : 0
+
+ name = local.csi
+ destination = "extended_s3"
+
+
+ server_side_encryption {
+ enabled = true
+ key_type = "CUSTOMER_MANAGED_CMK"
+ key_arn = var.kms_key_arn
+ }
+
+ extended_s3_configuration {
+ role_arn = aws_iam_role.firehose_role[0].arn
+ bucket_arn = module.s3bucket_event_cache[0].arn
+ buffering_interval = var.event_cache_buffer_interval
+
+ cloudwatch_logging_options {
+ enabled = true
+ log_group_name = aws_cloudwatch_log_group.kinesis_data_firehose[0].name
+ log_stream_name = aws_cloudwatch_log_stream.kinesis_data_firehose_extended_s3[0].name
+ }
+ }
+}
diff --git a/infrastructure/terraform/modules/eventsub/locals.tf b/infrastructure/terraform/modules/eventsub/locals.tf
new file mode 100644
index 0000000..1141f72
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/locals.tf
@@ -0,0 +1,34 @@
+locals {
+ module = "eventsub"
+
+ csi = replace(
+ format(
+ "%s-%s-%s-%s",
+ var.project,
+ var.environment,
+ var.component,
+ var.name,
+ ),
+ "_",
+ "",
+ )
+ csi_global = replace(
+ format(
+ "%s-%s-%s-%s-%s",
+ var.project,
+ var.aws_account_id,
+ var.region,
+ var.environment,
+ var.component,
+ ),
+ "_",
+ "",
+ )
+ default_tags = merge(
+ var.default_tags,
+ {
+ Module = local.module
+ Name = local.csi
+ },
+ )
+}
diff --git a/infrastructure/terraform/modules/eventsub/module_s3bucket_event_cache.tf b/infrastructure/terraform/modules/eventsub/module_s3bucket_event_cache.tf
new file mode 100644
index 0000000..d095c5c
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/module_s3bucket_event_cache.tf
@@ -0,0 +1,173 @@
+module "s3bucket_event_cache" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.1.2/terraform-s3bucket.zip"
+
+ count = var.enable_event_cache ? 1 : 0
+
+ name = "eventsub_event_cache"
+
+ aws_account_id = var.aws_account_id
+ region = var.region
+ project = var.project
+ environment = var.environment
+ component = var.component
+
+ acl = "private"
+ force_destroy = var.force_destroy
+ versioning = true
+
+ lifecycle_rules = [
+ {
+ enabled = true
+
+ noncurrent_version_transition = [
+ {
+ noncurrent_days = "30"
+ storage_class = "STANDARD_IA"
+ }
+ ]
+
+ noncurrent_version_expiration = {
+ noncurrent_days = "90"
+ }
+
+ abort_incomplete_multipart_upload = {
+ days = "1"
+ }
+ }
+ ]
+
+ policy_documents = [
+ data.aws_iam_policy_document.s3bucket_event_cache[0].json
+ ]
+
+ bucket_logging_target = {
+ bucket = "${var.access_logging_bucket}"
+ }
+
+ public_access = {
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+ }
+
+ default_tags = {
+ Name = "Event Cache Storage"
+ NHSE-Enable-S3-Backup-Acct = "True"
+ }
+}
+
+data "aws_iam_policy_document" "s3bucket_event_cache" {
+ count = var.enable_event_cache ? 1 : 0
+
+ statement {
+ sid = "DontAllowNonSecureConnection"
+ effect = "Deny"
+
+ actions = [
+ "s3:*",
+ ]
+
+ resources = [
+ module.s3bucket_event_cache[0].arn,
+ "${module.s3bucket_event_cache[0].arn}/*",
+ ]
+
+ principals {
+ type = "AWS"
+
+ identifiers = [
+ "*",
+ ]
+ }
+
+ condition {
+ test = "Bool"
+ variable = "aws:SecureTransport"
+
+ values = [
+ "false",
+ ]
+ }
+ }
+
+ statement {
+ sid = "AllowManagedAccountsToList"
+ effect = "Allow"
+
+ actions = [
+ "s3:ListBucket",
+ ]
+
+ resources = [
+ module.s3bucket_event_cache[0].arn,
+ ]
+
+ principals {
+ type = "AWS"
+ identifiers = [
+ "arn:aws:iam::${var.aws_account_id}:root"
+ ]
+ }
+ }
+
+ statement {
+ sid = "AllowManagedAccountsToGet"
+ effect = "Allow"
+
+ actions = [
+ "s3:GetObject",
+ ]
+
+ resources = [
+ "${module.s3bucket_event_cache[0].arn}/*",
+ ]
+
+ principals {
+ type = "AWS"
+ identifiers = [
+ "arn:aws:iam::${var.aws_account_id}:root"
+ ]
+ }
+ }
+ statement {
+ sid = "AllowGlueListBucketAndGetLocation"
+ effect = "Allow"
+
+ principals {
+ type = "AWS"
+ identifiers = [var.glue_role_arn]
+ }
+
+ actions = [
+ "s3:ListBucket",
+ "s3:GetBucketLocation"
+ ]
+
+ resources = [
+ "arn:aws:s3:::${module.s3bucket_event_cache[0].bucket}"
+ ]
+ }
+
+ # Object-level permissions: Get/Put/Delete objects
+ statement {
+ sid = "AllowGlueObjectAccess"
+ effect = "Allow"
+
+ principals {
+ type = "AWS"
+ identifiers = [var.glue_role_arn]
+ }
+
+ actions = [
+ "s3:GetObject",
+ "s3:GetObjectVersion",
+ "s3:PutObject",
+ "s3:DeleteObject"
+ ]
+
+ resources = [
+ "arn:aws:s3:::${module.s3bucket_event_cache[0].bucket}/*"
+ ]
+ }
+}
diff --git a/infrastructure/terraform/modules/eventsub/outputs.tf b/infrastructure/terraform/modules/eventsub/outputs.tf
new file mode 100644
index 0000000..e2ff3b3
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/outputs.tf
@@ -0,0 +1,15 @@
+output "sns_topic" {
+ description = "SNS Topic ARN and Name"
+ value = {
+ arn = aws_sns_topic.main.arn
+ name = aws_sns_topic.main.name
+ }
+}
+
+output "s3_bucket_event_cache" {
+ description = "S3 Bucket ARN and Name for event cache"
+ value = var.enable_event_cache ? {
+ arn = module.s3bucket_event_cache[0].arn
+ bucket = module.s3bucket_event_cache[0].bucket
+ } : {}
+}
diff --git a/infrastructure/terraform/modules/eventsub/sns_topic.tf b/infrastructure/terraform/modules/eventsub/sns_topic.tf
new file mode 100644
index 0000000..cc30db1
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/sns_topic.tf
@@ -0,0 +1,24 @@
+resource "aws_sns_topic" "main" {
+ name = local.csi
+ kms_master_key_id = var.kms_key_arn
+
+ application_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
+ application_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
+ application_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null
+
+ firehose_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
+ firehose_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
+ firehose_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null
+
+ http_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
+ http_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
+ http_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null
+
+ lambda_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
+ lambda_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
+ lambda_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null
+
+ sqs_failure_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
+ sqs_success_feedback_role_arn = var.enable_sns_delivery_logging == true ? aws_iam_role.sns_delivery_logging_role[0].arn : null
+ sqs_success_feedback_sample_rate = var.enable_sns_delivery_logging == true ? var.sns_success_logging_sample_percent : null
+}
diff --git a/infrastructure/terraform/modules/eventsub/sns_topic_policy.tf b/infrastructure/terraform/modules/eventsub/sns_topic_policy.tf
new file mode 100644
index 0000000..6ccd831
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/sns_topic_policy.tf
@@ -0,0 +1,63 @@
+resource "aws_sns_topic_policy" "main" {
+ arn = aws_sns_topic.main.arn
+
+ policy = data.aws_iam_policy_document.sns_topic_policy.json
+}
+
+data "aws_iam_policy_document" "sns_topic_policy" {
+ policy_id = "__default_policy_ID"
+
+ statement {
+ sid = "AllowAllSNSActionsFromAccount"
+ effect = "Allow"
+
+ principals {
+ type = "AWS"
+ identifiers = ["*"]
+ }
+
+ actions = [
+ "SNS:Subscribe",
+ "SNS:SetTopicAttributes",
+ "SNS:RemovePermission",
+ "SNS:Receive",
+ "SNS:Publish",
+ "SNS:ListSubscriptionsByTopic",
+ "SNS:GetTopicAttributes",
+ "SNS:DeleteTopic",
+ "SNS:AddPermission",
+ ]
+
+ resources = [
+ aws_sns_topic.main.arn,
+ ]
+
+ condition {
+ test = "StringEquals"
+ variable = "AWS:SourceOwner"
+
+ values = [
+ var.aws_account_id,
+ ]
+ }
+ }
+
+ statement {
+ sid = "AllowAllSNSActionsFromSharedAccount"
+ effect = "Allow"
+ actions = [
+ "SNS:Publish",
+ ]
+
+ principals {
+ type = "AWS"
+ identifiers = [
+ "arn:aws:iam::${var.shared_infra_account_id}:root"
+ ]
+ }
+
+ resources = [
+ aws_sns_topic.main.arn,
+ ]
+ }
+}
diff --git a/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf b/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf
new file mode 100644
index 0000000..42457f6
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/sns_topic_subscription_firehose.tf
@@ -0,0 +1,9 @@
+resource "aws_sns_topic_subscription" "firehose" {
+ count = var.enable_event_cache ? 1 : 0
+
+ topic_arn = aws_sns_topic.main.arn
+ protocol = "firehose"
+ subscription_role_arn = aws_iam_role.sns_role.arn
+ endpoint = aws_kinesis_firehose_delivery_stream.main[0].arn
+ raw_message_delivery = var.enable_firehose_raw_message_delivery
+}
diff --git a/infrastructure/terraform/modules/eventsub/variables.tf b/infrastructure/terraform/modules/eventsub/variables.tf
new file mode 100644
index 0000000..964e90b
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/variables.tf
@@ -0,0 +1,160 @@
+##
+# Basic inherited variables for terraformscaffold modules
+##
+
+variable "project" {
+ type = string
+ description = "The name of the terraformscaffold project calling the module"
+}
+
+variable "environment" {
+ type = string
+ description = "The name of the terraformscaffold environment the module is called for"
+}
+
+variable "component" {
+ type = string
+ description = "The name of the terraformscaffold component calling this module"
+}
+
+variable "aws_account_id" {
+ type = string
+ description = "The AWS Account ID (numeric)"
+}
+
+variable "group" {
+ type = string
+ description = "The name of the tfscaffold group"
+ default = null
+}
+
+##
+# Variable specific to the module
+##
+
+# We presume this will always be specified. The default of {} will cause an error if a valid map is not specified.
+# If we ever want to define this but allow it to not be specified, then we must provide a default tag keypair will be applied
+# as the true default. In any other case default_tags should be removed from the module.
+variable "default_tags" {
+ type = map(string)
+ description = "Default tag map for application to all taggable resources in the module"
+ default = {}
+}
+
+variable "region" {
+ type = string
+ description = "The AWS Region"
+}
+
+variable "name" {
+ type = string
+ description = "A unique name to distinguish this module invocation from others within the same CSI scope"
+}
+
+variable "kms_key_arn" {
+ type = string
+ description = "KMS key arn to use for this function"
+}
+
+variable "log_retention_in_days" {
+ type = number
+ description = "The retention period in days for the Cloudwatch Logs events generated by the lambda function"
+}
+
+variable "event_cache_buffer_interval" {
+ type = number
+ description = "The buffer interval for data firehose"
+ default = 500
+}
+
+variable "enable_sns_delivery_logging" {
+ type = bool
+ description = "Enable SNS Delivery Failure Notifications"
+ default = true
+}
+
+variable "sns_success_logging_sample_percent" {
+ type = number
+ description = "Enable SNS Delivery Successful Sample Percentage"
+ default = 0
+}
+
+##
+# CloudWatch Anomaly Detection Variables
+##
+
+variable "enable_event_anomaly_detection" {
+ type = bool
+ description = "Enable CloudWatch anomaly detection alarm for SNS topic message publishing"
+ default = true
+}
+
+variable "event_anomaly_evaluation_periods" {
+ type = number
+ description = "Number of evaluation periods for the anomaly alarm. Each period is defined by event_anomaly_period."
+ default = 2
+}
+
+variable "event_anomaly_period" {
+ type = number
+ description = "The period in seconds over which the specified statistic is applied for anomaly detection. Minimum 300 seconds (5 minutes). Recommended: 300-600."
+ default = 300
+}
+
+variable "event_anomaly_band_width" {
+ type = number
+ description = "The width of the anomaly detection band. Higher values (e.g. 4-6) reduce sensitivity and noise, lower values (e.g. 2-3) increase sensitivity. Recommended: 2-4."
+ default = 3
+
+ validation {
+ condition = var.event_anomaly_band_width >= 2 && var.event_anomaly_band_width <= 10
+ error_message = "Band width must be between 2 and 10"
+ }
+}
+
+variable "log_level" {
+ type = string
+ description = "The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels"
+ default = "WARN"
+}
+
+variable "event_cache_expiry_days" {
+ type = number
+ description = "s3 archiving expiry in days"
+ default = 30
+}
+
+variable "enable_event_cache" {
+ type = bool
+ description = "Enable caching of events to an S3 bucket"
+ default = true
+}
+
+variable "enable_firehose_raw_message_delivery" {
+ type = bool
+ description = "Enables raw message delivery on firehose subscription"
+ default = false
+}
+
+variable "force_destroy" {
+ type = bool
+ description = "When enabled will force destroy event-cache S3 bucket"
+ default = false
+}
+
+variable "shared_infra_account_id" {
+ type = string
+ description = "The AWS Account ID of the shared infrastructure account"
+ default = "000000000000"
+}
+
+variable "glue_role_arn" {
+ type = string
+ description = "ARN of the Glue execution role from the parent"
+}
+
+variable "access_logging_bucket" {
+ type = string
+ description = "Name of S3 bucket to use for access logging"
+ default = ""
+}
diff --git a/infrastructure/terraform/modules/eventsub/versions.tf b/infrastructure/terraform/modules/eventsub/versions.tf
new file mode 100644
index 0000000..f8dc86e
--- /dev/null
+++ b/infrastructure/terraform/modules/eventsub/versions.tf
@@ -0,0 +1,9 @@
+
+terraform {
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ }
+ }
+ required_version = ">= 1.9.0"
+}
From 5a9c54211d5c08f3327abdd796ba47a82e4a86be Mon Sep 17 00:00:00 2001
From: Gareth Allan <157592212+gareth-allan@users.noreply.github.com>
Date: Tue, 28 Apr 2026 12:02:06 +0100
Subject: [PATCH 2/2] CCM-16644: Shorten s3 bucket name
---
.../terraform/modules/eventsub/module_s3bucket_event_cache.tf | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/infrastructure/terraform/modules/eventsub/module_s3bucket_event_cache.tf b/infrastructure/terraform/modules/eventsub/module_s3bucket_event_cache.tf
index d095c5c..6ad0b92 100644
--- a/infrastructure/terraform/modules/eventsub/module_s3bucket_event_cache.tf
+++ b/infrastructure/terraform/modules/eventsub/module_s3bucket_event_cache.tf
@@ -3,7 +3,7 @@ module "s3bucket_event_cache" {
count = var.enable_event_cache ? 1 : 0
- name = "eventsub_event_cache"
+ name = "es_event_cache"
aws_account_id = var.aws_account_id
region = var.region