From 2932190715be8176704c78e9848c213473ea252e Mon Sep 17 00:00:00 2001 From: Oneeb <258801025+oneeb-nhs@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:52:14 +0000 Subject: [PATCH 1/9] updated cloud watch alarms and metrics to notify when dynamodb accessed except lambda execution role --- infrastructure/stacks/api-layer/cloudwatch_alarms.tf | 10 ++++++++++ infrastructure/stacks/api-layer/cloudwatch_metrics.tf | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/infrastructure/stacks/api-layer/cloudwatch_alarms.tf b/infrastructure/stacks/api-layer/cloudwatch_alarms.tf index f85defbae..15f9e6b93 100644 --- a/infrastructure/stacks/api-layer/cloudwatch_alarms.tf +++ b/infrastructure/stacks/api-layer/cloudwatch_alarms.tf @@ -172,6 +172,16 @@ locals { alarm_description = "Multiple Lambda function changes detected within 10 minutes" actions_enabled = true } + DynamoDBTableReadOutsideLambdaRole = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "DynamoDB table read detected from non-Lambda execution role" + actions_enabled = true + } + } # API Gateway alarm configuration diff --git a/infrastructure/stacks/api-layer/cloudwatch_metrics.tf b/infrastructure/stacks/api-layer/cloudwatch_metrics.tf index d45bddeea..77e4180b0 100644 --- a/infrastructure/stacks/api-layer/cloudwatch_metrics.tf +++ b/infrastructure/stacks/api-layer/cloudwatch_metrics.tf @@ -114,6 +114,12 @@ locals { filter = "{($.eventSource=lambda.amazonaws.com) && (($.eventName=CreateFunction20150331) || ($.eventName=DeleteFunction20150331) || ($.eventName=UpdateFunctionCode20150331) || ($.eventName=UpdateFunctionConfiguration20150331))}" log_group_name = "NHSDAudit_trail_log_group" }, + { + name = "DynamoDBTableReadOutsideLambdaRole" + namespace = "security" + filter = "{($.eventSource=dynamodb.amazonaws.com) && (($.eventName=GetItem) || ($.eventName=Query) || ($.eventName=Scan)) && ($.requestParameters.tableName=\"${module.eligibility_status_table.table_name}\") && (($.userIdentity.sessionContext.sessionIssuer.arn !=\"${aws_iam_role.eligibility_lambda_role.arn}\")}" + log_group_name = "NHSDAudit_trail_log_group" + }, ] } From eb52145ecf773e2e69f5f58456735d02e6ad2f2e Mon Sep 17 00:00:00 2001 From: Oneeb <258801025+oneeb-nhs@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:05:07 +0000 Subject: [PATCH 2/9] updated cloudwatch config --- infrastructure/stacks/api-layer/cloudwatch_alarms.tf | 1 - infrastructure/stacks/api-layer/cloudwatch_metrics.tf | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/infrastructure/stacks/api-layer/cloudwatch_alarms.tf b/infrastructure/stacks/api-layer/cloudwatch_alarms.tf index 15f9e6b93..e4907aa82 100644 --- a/infrastructure/stacks/api-layer/cloudwatch_alarms.tf +++ b/infrastructure/stacks/api-layer/cloudwatch_alarms.tf @@ -181,7 +181,6 @@ locals { alarm_description = "DynamoDB table read detected from non-Lambda execution role" actions_enabled = true } - } # API Gateway alarm configuration diff --git a/infrastructure/stacks/api-layer/cloudwatch_metrics.tf b/infrastructure/stacks/api-layer/cloudwatch_metrics.tf index 77e4180b0..f9f2b79a4 100644 --- a/infrastructure/stacks/api-layer/cloudwatch_metrics.tf +++ b/infrastructure/stacks/api-layer/cloudwatch_metrics.tf @@ -117,8 +117,8 @@ locals { { name = "DynamoDBTableReadOutsideLambdaRole" namespace = "security" - filter = "{($.eventSource=dynamodb.amazonaws.com) && (($.eventName=GetItem) || ($.eventName=Query) || ($.eventName=Scan)) && ($.requestParameters.tableName=\"${module.eligibility_status_table.table_name}\") && (($.userIdentity.sessionContext.sessionIssuer.arn !=\"${aws_iam_role.eligibility_lambda_role.arn}\")}" - log_group_name = "NHSDAudit_trail_log_group" + filter = "{($.eventSource=dynamodb.amazonaws.com) && (($.eventName=GetItem) || ($.eventName=Query) || ($.eventName=Scan)) && ($.requestParameters.tableName=\"${module.eligibility_status_table.table_name}\") && (($.userIdentity.sessionContext.sessionIssuer.arn != \"${aws_iam_role.eligibility_lambda_role.arn}\") || ($.userIdentity.sessionContext.sessionIssuer.arn NOT EXISTS))}" + log_group_name = "test-dynamodb-aws-cloudtrail-logs-448049830832-2e96053a" }, ] } From 1d8c6cae4c3ec2f2a004eddf9d83257f3847c2fa Mon Sep 17 00:00:00 2001 From: Oneeb <258801025+oneeb-nhs@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:43:52 +0000 Subject: [PATCH 3/9] started code to add cloudtrail for dynamodb logs --- infrastructure/stacks/api-layer/cloudtrail.tf | 181 ++++++++++++++++++ .../stacks/api-layer/cloudwatch_metrics.tf | 4 +- infrastructure/stacks/api-layer/s3_buckets.tf | 9 + 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 infrastructure/stacks/api-layer/cloudtrail.tf diff --git a/infrastructure/stacks/api-layer/cloudtrail.tf b/infrastructure/stacks/api-layer/cloudtrail.tf new file mode 100644 index 000000000..705a3b8b8 --- /dev/null +++ b/infrastructure/stacks/api-layer/cloudtrail.tf @@ -0,0 +1,181 @@ +locals { + cloudtrail_name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}${var.project_name}-${var.environment}-audit-trail" +} + +resource "aws_kms_key" "cloudtrail_cmk" { + description = "KMS key for CloudTrail log file encryption" + deletion_window_in_days = 14 + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EnableRootPermissions" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "AllowCloudTrailEncryptLogs" + Effect = "Allow" + Principal = { + Service = "cloudtrail.amazonaws.com" + } + Action = [ + "kms:GenerateDataKey*", + "kms:DescribeKey", + "kms:Encrypt" + ] + Resource = "*" + Condition = { + StringEquals = { + "aws:SourceArn" = "arn:aws:cloudtrail:${var.default_aws_region}:${data.aws_caller_identity.current.account_id}:trail/${local.cloudtrail_name}" + } + StringLike = { + "kms:EncryptionContext:aws:cloudtrail:arn" = "arn:aws:cloudtrail:*:${data.aws_caller_identity.current.account_id}:trail/*" + } + } + } + ] + }) +} + +resource "aws_kms_alias" "cloudtrail_cmk" { + name = "alias/${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}${var.project_name}-${var.environment}-cloudtrail" + target_key_id = aws_kms_key.cloudtrail_cmk.id +} + +resource "aws_cloudwatch_log_group" "cloudtrail_logs" { + name = "NHSDAudit_trail_log_group" + retention_in_days = 365 + kms_key_id = aws_kms_key.cloudtrail_cmk.arn +} + +resource "aws_iam_role" "cloudtrail_to_cloudwatch" { + name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}${var.project_name}-${var.environment}-cloudtrail-cw-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "cloudtrail.amazonaws.com" + } + Action = "sts:AssumeRole" + } + ] + }) +} + +resource "aws_iam_role_policy" "cloudtrail_to_cloudwatch" { + name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}${var.project_name}-${var.environment}-cloudtrail-cw-policy" + role = aws_iam_role.cloudtrail_to_cloudwatch.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "${aws_cloudwatch_log_group.cloudtrail_logs.arn}:*" + } + ] + }) +} + +resource "aws_s3_bucket_policy" "cloudtrail_logs_bucket" { + bucket = module.s3_elid_cloudwatch_bucket.storage_bucket_id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "DenyInsecureTransport" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + module.s3_elid_cloudwatch_bucket.storage_bucket_arn, + "${module.s3_elid_cloudwatch_bucket.storage_bucket_arn}/*" + ] + Condition = { + Bool = { + "aws:SecureTransport" = "false" + } + } + }, + { + Sid = "AWSCloudTrailAclCheck" + Effect = "Allow" + Principal = { + Service = "cloudtrail.amazonaws.com" + } + Action = "s3:GetBucketAcl" + Resource = module.s3_elid_cloudwatch_bucket.storage_bucket_arn + Condition = { + StringEquals = { + "aws:SourceArn" = "arn:aws:cloudtrail:${var.default_aws_region}:${data.aws_caller_identity.current.account_id}:trail/${local.cloudtrail_name}" + } + } + }, + { + Sid = "AWSCloudTrailWrite" + Effect = "Allow" + Principal = { + Service = "cloudtrail.amazonaws.com" + } + Action = "s3:PutObject" + Resource = "${module.s3_elid_cloudwatch_bucket.storage_bucket_arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*" + Condition = { + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + "aws:SourceArn" = "arn:aws:cloudtrail:${var.default_aws_region}:${data.aws_caller_identity.current.account_id}:trail/${local.cloudtrail_name}" + } + } + } + ] + }) +} + +resource "aws_cloudtrail" "api_audit_trail" { + name = local.cloudtrail_name + s3_bucket_name = module.s3_elid_cloudwatch_bucket.storage_bucket_name + kms_key_id = aws_kms_key.cloudtrail_cmk.arn + include_global_service_events = true + is_multi_region_trail = false + enable_log_file_validation = true + + cloud_watch_logs_role_arn = aws_iam_role.cloudtrail_to_cloudwatch.arn + cloud_watch_logs_group_arn = "${aws_cloudwatch_log_group.cloudtrail_logs.arn}:*" + + # Keep management event coverage for security controls. + event_selector { + read_write_type = "All" + include_management_events = true + } + + # Capture DynamoDB read data events for the eligibility status table. + event_selector { + read_write_type = "ReadOnly" + include_management_events = false + + data_resource { + type = "AWS::DynamoDB::Table" + values = [module.eligibility_status_table.arn] + } + } + + depends_on = [ + aws_s3_bucket_policy.cloudtrail_logs_bucket, + aws_iam_role_policy.cloudtrail_to_cloudwatch + ] +} + diff --git a/infrastructure/stacks/api-layer/cloudwatch_metrics.tf b/infrastructure/stacks/api-layer/cloudwatch_metrics.tf index f9f2b79a4..e30ecad72 100644 --- a/infrastructure/stacks/api-layer/cloudwatch_metrics.tf +++ b/infrastructure/stacks/api-layer/cloudwatch_metrics.tf @@ -117,8 +117,8 @@ locals { { name = "DynamoDBTableReadOutsideLambdaRole" namespace = "security" - filter = "{($.eventSource=dynamodb.amazonaws.com) && (($.eventName=GetItem) || ($.eventName=Query) || ($.eventName=Scan)) && ($.requestParameters.tableName=\"${module.eligibility_status_table.table_name}\") && (($.userIdentity.sessionContext.sessionIssuer.arn != \"${aws_iam_role.eligibility_lambda_role.arn}\") || ($.userIdentity.sessionContext.sessionIssuer.arn NOT EXISTS))}" - log_group_name = "test-dynamodb-aws-cloudtrail-logs-448049830832-2e96053a" + filter = "{($.eventSource=dynamodb.amazonaws.com) && (($.eventName=GetItem) || ($.eventName=Query) || ($.eventName=Scan)) && ($.requestParameters.tableName=\"${module.eligibility_status_table.table_name}\") && ($.userIdentity.sessionContext.sessionIssuer.arn != \"${aws_iam_role.eligibility_lambda_role.arn}\")}" + log_group_name = "elid-aws-cloudtrail-logs" }, ] } diff --git a/infrastructure/stacks/api-layer/s3_buckets.tf b/infrastructure/stacks/api-layer/s3_buckets.tf index c2d924543..2b80cfbcd 100644 --- a/infrastructure/stacks/api-layer/s3_buckets.tf +++ b/infrastructure/stacks/api-layer/s3_buckets.tf @@ -57,3 +57,12 @@ module "s3_dq_metrics_bucket" { stack_name = local.stack_name workspace = terraform.workspace } + +module "s3_elid_cloudwatch_bucket" { + source = "../../modules/s3" + bucket_name = "elid-cloudwatch-logs" + environment = var.environment + project_name = var.project_name + stack_name = local.stack_name + workspace = terraform.workspace +} From 37addd7714989180ded6980ecc494890e1ddf8e2 Mon Sep 17 00:00:00 2001 From: Rob Bailiff Date: Fri, 20 Mar 2026 15:59:18 +0000 Subject: [PATCH 4/9] Separated out and refactored new terrafrom resources --- infrastructure/stacks/api-layer/cloudtrail.tf | 173 ++++-------------- infrastructure/stacks/api-layer/cloudwatch.tf | 8 + .../stacks/api-layer/cloudwatch_metrics.tf | 2 +- .../stacks/api-layer/iam_policies.tf | 84 +++++++++ infrastructure/stacks/api-layer/iam_roles.tf | 20 ++ infrastructure/stacks/api-layer/s3_buckets.tf | 4 +- 6 files changed, 146 insertions(+), 145 deletions(-) diff --git a/infrastructure/stacks/api-layer/cloudtrail.tf b/infrastructure/stacks/api-layer/cloudtrail.tf index 705a3b8b8..ba42e79f6 100644 --- a/infrastructure/stacks/api-layer/cloudtrail.tf +++ b/infrastructure/stacks/api-layer/cloudtrail.tf @@ -1,8 +1,26 @@ -locals { - cloudtrail_name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}${var.project_name}-${var.environment}-audit-trail" +resource "aws_cloudtrail" "data_events_trail" { + name = "${var.project_name}-${var.environment}-data-events-trail" + s3_bucket_name = module.s3_cloudtrail_bucket.storage_bucket_name + kms_key_id = aws_kms_key.cloudtrail_kms_key.arn + include_global_service_events = true + is_multi_region_trail = false + enable_log_file_validation = true + + cloud_watch_logs_role_arn = aws_iam_role.cloudtrail_cloudwatch_role.arn + cloud_watch_logs_group_arn = "${aws_cloudwatch_log_group.cloudtrail_log_group.arn}:*" + + event_selector { + read_write_type = "ReadOnly" + include_management_events = false + + data_resource { + type = "AWS::DynamoDB::Table" + values = [module.eligibility_status_table.arn] + } + } } -resource "aws_kms_key" "cloudtrail_cmk" { +resource "aws_kms_key" "cloudtrail_kms_key" { description = "KMS key for CloudTrail log file encryption" deletion_window_in_days = 14 enable_key_rotation = true @@ -31,151 +49,22 @@ resource "aws_kms_key" "cloudtrail_cmk" { "kms:Encrypt" ] Resource = "*" - Condition = { - StringEquals = { - "aws:SourceArn" = "arn:aws:cloudtrail:${var.default_aws_region}:${data.aws_caller_identity.current.account_id}:trail/${local.cloudtrail_name}" - } - StringLike = { - "kms:EncryptionContext:aws:cloudtrail:arn" = "arn:aws:cloudtrail:*:${data.aws_caller_identity.current.account_id}:trail/*" - } - } - } - ] - }) -} - -resource "aws_kms_alias" "cloudtrail_cmk" { - name = "alias/${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}${var.project_name}-${var.environment}-cloudtrail" - target_key_id = aws_kms_key.cloudtrail_cmk.id -} - -resource "aws_cloudwatch_log_group" "cloudtrail_logs" { - name = "NHSDAudit_trail_log_group" - retention_in_days = 365 - kms_key_id = aws_kms_key.cloudtrail_cmk.arn -} - -resource "aws_iam_role" "cloudtrail_to_cloudwatch" { - name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}${var.project_name}-${var.environment}-cloudtrail-cw-role" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Principal = { - Service = "cloudtrail.amazonaws.com" - } - Action = "sts:AssumeRole" - } - ] - }) -} - -resource "aws_iam_role_policy" "cloudtrail_to_cloudwatch" { - name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}${var.project_name}-${var.environment}-cloudtrail-cw-policy" - role = aws_iam_role.cloudtrail_to_cloudwatch.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = [ - "logs:CreateLogStream", - "logs:PutLogEvents" - ] - Resource = "${aws_cloudwatch_log_group.cloudtrail_logs.arn}:*" - } - ] - }) -} - -resource "aws_s3_bucket_policy" "cloudtrail_logs_bucket" { - bucket = module.s3_elid_cloudwatch_bucket.storage_bucket_id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "DenyInsecureTransport" - Effect = "Deny" - Principal = "*" - Action = "s3:*" - Resource = [ - module.s3_elid_cloudwatch_bucket.storage_bucket_arn, - "${module.s3_elid_cloudwatch_bucket.storage_bucket_arn}/*" - ] - Condition = { - Bool = { - "aws:SecureTransport" = "false" - } - } - }, - { - Sid = "AWSCloudTrailAclCheck" - Effect = "Allow" - Principal = { - Service = "cloudtrail.amazonaws.com" - } - Action = "s3:GetBucketAcl" - Resource = module.s3_elid_cloudwatch_bucket.storage_bucket_arn - Condition = { - StringEquals = { - "aws:SourceArn" = "arn:aws:cloudtrail:${var.default_aws_region}:${data.aws_caller_identity.current.account_id}:trail/${local.cloudtrail_name}" - } - } - }, - { - Sid = "AWSCloudTrailWrite" - Effect = "Allow" - Principal = { - Service = "cloudtrail.amazonaws.com" - } - Action = "s3:PutObject" - Resource = "${module.s3_elid_cloudwatch_bucket.storage_bucket_arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*" - Condition = { - StringEquals = { - "s3:x-amz-acl" = "bucket-owner-full-control" - "aws:SourceArn" = "arn:aws:cloudtrail:${var.default_aws_region}:${data.aws_caller_identity.current.account_id}:trail/${local.cloudtrail_name}" - } - } } ] }) -} - -resource "aws_cloudtrail" "api_audit_trail" { - name = local.cloudtrail_name - s3_bucket_name = module.s3_elid_cloudwatch_bucket.storage_bucket_name - kms_key_id = aws_kms_key.cloudtrail_cmk.arn - include_global_service_events = true - is_multi_region_trail = false - enable_log_file_validation = true - - cloud_watch_logs_role_arn = aws_iam_role.cloudtrail_to_cloudwatch.arn - cloud_watch_logs_group_arn = "${aws_cloudwatch_log_group.cloudtrail_logs.arn}:*" - # Keep management event coverage for security controls. - event_selector { - read_write_type = "All" - include_management_events = true + tags = { + environment = var.environment + project_name = var.project_name + stack_name = local.stack_name + workspace = terraform.workspace } - # Capture DynamoDB read data events for the eligibility status table. - event_selector { - read_write_type = "ReadOnly" - include_management_events = false - - data_resource { - type = "AWS::DynamoDB::Table" - values = [module.eligibility_status_table.arn] - } - } +} - depends_on = [ - aws_s3_bucket_policy.cloudtrail_logs_bucket, - aws_iam_role_policy.cloudtrail_to_cloudwatch - ] +# KMS key alias +resource "aws_kms_alias" "cloudtrail_kms_alias" { + name = "alias/${terraform.workspace == "default" ? "" : "${terraform.workspace}"}-cloudtrail-cmk" + target_key_id = aws_kms_key.cloudtrail_kms_key.key_id } diff --git a/infrastructure/stacks/api-layer/cloudwatch.tf b/infrastructure/stacks/api-layer/cloudwatch.tf index 6f9ca738a..e08e68c98 100644 --- a/infrastructure/stacks/api-layer/cloudwatch.tf +++ b/infrastructure/stacks/api-layer/cloudwatch.tf @@ -40,3 +40,11 @@ resource "aws_cloudwatch_log_group" "rotation_sfn_logs" { kms_key_id = module.secrets_manager.rotation_sns_key_arn retention_in_days = 365 } + +# CloudWatch Log Group for CloudTrail +resource "aws_cloudwatch_log_group" "cloudtrail_log_group" { + name = "elid-aws-cloudtrail-logs" + retention_in_days = 365 + kms_key_id = module.s3_elid_cloudwatch_bucket.storage_bucket_kms_key_id +} + diff --git a/infrastructure/stacks/api-layer/cloudwatch_metrics.tf b/infrastructure/stacks/api-layer/cloudwatch_metrics.tf index e30ecad72..2c7e147ef 100644 --- a/infrastructure/stacks/api-layer/cloudwatch_metrics.tf +++ b/infrastructure/stacks/api-layer/cloudwatch_metrics.tf @@ -118,7 +118,7 @@ locals { name = "DynamoDBTableReadOutsideLambdaRole" namespace = "security" filter = "{($.eventSource=dynamodb.amazonaws.com) && (($.eventName=GetItem) || ($.eventName=Query) || ($.eventName=Scan)) && ($.requestParameters.tableName=\"${module.eligibility_status_table.table_name}\") && ($.userIdentity.sessionContext.sessionIssuer.arn != \"${aws_iam_role.eligibility_lambda_role.arn}\")}" - log_group_name = "elid-aws-cloudtrail-logs" + log_group_name = aws_cloudwatch_log_group.cloudtrail_log_group.name }, ] } diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index 560e9266e..655a31607 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -813,3 +813,87 @@ resource "aws_iam_role_policy" "external_s3_kms_access_policy" { role = aws_iam_role.write_access_role[count.index].id policy = data.aws_iam_policy_document.s3_dq_kms_access_policy.json } + + +################################## +# Cloudtrail Bucket & KMS Policies +################################## + +# S3 Cloudtrail bucket policy +data "aws_iam_policy_document" "s3_cloudtrail_bucket_policy" { + statement { + sid = "AllowSSLRequestsOnly" + actions = [ + "s3:ListBucket", + "s3:GetBucketLocation", + "s3:GetObject", + "s3:PutObject" + ] + resources = [ + module.s3_cloudtrail_bucket.storage_bucket_arn, + "${module.s3_cloudtrail_bucket.storage_bucket_arn}/*", + ] + condition { + test = "Bool" + values = ["true"] + variable = "aws:SecureTransport" + } + } +} + +# Attach s3 Cloudtrail bucket policy to Cloudtrail role +resource "aws_iam_role_policy" "s3_cloudtrail_bucket_policy" { + count = length(aws_iam_role.cloudtrail_cloudwatch_role) + name = "S3CloudTrailBucketAccess" + role = aws_iam_role.cloudtrail_cloudwatch_role[count.index].id + policy = data.aws_iam_policy_document.s3_cloudtrail_bucket_policy.json +} + +# S3 Cloudtrail bucket KMS access policy +data "aws_iam_policy_document" "s3_cloudtrail_kms_access_policy" { + statement { + actions = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ] + resources = [ + module.s3_cloudtrail_bucket.storage_bucket_kms_key_arn + ] + } +} + +# Attach S3 Cloudtrail bucket KMS policy to Cloudtrail role +resource "aws_iam_role_policy" "s3_cloudtrail_kms_access_policy" { + count = length(aws_iam_role.cloudtrail_cloudwatch_role) + name = "S3CloudTrailKMSAccess" + role = aws_iam_role.cloudtrail_cloudwatch_role[count.index].id + policy = data.aws_iam_policy_document.s3_cloudtrail_kms_access_policy.json +} + +# CloudWatch Logs permissions policy for CloudTrail +data "aws_iam_policy_document" "cloudtrail_cloudwatch_policy" { + statement { + effect = "Allow" + actions = [ + "logs:PutLogEvents", + "logs:CreateLogGroup", + "logs:CreateLogStream" + ] + resources = [ + aws_cloudwatch_log_group.cloudtrail_log_group.arn, + "${aws_cloudwatch_log_group.cloudtrail_log_group.arn}:*" + ] + + } +} + +# Attach CloudTrail CloudWatch Logs policy to CloudTrail role +resource "aws_iam_role_policy" "cloudtrail_cloudwatch_policy" { + count = length(aws_iam_role.cloudtrail_cloudwatch_role) + name = "CloudTrailCloudWatchLogsAccess" + role = aws_iam_role.cloudtrail_cloudwatch_role[count.index].id + policy = data.aws_iam_policy_document.cloudtrail_cloudwatch_policy.json +} diff --git a/infrastructure/stacks/api-layer/iam_roles.tf b/infrastructure/stacks/api-layer/iam_roles.tf index e4c3dbe23..a6b57e1ca 100644 --- a/infrastructure/stacks/api-layer/iam_roles.tf +++ b/infrastructure/stacks/api-layer/iam_roles.tf @@ -142,3 +142,23 @@ resource "aws_iam_role_policy_attachment" "rotation_vpc_access" { role = aws_iam_role.rotation_lambda_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" } + +# IAM role for CloudTrail to write to CloudWatch Logs +resource "aws_iam_role" "cloudtrail_cloudwatch_role" { + name = "${var.project_name}-${var.environment}-cloudtrail-cloudwatch-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "cloudtrail.amazonaws.com" + } + } + ] + }) + permissions_boundary = aws_iam_policy.assumed_role_permissions_boundary.arn +} + diff --git a/infrastructure/stacks/api-layer/s3_buckets.tf b/infrastructure/stacks/api-layer/s3_buckets.tf index 2b80cfbcd..8e7b1ab35 100644 --- a/infrastructure/stacks/api-layer/s3_buckets.tf +++ b/infrastructure/stacks/api-layer/s3_buckets.tf @@ -58,9 +58,9 @@ module "s3_dq_metrics_bucket" { workspace = terraform.workspace } -module "s3_elid_cloudwatch_bucket" { +module "s3_cloudtrail_bucket" { source = "../../modules/s3" - bucket_name = "elid-cloudwatch-logs" + bucket_name = "eli-cloudwatch-logs" environment = var.environment project_name = var.project_name stack_name = local.stack_name From 1d9f3ea47930feeea263d5f4088125912d5c2917 Mon Sep 17 00:00:00 2001 From: Rob Bailiff Date: Fri, 20 Mar 2026 16:22:17 +0000 Subject: [PATCH 5/9] Fixed some errors --- infrastructure/stacks/api-layer/cloudwatch.tf | 2 +- infrastructure/stacks/api-layer/iam_policies.tf | 9 +++------ infrastructure/stacks/api-layer/iam_roles.tf | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/infrastructure/stacks/api-layer/cloudwatch.tf b/infrastructure/stacks/api-layer/cloudwatch.tf index e08e68c98..6cf375a9a 100644 --- a/infrastructure/stacks/api-layer/cloudwatch.tf +++ b/infrastructure/stacks/api-layer/cloudwatch.tf @@ -45,6 +45,6 @@ resource "aws_cloudwatch_log_group" "rotation_sfn_logs" { resource "aws_cloudwatch_log_group" "cloudtrail_log_group" { name = "elid-aws-cloudtrail-logs" retention_in_days = 365 - kms_key_id = module.s3_elid_cloudwatch_bucket.storage_bucket_kms_key_id + kms_key_id = module.s3_cloudtrail_bucket.storage_bucket_kms_key_id } diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index 655a31607..bdcad7dd9 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -843,9 +843,8 @@ data "aws_iam_policy_document" "s3_cloudtrail_bucket_policy" { # Attach s3 Cloudtrail bucket policy to Cloudtrail role resource "aws_iam_role_policy" "s3_cloudtrail_bucket_policy" { - count = length(aws_iam_role.cloudtrail_cloudwatch_role) name = "S3CloudTrailBucketAccess" - role = aws_iam_role.cloudtrail_cloudwatch_role[count.index].id + role = aws_iam_role.cloudtrail_cloudwatch_role.id policy = data.aws_iam_policy_document.s3_cloudtrail_bucket_policy.json } @@ -867,9 +866,8 @@ data "aws_iam_policy_document" "s3_cloudtrail_kms_access_policy" { # Attach S3 Cloudtrail bucket KMS policy to Cloudtrail role resource "aws_iam_role_policy" "s3_cloudtrail_kms_access_policy" { - count = length(aws_iam_role.cloudtrail_cloudwatch_role) name = "S3CloudTrailKMSAccess" - role = aws_iam_role.cloudtrail_cloudwatch_role[count.index].id + role = aws_iam_role.cloudtrail_cloudwatch_role.id policy = data.aws_iam_policy_document.s3_cloudtrail_kms_access_policy.json } @@ -892,8 +890,7 @@ data "aws_iam_policy_document" "cloudtrail_cloudwatch_policy" { # Attach CloudTrail CloudWatch Logs policy to CloudTrail role resource "aws_iam_role_policy" "cloudtrail_cloudwatch_policy" { - count = length(aws_iam_role.cloudtrail_cloudwatch_role) name = "CloudTrailCloudWatchLogsAccess" - role = aws_iam_role.cloudtrail_cloudwatch_role[count.index].id + role = aws_iam_role.cloudtrail_cloudwatch_role.id policy = data.aws_iam_policy_document.cloudtrail_cloudwatch_policy.json } diff --git a/infrastructure/stacks/api-layer/iam_roles.tf b/infrastructure/stacks/api-layer/iam_roles.tf index a6b57e1ca..6692c4a01 100644 --- a/infrastructure/stacks/api-layer/iam_roles.tf +++ b/infrastructure/stacks/api-layer/iam_roles.tf @@ -145,7 +145,7 @@ resource "aws_iam_role_policy_attachment" "rotation_vpc_access" { # IAM role for CloudTrail to write to CloudWatch Logs resource "aws_iam_role" "cloudtrail_cloudwatch_role" { - name = "${var.project_name}-${var.environment}-cloudtrail-cloudwatch-role" + name = "cloudtrail-cloudwatch-role" assume_role_policy = jsonencode({ Version = "2012-10-17" From 00f511d9157ee4936cc080b1b780967375aac929 Mon Sep 17 00:00:00 2001 From: Oneeb <258801025+oneeb-nhs@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:05:54 +0000 Subject: [PATCH 6/9] resolved PR comments --- infrastructure/stacks/api-layer/cloudtrail.tf | 3 +-- infrastructure/stacks/api-layer/cloudwatch.tf | 5 ++--- .../stacks/api-layer/cloudwatch_metrics.tf | 2 +- infrastructure/stacks/api-layer/iam_policies.tf | 17 +++++++++-------- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/infrastructure/stacks/api-layer/cloudtrail.tf b/infrastructure/stacks/api-layer/cloudtrail.tf index ba42e79f6..507162a68 100644 --- a/infrastructure/stacks/api-layer/cloudtrail.tf +++ b/infrastructure/stacks/api-layer/cloudtrail.tf @@ -64,7 +64,6 @@ resource "aws_kms_key" "cloudtrail_kms_key" { # KMS key alias resource "aws_kms_alias" "cloudtrail_kms_alias" { - name = "alias/${terraform.workspace == "default" ? "" : "${terraform.workspace}"}-cloudtrail-cmk" + name = "alias/${var.project_name}-${var.environment}-cloudtrail-cmk" target_key_id = aws_kms_key.cloudtrail_kms_key.key_id } - diff --git a/infrastructure/stacks/api-layer/cloudwatch.tf b/infrastructure/stacks/api-layer/cloudwatch.tf index 6cf375a9a..b693a796d 100644 --- a/infrastructure/stacks/api-layer/cloudwatch.tf +++ b/infrastructure/stacks/api-layer/cloudwatch.tf @@ -43,8 +43,7 @@ resource "aws_cloudwatch_log_group" "rotation_sfn_logs" { # CloudWatch Log Group for CloudTrail resource "aws_cloudwatch_log_group" "cloudtrail_log_group" { - name = "elid-aws-cloudtrail-logs" + name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}elid-aws-cloudtrail-logs" retention_in_days = 365 - kms_key_id = module.s3_cloudtrail_bucket.storage_bucket_kms_key_id + kms_key_id = aws_kms_key.cloudtrail_kms_key.arn } - diff --git a/infrastructure/stacks/api-layer/cloudwatch_metrics.tf b/infrastructure/stacks/api-layer/cloudwatch_metrics.tf index 2c7e147ef..3cbf09b45 100644 --- a/infrastructure/stacks/api-layer/cloudwatch_metrics.tf +++ b/infrastructure/stacks/api-layer/cloudwatch_metrics.tf @@ -117,7 +117,7 @@ locals { { name = "DynamoDBTableReadOutsideLambdaRole" namespace = "security" - filter = "{($.eventSource=dynamodb.amazonaws.com) && (($.eventName=GetItem) || ($.eventName=Query) || ($.eventName=Scan)) && ($.requestParameters.tableName=\"${module.eligibility_status_table.table_name}\") && ($.userIdentity.sessionContext.sessionIssuer.arn != \"${aws_iam_role.eligibility_lambda_role.arn}\")}" + filter = "{($.eventSource=dynamodb.amazonaws.com) && (($.eventName=GetItem) || ($.eventName=Query) || ($.eventName=Scan) || ($.eventName=BatchGetItem) || ($.eventName=BatchWriteItem)) && ($.requestParameters.tableName=\"${module.eligibility_status_table.table_name}\") && ($.userIdentity.sessionContext.sessionIssuer.arn != \"${aws_iam_role.eligibility_lambda_role.arn}\")}" log_group_name = aws_cloudwatch_log_group.cloudtrail_log_group.name }, ] diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index bdcad7dd9..08fdebc2b 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -824,27 +824,28 @@ data "aws_iam_policy_document" "s3_cloudtrail_bucket_policy" { statement { sid = "AllowSSLRequestsOnly" actions = [ - "s3:ListBucket", - "s3:GetBucketLocation", - "s3:GetObject", - "s3:PutObject" + "s3:*" ] + effect = "Deny" resources = [ module.s3_cloudtrail_bucket.storage_bucket_arn, "${module.s3_cloudtrail_bucket.storage_bucket_arn}/*", ] + principals { + type = "*" + identifiers = ["*"] + } condition { test = "Bool" - values = ["true"] + values = ["false"] variable = "aws:SecureTransport" } } } # Attach s3 Cloudtrail bucket policy to Cloudtrail role -resource "aws_iam_role_policy" "s3_cloudtrail_bucket_policy" { - name = "S3CloudTrailBucketAccess" - role = aws_iam_role.cloudtrail_cloudwatch_role.id +resource "aws_s3_bucket_policy" "s3_cloudtrail_bucket_policy" { + bucket = module.s3_cloudtrail_bucket.storage_bucket_id policy = data.aws_iam_policy_document.s3_cloudtrail_bucket_policy.json } From 5a0ef6f04ff713017d097ee7a029b35ba19c515a Mon Sep 17 00:00:00 2001 From: Oneeb <258801025+oneeb-nhs@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:38:46 +0000 Subject: [PATCH 7/9] updated policies --- infrastructure/stacks/api-layer/cloudtrail.tf | 32 +++++++++++++++++++ infrastructure/stacks/api-layer/cloudwatch.tf | 2 +- .../stacks/api-layer/iam_policies.tf | 24 +++++++++++++- infrastructure/stacks/api-layer/iam_roles.tf | 8 ++++- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/infrastructure/stacks/api-layer/cloudtrail.tf b/infrastructure/stacks/api-layer/cloudtrail.tf index 507162a68..6d71549be 100644 --- a/infrastructure/stacks/api-layer/cloudtrail.tf +++ b/infrastructure/stacks/api-layer/cloudtrail.tf @@ -67,3 +67,35 @@ resource "aws_kms_alias" "cloudtrail_kms_alias" { name = "alias/${var.project_name}-${var.environment}-cloudtrail-cmk" target_key_id = aws_kms_key.cloudtrail_kms_key.key_id } + +# KMS key policy to allow CloudTrail and CloudWatch Logs to use the key for encryption and decryption +resource "aws_kms_key_policy" "cloudtrail_kms_key_policy" { + key_id = aws_kms_key.cloudtrail_kms_key.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = "kms:*" + Resource = "*" + }, + { + Effect = "Allow" + Principal = { + Service = "logs.amazonaws.com" + } + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ] + Resource = "*" + } + ] + }) +} diff --git a/infrastructure/stacks/api-layer/cloudwatch.tf b/infrastructure/stacks/api-layer/cloudwatch.tf index b693a796d..2b1b603d1 100644 --- a/infrastructure/stacks/api-layer/cloudwatch.tf +++ b/infrastructure/stacks/api-layer/cloudwatch.tf @@ -45,5 +45,5 @@ resource "aws_cloudwatch_log_group" "rotation_sfn_logs" { resource "aws_cloudwatch_log_group" "cloudtrail_log_group" { name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}elid-aws-cloudtrail-logs" retention_in_days = 365 - kms_key_id = aws_kms_key.cloudtrail_kms_key.arn + kms_key_id = aws_kms_alias.cloudtrail_kms_alias.arn } diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index 08fdebc2b..6e7d1b15d 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -822,7 +822,29 @@ resource "aws_iam_role_policy" "external_s3_kms_access_policy" { # S3 Cloudtrail bucket policy data "aws_iam_policy_document" "s3_cloudtrail_bucket_policy" { statement { - sid = "AllowSSLRequestsOnly" + sid = "AllowS3SSLRequestsOnly" + actions = [ + "s3:ListBucket", + "s3:GetBucketLocation", + "s3:GetObject", + "s3:PutObject" + ] + resources = [ + module.s3_cloudtrail_bucket.storage_bucket_arn, + "${module.s3_cloudtrail_bucket.storage_bucket_arn}/*", + ] + principals { + type = "*" + identifiers = ["*"] + } + condition { + test = "Bool" + values = ["true"] + variable = "aws:SecureTransport" + } + } + statement { + sid = "DenyS3NonSSLRequests" actions = [ "s3:*" ] diff --git a/infrastructure/stacks/api-layer/iam_roles.tf b/infrastructure/stacks/api-layer/iam_roles.tf index 6692c4a01..d66ae516d 100644 --- a/infrastructure/stacks/api-layer/iam_roles.tf +++ b/infrastructure/stacks/api-layer/iam_roles.tf @@ -160,5 +160,11 @@ resource "aws_iam_role" "cloudtrail_cloudwatch_role" { ] }) permissions_boundary = aws_iam_policy.assumed_role_permissions_boundary.arn -} + tags = { + environment = var.environment + project_name = var.project_name + stack_name = local.stack_name + workspace = terraform.workspace + } +} From 2174e0c935693597c15f18fd2dcfe7ac76f624d5 Mon Sep 17 00:00:00 2001 From: Oneeb <258801025+oneeb-nhs@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:20:57 +0000 Subject: [PATCH 8/9] updated principals for s3 cloud trail bucket policy and added s3:GetBucketAcl in actions --- infrastructure/stacks/api-layer/iam_policies.tf | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index 6e7d1b15d..61f803c51 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -827,15 +827,16 @@ data "aws_iam_policy_document" "s3_cloudtrail_bucket_policy" { "s3:ListBucket", "s3:GetBucketLocation", "s3:GetObject", - "s3:PutObject" + "s3:PutObject", + "s3:GetBucketAcl" ] resources = [ module.s3_cloudtrail_bucket.storage_bucket_arn, "${module.s3_cloudtrail_bucket.storage_bucket_arn}/*", ] principals { - type = "*" - identifiers = ["*"] + type = "Service" + identifiers = ["cloudtrail.amazonaws.com"] } condition { test = "Bool" From b99dda39e3e80affe9cdc25357bdc9618a7509fb Mon Sep 17 00:00:00 2001 From: Rob Bailiff Date: Tue, 24 Mar 2026 09:42:53 +0000 Subject: [PATCH 9/9] Skip unwanted checkov flags --- infrastructure/stacks/api-layer/cloudtrail.tf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/infrastructure/stacks/api-layer/cloudtrail.tf b/infrastructure/stacks/api-layer/cloudtrail.tf index 6d71549be..71f5302a6 100644 --- a/infrastructure/stacks/api-layer/cloudtrail.tf +++ b/infrastructure/stacks/api-layer/cloudtrail.tf @@ -1,4 +1,6 @@ resource "aws_cloudtrail" "data_events_trail" { + #checkov:skip=CKV_AWS_67: Ensure CloudTrail is enabled in all Regions + #checkov:skip=CKV_AWS_252: Ensure CloudTrail defines an SNS Topic name = "${var.project_name}-${var.environment}-data-events-trail" s3_bucket_name = module.s3_cloudtrail_bucket.storage_bucket_name kms_key_id = aws_kms_key.cloudtrail_kms_key.arn