diff --git a/.github/workflows/base-deploy.yml b/.github/workflows/base-deploy.yml index f5c502647..fc56410aa 100644 --- a/.github/workflows/base-deploy.yml +++ b/.github/workflows/base-deploy.yml @@ -202,6 +202,7 @@ jobs: TF_VAR_API_PRIVATE_KEY_CERT: ${{ secrets.API_PRIVATE_KEY_CERT }} TF_VAR_SPLUNK_HEC_TOKEN: ${{ secrets.SPLUNK_HEC_TOKEN }} TF_VAR_SPLUNK_HEC_ENDPOINT: ${{ secrets.SPLUNK_HEC_ENDPOINT }} + TF_VAR_OPERATOR_EMAILS: ${{ vars.SECRET_ROTATION_OPERATOR_EMAILS }} working-directory: ./infrastructure shell: bash diff --git a/infrastructure/.gitignore b/infrastructure/.gitignore index e235b6896..dacb20cdf 100644 --- a/infrastructure/.gitignore +++ b/infrastructure/.gitignore @@ -38,3 +38,8 @@ override.tf.json # Ignore CLI configuration files .terraformrc terraform.rc + +# Ignore secret rotation lambda zip files + +stacks/api-layer/scripts/create_pending_secret.zip +stacks/api-layer/scripts/promote_to_current.zip diff --git a/infrastructure/modules/secrets_manager/kms.tf b/infrastructure/modules/secrets_manager/kms.tf index abb9e1d16..81f722595 100644 --- a/infrastructure/modules/secrets_manager/kms.tf +++ b/infrastructure/modules/secrets_manager/kms.tf @@ -57,3 +57,59 @@ resource "aws_kms_key" "secrets_cmk" { }) tags = var.tags } + +resource "aws_kms_key" "rotation_sns_cmk" { + description = "KMS key for SNS topic encryption (CLI Login Notifications)" + deletion_window_in_days = 14 + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "Enable IAM User Permissions" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "Allow SNS and EventBridge Usage" + Effect = "Allow" + Principal = { + Service = [ + "sns.amazonaws.com", + "events.amazonaws.com" + ] + } + Action = [ + "kms:Decrypt", + "kms:GenerateDataKey" + ] + Resource = "*" + }, + { + Sid = "Allow CloudWatch Logs Encryption" + Effect = "Allow" + Principal = { + Service = "logs.${var.region}.amazonaws.com" + } + Action = [ + "kms:Encrypt*", + "kms:Decrypt*", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:Describe*" + ] + Resource = "*" + Condition = { + ArnLike = { + "kms:EncryptionContext:aws:logs:arn": "arn:aws:logs:${var.region}:${data.aws_caller_identity.current.account_id}:log-group:/aws/stepfunctions/SecretRotationWorkflow" + } + } + } + ] + }) +} diff --git a/infrastructure/modules/secrets_manager/outputs.tf b/infrastructure/modules/secrets_manager/outputs.tf index c2cf721e2..7a45ede74 100644 --- a/infrastructure/modules/secrets_manager/outputs.tf +++ b/infrastructure/modules/secrets_manager/outputs.tf @@ -6,3 +6,14 @@ output "aws_hashing_secret_name" { value = aws_secretsmanager_secret.hashing_secret.name } +output "kms_key_arn" { + value = aws_kms_key.secrets_cmk.arn +} + +output "rotation_sns_key_id" { + value = aws_kms_key.rotation_sns_cmk.key_id +} + +output "rotation_sns_key_arn" { + value = aws_kms_key.rotation_sns_cmk.arn +} diff --git a/infrastructure/stacks/api-layer/cloudwatch.tf b/infrastructure/stacks/api-layer/cloudwatch.tf index b9731f366..6f9ca738a 100644 --- a/infrastructure/stacks/api-layer/cloudwatch.tf +++ b/infrastructure/stacks/api-layer/cloudwatch.tf @@ -34,3 +34,9 @@ resource "aws_cloudwatch_log_stream" "firehose_audit_stream" { aws_cloudwatch_log_group.firehose_audit ] } + +resource "aws_cloudwatch_log_group" "rotation_sfn_logs" { + name = "/aws/stepfunctions/SecretRotationWorkflow" + kms_key_id = module.secrets_manager.rotation_sns_key_arn + retention_in_days = 365 +} diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index 4bcae5205..0fd67e453 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -561,3 +561,101 @@ resource "aws_iam_role_policy" "external_secret_read_policy_attachment" { role = aws_iam_role.write_access_role[count.index].id policy = data.aws_iam_policy_document.secrets_access_policy.json } + +# --- Rotation Logic Policies --- +resource "aws_iam_policy" "rotation_secrets_policy" { + name = "rotation_secrets_policy" + description = "Allow Lambda to read/write ONLY the hashing secret" + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Sid = "ManageSecretBits", + Effect = "Allow", + Action = [ + "secretsmanager:DescribeSecret", + "secretsmanager:PutSecretValue", + "secretsmanager:UpdateSecretVersionStage", + "secretsmanager:GetSecretValue" + ], + Resource = module.secrets_manager.aws_hashing_secret_arn + }, + { + Sid = "AllowKMSKeyUsage", + Effect = "Allow", + Action = [ + "kms:Decrypt", + "kms:GenerateDataKey" + ], + Resource = module.secrets_manager.kms_key_arn + }, + { + Sid = "BasicLogging", + Effect = "Allow", + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + Resource = [ + "arn:aws:logs:${var.default_aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/${aws_lambda_function.create_secret_lambda.function_name}:*", + "arn:aws:logs:${var.default_aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/${aws_lambda_function.promote_secret_lambda.function_name}:*" + ] + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "attach_rotation_secrets" { + role = aws_iam_role.rotation_lambda_role.name + policy_arn = aws_iam_policy.rotation_secrets_policy.arn +} + +resource "aws_iam_policy" "rotation_sfn_policy" { + name = "rotation_sfn_policy" + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Action = "lambda:InvokeFunction", + Resource = [ + aws_lambda_function.create_secret_lambda.arn, + aws_lambda_function.promote_secret_lambda.arn + ] + }, + { + Effect = "Allow", + Action = "sns:Publish", + Resource = aws_sns_topic.secret_rotation.arn + }, + { + Effect = "Allow", + Action = [ + "kms:Decrypt", + "kms:GenerateDataKey" + ], + Resource = module.secrets_manager.rotation_sns_key_arn + }, + { + Effect = "Allow", + Action = [ + "logs:CreateLogDelivery", + "logs:GetLogDelivery", + "logs:UpdateLogDelivery", + "logs:DeleteLogDelivery", + "logs:ListLogDeliveries", + "logs:PutResourcePolicy", + "logs:DescribeResourcePolicies", + "logs:DescribeLogGroups" + ], + Resource = "*" + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "attach_rotation_sfn" { + role = aws_iam_role.rotation_sfn_role.name + policy_arn = aws_iam_policy.rotation_sfn_policy.arn +} diff --git a/infrastructure/stacks/api-layer/iam_roles.tf b/infrastructure/stacks/api-layer/iam_roles.tf index 0289bb8cf..e4c3dbe23 100644 --- a/infrastructure/stacks/api-layer/iam_roles.tf +++ b/infrastructure/stacks/api-layer/iam_roles.tf @@ -74,3 +74,71 @@ resource "aws_iam_role" "eligibility_audit_firehose_role" { assume_role_policy = data.aws_iam_policy_document.firehose_assume_role.json permissions_boundary = aws_iam_policy.assumed_role_permissions_boundary.arn } + +# --- Secret Rotation Roles --- +resource "aws_iam_role" "rotation_lambda_role" { + name = "secret_rotation_lambda_role" + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Action = "sts:AssumeRole", + Effect = "Allow", + Principal = { Service = "lambda.amazonaws.com" } + }] + }) +} + +resource "aws_iam_role" "rotation_sfn_role" { + name = "secret_rotation_workflow_role" + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Action = "sts:AssumeRole", + Effect = "Allow", + Principal = { Service = "states.amazonaws.com" } + }] + }) +} + +# ----------------------------------------------------------------------------- +# IAM Role: Allow EventBridge to Start the Step Function +# ----------------------------------------------------------------------------- + +resource "aws_iam_role" "eventbridge_sfn_invoke_role" { + name = "eventbridge_invoke_sfn_role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "events.amazonaws.com" } + } + ] + }) +} + +resource "aws_iam_policy" "eventbridge_sfn_policy" { + name = "eventbridge_sfn_start_policy" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = "states:StartExecution" + Resource = aws_sfn_state_machine.rotation_machine.arn + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "attach_eb_sfn" { + role = aws_iam_role.eventbridge_sfn_invoke_role.name + policy_arn = aws_iam_policy.eventbridge_sfn_policy.arn +} + +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" +} diff --git a/infrastructure/stacks/api-layer/lambda.tf b/infrastructure/stacks/api-layer/lambda.tf index 87e32a4f5..9b31fee49 100644 --- a/infrastructure/stacks/api-layer/lambda.tf +++ b/infrastructure/stacks/api-layer/lambda.tf @@ -33,3 +33,65 @@ module "eligibility_signposting_lambda_function" { provisioned_concurrency_count = 5 api_domain_name = local.api_domain_name } + +# ----------------------------------------------------------------------------- +# Secret rotation lambdas +# ----------------------------------------------------------------------------- + +# 1. Generator Lambda +data "archive_file" "create_zip" { + type = "zip" + source_file = "${path.module}/scripts/create_pending_secret.py" + output_path = "${path.module}/scripts/create_pending_secret.zip" +} + +resource "aws_lambda_function" "create_secret_lambda" { + #checkov:skip=CKV_AWS_116: No deadletter queue is required for this Lambda function + #checkov:skip=CKV_AWS_272: Skipping code signing but flagged to create ticket to investigate on ELI-238 + #checkov:skip=CKV_AWS_50: No x-ray needed for this function + #checkov:skip=CKV_AWS_173: No encryption needed for the secret name + + filename = data.archive_file.create_zip.output_path + function_name = "${terraform.workspace}-CreatePendingSecretFunction" + role = aws_iam_role.rotation_lambda_role.arn + handler = "create_pending_secret.lambda_handler" + runtime = "python3.13" + timeout = 30 + reserved_concurrent_executions = 1 + environment { + variables = { SECRET_NAME = module.secrets_manager.aws_hashing_secret_name } + } + vpc_config { + subnet_ids = [for s in data.aws_subnet.private_subnets : s.id] + security_group_ids = [data.aws_security_group.main_sg.id] + } +} + +# 2. Promoter Lambda +data "archive_file" "promote_zip" { + type = "zip" + source_file = "${path.module}/scripts/promote_to_current.py" + output_path = "${path.module}/scripts/promote_to_current.zip" +} + +resource "aws_lambda_function" "promote_secret_lambda" { + #checkov:skip=CKV_AWS_116: No deadletter queue is required for this Lambda function + #checkov:skip=CKV_AWS_272: Skipping code signing but flagged to create ticket to investigate on ELI-238 + #checkov:skip=CKV_AWS_50: No x-ray needed for this function + #checkov:skip=CKV_AWS_173: No encryption needed for the secret name + + filename = data.archive_file.promote_zip.output_path + function_name = "${terraform.workspace}-PromoteToCurrentFunction" + role = aws_iam_role.rotation_lambda_role.arn + handler = "promote_to_current.lambda_handler" + runtime = "python3.13" + timeout = 30 + reserved_concurrent_executions = 1 + environment { + variables = { SECRET_NAME = module.secrets_manager.aws_hashing_secret_name } + } + vpc_config { + subnet_ids = [for s in data.aws_subnet.private_subnets : s.id] + security_group_ids = [data.aws_security_group.main_sg.id] + } +} diff --git a/infrastructure/stacks/api-layer/scripts/__init__.py b/infrastructure/stacks/api-layer/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/infrastructure/stacks/api-layer/scripts/create_pending_secret.py b/infrastructure/stacks/api-layer/scripts/create_pending_secret.py new file mode 100644 index 000000000..5eaf3c130 --- /dev/null +++ b/infrastructure/stacks/api-layer/scripts/create_pending_secret.py @@ -0,0 +1,78 @@ +import json +import logging +import os +import secrets +import string + +import boto3 + +SECRET_NAME = os.environ.get("SECRET_NAME") +REGION_NAME = os.environ.get("AWS_REGION") + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +class PendingVersionExistsError(Exception): + pass + + +def generate_password(length: int = 32) -> str: + """Generates a secure random password.""" + alphabet = string.ascii_letters + string.digits + "!@#$%^&*" + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def lambda_handler( + event: dict, # noqa: ARG001 + context: object, +) -> dict: + sm_client = boto3.client("secretsmanager", region_name=REGION_NAME) + + logger.info( + json.dumps( + { + "event": "rotation_started", + "request_id": context.aws_request_id, + "secret_name": SECRET_NAME, + "function": "create_pending_secret", + } + ) + ) + + try: + metadata = sm_client.describe_secret(SecretId=SECRET_NAME) + for version_id, stages in metadata.get("VersionIdsToStages", {}).items(): + if "AWSPENDING" in stages: + msg = f"Pending version already exists with version_id: {version_id}." + + logger.warning( + json.dumps( + { + "event": "rotation_aborted", + "reason": "pending_version_exists", + "pending_version_id": version_id, + } + ) + ) + + raise PendingVersionExistsError(msg) + except sm_client.exceptions.ResourceNotFoundException: + logger.info("Secret not found. Proceeding to create (assuming it will be initialized).") + + new_password = generate_password() + + try: + resp = sm_client.put_secret_value(SecretId=SECRET_NAME, SecretString=new_password, VersionStages=["AWSPENDING"]) + + logger.info( + json.dumps({"event": "pending_version_created", "version_id": resp["VersionId"], "status": "success"}) + ) + return {"status": "success", "secret_name": SECRET_NAME, "version_id": resp["VersionId"]} + + except sm_client.exceptions.ResourceNotFoundException as e: + exception_message = f"The secret '{SECRET_NAME}' was not found in region '{REGION_NAME}'." + raise sm_client.exceptions.ResourceNotFoundException(exception_message) from e + except Exception as e: + logger.exception(json.dumps({"event": "rotation_failed", "type": type(e).__name__})) + raise diff --git a/infrastructure/stacks/api-layer/scripts/promote_to_current.py b/infrastructure/stacks/api-layer/scripts/promote_to_current.py new file mode 100644 index 000000000..84f2053e0 --- /dev/null +++ b/infrastructure/stacks/api-layer/scripts/promote_to_current.py @@ -0,0 +1,77 @@ +import json +import logging +import os + +import boto3 + +SECRET_NAME = os.environ.get("SECRET_NAME") +REGION_NAME = os.environ.get("AWS_REGION") + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def lambda_handler( + event: dict, # noqa: ARG001 + context: object, +) -> dict: + sm_client = boto3.client("secretsmanager", region_name=REGION_NAME) + logger.info( + json.dumps( + { + "event": "promotion_started", + "request_id": context.aws_request_id, + "secret_name": SECRET_NAME, + "function": "promote_to_current", + } + ) + ) + + try: + metadata = sm_client.describe_secret(SecretId=SECRET_NAME) + pending_version = None + current_version = None + for version_id, stages in metadata["VersionIdsToStages"].items(): + if "AWSPENDING" in stages: + pending_version = version_id + if "AWSCURRENT" in stages: + current_version = version_id + + if pending_version: + logger.info( + json.dumps( + { + "event": "promoting_version", + "pending_version_id": pending_version, + "old_current_version_id": current_version, + "action": "swap_AWSCURRENT", + } + ) + ) + + swap_kwargs = {"SecretId": SECRET_NAME, "VersionStage": "AWSCURRENT", "MoveToVersionId": pending_version} + + if current_version: + swap_kwargs["RemoveFromVersionId"] = current_version + + sm_client.update_secret_version_stage(**swap_kwargs) + + sm_client.update_secret_version_stage( + SecretId=SECRET_NAME, VersionStage="AWSPENDING", RemoveFromVersionId=pending_version + ) + + logger.info( + json.dumps({"event": "promotion_complete", "new_current_version": pending_version, "status": "success"}) + ) + + return {"status": "success", "action": "promoted_and_cleaned", "new_current_version": pending_version} + + except Exception as e: + logger.exception(json.dumps({"event": "promotion_failed", "type": type(e).__name__})) + raise + + else: + logger.warning( + json.dumps({"event": "promotion_skipped", "reason": "no_pending_version_found", "secret_name": SECRET_NAME}) + ) + return {"status": "skipped", "reason": "no_pending_version"} diff --git a/infrastructure/stacks/api-layer/secret_rotation_scheduler.tf b/infrastructure/stacks/api-layer/secret_rotation_scheduler.tf new file mode 100644 index 000000000..9ff3d59ec --- /dev/null +++ b/infrastructure/stacks/api-layer/secret_rotation_scheduler.tf @@ -0,0 +1,32 @@ +locals { + rotation_schedules = { + # PROD: Run on the 1st Tuesday of Jan, Apr, Jul, Oct at 11:00 UTC + # Syntax breakdown: + # ? -> Ignore Day-of-month (required when specifying Day-of-week) + # 1,4,7,10 -> The months (Quarterly) + # 3#1 -> Tuesday (Day 3) -> First instance (#1) + "prod" = "cron(0 11 ? 1,4,7,10 3#1 *)" + + # PREPROD: Run on the Last Tuesday of Dec, Mar, Jun, Sep at 11:00 UTC + # This ensures it runs exactly 7 days before the Prod schedule. + # 3L -> Tuesday (Day 3) -> Last instance (L) + "preprod" = "cron(0 11 ? 3,6,9,12 3L *)" + } + + is_rotation_enabled = contains(keys(local.rotation_schedules), var.environment) +} + +resource "aws_cloudwatch_event_rule" "rotation_schedule" { + count = local.is_rotation_enabled ? 1 : 0 + name = "secret-rotation-quarterly" + description = "Triggers secret rotation (Enabled for: ${var.environment})" + schedule_expression = local.rotation_schedules[var.environment] +} + +resource "aws_cloudwatch_event_target" "rotation_target" { + count = local.is_rotation_enabled ? 1 : 0 + rule = aws_cloudwatch_event_rule.rotation_schedule[0].name + target_id = "RotateSecretStepFunction" + arn = aws_sfn_state_machine.rotation_machine.arn + role_arn = aws_iam_role.eventbridge_sfn_invoke_role.arn +} diff --git a/infrastructure/stacks/api-layer/sns.tf b/infrastructure/stacks/api-layer/sns.tf new file mode 100644 index 000000000..25a1acd9e --- /dev/null +++ b/infrastructure/stacks/api-layer/sns.tf @@ -0,0 +1,12 @@ +resource "aws_sns_topic" "secret_rotation" { + name = "secret-rotation-notifications" + kms_master_key_id = module.secrets_manager.rotation_sns_key_id +} + +resource "aws_sns_topic_subscription" "email_targets" { + for_each = toset(var.OPERATOR_EMAILS) + + topic_arn = aws_sns_topic.secret_rotation.arn + protocol = "email" + endpoint = each.value +} diff --git a/infrastructure/stacks/api-layer/step_functions.tf b/infrastructure/stacks/api-layer/step_functions.tf new file mode 100644 index 000000000..1560584bf --- /dev/null +++ b/infrastructure/stacks/api-layer/step_functions.tf @@ -0,0 +1,190 @@ +resource "aws_sfn_state_machine" "rotation_machine" { + #checkov:skip=CKV_AWS_284: No x-ray needed for this resource + name = "SecretRotationWorkflow" + role_arn = aws_iam_role.rotation_sfn_role.arn + + logging_configuration { + level = "ALL" + include_execution_data = true + log_destination = "${aws_cloudwatch_log_group.rotation_sfn_logs.arn}:*" + } + + definition = jsonencode({ + Comment = "Secret Rotation: Create -> Manual Pause -> Promote -> Manual Pause", + StartAt = "CreatePendingVersion", + States = { + "CreatePendingVersion" : { + Type = "Task", + Resource = aws_lambda_function.create_secret_lambda.arn, + Catch = [{ ErrorEquals = ["States.ALL"], Next = "NotifyFailure" }], + Next = "WaitFor_AddNewHashes" + }, + "WaitFor_AddNewHashes" : { + Type = "Task", + Resource = "arn:aws:states:::sns:publish.waitForTaskToken", + TimeoutSeconds = 86400, + Parameters = { + TopicArn = aws_sns_topic.secret_rotation.arn, + "Message.$" = local.add_jobs_message + }, + Catch = [ + { ErrorEquals = ["States.Timeout"], Next = "NotifyTimeout" }, + { ErrorEquals = ["States.ALL"], Next = "NotifyFailure" } + ], + Next = "PromoteToCurrent" + }, + "PromoteToCurrent" : { + Type = "Task", + Resource = aws_lambda_function.promote_secret_lambda.arn, + Catch = [{ ErrorEquals = ["States.ALL"], Next = "NotifyFailure" }], + Next = "WaitFor_DelOldHashes" + }, + "WaitFor_DelOldHashes" : { + Type = "Task", + Resource = "arn:aws:states:::sns:publish.waitForTaskToken", + TimeoutSeconds = 86400, + Parameters = { + TopicArn = aws_sns_topic.secret_rotation.arn, + "Message.$" = local.delete_jobs_message + }, + Catch = [ + { ErrorEquals = ["States.Timeout"], Next = "NotifyTimeout" }, + { ErrorEquals = ["States.ALL"], Next = "NotifyFailure" } + ], + End = true + }, + + "NotifyTimeout" : { + Type = "Task", + Resource = "arn:aws:states:::sns:publish", + Parameters = { + TopicArn = aws_sns_topic.secret_rotation.arn, + Subject = "WARNING: Secret Rotation Timed Out", + "Message.$" = local.timeout_message + }, + Next = "Fail_Timeout" + }, + + "Fail_Timeout" : { + Type = "Fail", + Error = "ManualActionTimedOut", + Cause = "User did not respond within 24 hours." + }, + "NotifyFailure" : { + Type = "Task", + Resource = "arn:aws:states:::sns:publish", + Parameters = { + TopicArn = aws_sns_topic.secret_rotation.arn, + Subject = "CRITICAL: Secret Rotation Failed", + "Message.$" = local.failure_message + }, + Next = "Fail_Generic" + }, + "Fail_Generic" : { + Type = "Fail" + } + } + }) +} + +locals { + add_jobs_message = < + +====================================================== +', $.Cause) +EOT + + timeout_message = < + +====================================================== +') +EOT +} diff --git a/infrastructure/stacks/api-layer/variables.tf b/infrastructure/stacks/api-layer/variables.tf index bf5931e85..93bb4e2da 100644 --- a/infrastructure/stacks/api-layer/variables.tf +++ b/infrastructure/stacks/api-layer/variables.tf @@ -15,3 +15,8 @@ variable "waf_enabled_environments" { description = "Environments in which WAF resources are deployed. Adjust to disable in test after evaluation." default = ["dev", "preprod", "prod"] } + +variable "OPERATOR_EMAILS" { + description = "List of email addresses to receive manual approval notifications" + type = list(string) +}