From 236769b3418f81a50b7cf74f9944fee80cc927e6 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:38:25 +0000 Subject: [PATCH 01/10] ELI-597: Initial setup for automation of secret rotation --- .../modules/secrets_manager/outputs.tf | 3 + .../stacks/api-layer/iam_policies.tf | 73 +++++++++++++++++++ infrastructure/stacks/api-layer/iam_roles.tf | 63 ++++++++++++++++ infrastructure/stacks/api-layer/lambda.tf | 44 +++++++++++ .../scripts/create_pending_secret.py | 36 +++++++++ .../api-layer/scripts/promote_to_current.py | 52 +++++++++++++ .../api-layer/secret_rotation_scheduler.tf | 14 ++++ infrastructure/stacks/api-layer/sns.tf | 11 +++ .../stacks/api-layer/step_functions.tf | 58 +++++++++++++++ infrastructure/stacks/api-layer/variables.tf | 6 ++ 10 files changed, 360 insertions(+) create mode 100644 infrastructure/stacks/api-layer/scripts/create_pending_secret.py create mode 100644 infrastructure/stacks/api-layer/scripts/promote_to_current.py create mode 100644 infrastructure/stacks/api-layer/secret_rotation_scheduler.tf create mode 100644 infrastructure/stacks/api-layer/sns.tf create mode 100644 infrastructure/stacks/api-layer/step_functions.tf diff --git a/infrastructure/modules/secrets_manager/outputs.tf b/infrastructure/modules/secrets_manager/outputs.tf index c2cf721e2..7b60341fb 100644 --- a/infrastructure/modules/secrets_manager/outputs.tf +++ b/infrastructure/modules/secrets_manager/outputs.tf @@ -6,3 +6,6 @@ output "aws_hashing_secret_name" { value = aws_secretsmanager_secret.hashing_secret.name } +output "kms_key_arn" { + value = aws_kms_key.secrets_cmk.arn +} diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index 4bcae5205..ac695b2ec 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -561,3 +561,76 @@ 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:*:*:*" + } + ] + }) +} + +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.cli_login_topic.arn + } + ] + }) +} + +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..7d349b595 100644 --- a/infrastructure/stacks/api-layer/iam_roles.tf +++ b/infrastructure/stacks/api-layer/iam_roles.tf @@ -74,3 +74,66 @@ 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 +} diff --git a/infrastructure/stacks/api-layer/lambda.tf b/infrastructure/stacks/api-layer/lambda.tf index 87e32a4f5..e59a2ac03 100644 --- a/infrastructure/stacks/api-layer/lambda.tf +++ b/infrastructure/stacks/api-layer/lambda.tf @@ -33,3 +33,47 @@ 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" { + 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 + + environment { + variables = { SECRET_NAME = module.secrets_manager.aws_hashing_secret_name } + } +} + +# 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" { + 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 + + environment { + variables = { SECRET_NAME = module.secrets_manager.aws_hashing_secret_name } + } +} 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..344e81b16 --- /dev/null +++ b/infrastructure/stacks/api-layer/scripts/create_pending_secret.py @@ -0,0 +1,36 @@ +import boto3 +import secrets +import string +import os + +SECRET_NAME = os.environ.get('SECRET_NAME') +REGION_NAME = os.environ.get('AWS_REGION') + +def generate_password(length=32): + """Generates a secure random password.""" + alphabet = string.ascii_letters + string.digits + "!@#$%^&*" + return ''.join(secrets.choice(alphabet) for i in range(length)) + +def lambda_handler(event, context): + sm_client = boto3.client('secretsmanager', region_name=REGION_NAME) + + new_password = generate_password() + + try: + resp = sm_client.put_secret_value( + SecretId=SECRET_NAME, + SecretString=new_password, + VersionStages=['AWSPENDING'] + ) + + print(f"Successfully created pending version for {SECRET_NAME}") + return { + "status": "success", + "secret_name": SECRET_NAME, + "version_id": resp['VersionId'] + } + + except sm_client.exceptions.ResourceNotFoundException: + raise Exception(f"The secret '{SECRET_NAME}' was not found in region '{REGION_NAME}'.") + except Exception as e: + raise Exception(f"Error creating pending secret: {str(e)}") 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..679e7e2c3 --- /dev/null +++ b/infrastructure/stacks/api-layer/scripts/promote_to_current.py @@ -0,0 +1,52 @@ +import boto3 +import os + +SECRET_NAME = os.environ.get('SECRET_NAME') +REGION_NAME = os.environ.get('AWS_REGION') + +def lambda_handler(event, context): + sm_client = boto3.client('secretsmanager', region_name=REGION_NAME) + print(f"Starting promotion for secret: {SECRET_NAME}") + + try: + metadata = sm_client.describe_secret(SecretId=SECRET_NAME) + pending_version = None + current_version_id = None + + for version_id, stages in metadata['VersionIdsToStages'].items(): + if 'AWSPENDING' in stages: + pending_version = version_id + if 'AWSCURRENT' in stages: + current_version_id = version_id + + if not pending_version: + print("No version with label 'AWSPENDING' found. Nothing to do.") + return {"status": "skipped", "reason": "no_pending_version"} + + if pending_version != current_version_id: + print(f"Promoting {pending_version} to AWSCURRENT...") + update_kwargs = { + 'SecretId': SECRET_NAME, + 'VersionStage': 'AWSCURRENT', + 'MoveToVersionId': pending_version + } + if current_version_id: + update_kwargs['RemoveFromVersionId'] = current_version_id + + sm_client.update_secret_version_stage(**update_kwargs) + + sm_client.update_secret_version_stage( + SecretId=SECRET_NAME, + VersionStage='AWSPENDING', + RemoveFromVersionId=pending_version + ) + + return { + 'status': 'success', + 'action': 'promoted_and_cleaned', + 'new_current_version': pending_version + } + + except Exception as e: + print(f"Error promoting secret: {str(e)}") + raise e 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..7a1d029cb --- /dev/null +++ b/infrastructure/stacks/api-layer/secret_rotation_scheduler.tf @@ -0,0 +1,14 @@ +resource "aws_cloudwatch_event_rule" "rotation_schedule" { + name = "secret-rotation-quarterly" + description = "Triggers rotation on the 1st day of every 3rd month" + + # Run at 08:00 UTC, on the 1st day of the month, every 3 months (Jan, Apr, Jul, Oct) + schedule_expression = "cron(0 8 1 */3 ? *)" +} + +resource "aws_cloudwatch_event_target" "rotation_target" { + rule = aws_cloudwatch_event_rule.rotation_schedule.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..42ac49a01 --- /dev/null +++ b/infrastructure/stacks/api-layer/sns.tf @@ -0,0 +1,11 @@ +resource "aws_sns_topic" "cli_login_topic" { + name = "cli-login-notifications" +} + +resource "aws_sns_topic_subscription" "email_targets" { + for_each = toset(var.operator_emails) + + topic_arn = aws_sns_topic.cli_login_topic.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..50c8d53ee --- /dev/null +++ b/infrastructure/stacks/api-layer/step_functions.tf @@ -0,0 +1,58 @@ +resource "aws_sfn_state_machine" "rotation_machine" { + name = "SecretRotationWorkflow" + role_arn = aws_iam_role.rotation_sfn_role.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, + Next = "WaitFor_AddNewHashes" + }, + "WaitFor_AddNewHashes" : { + Type = "Task", + Resource = "arn:aws:states:::sns:publish.waitForTaskToken", + TimeoutSeconds = 86400, + Parameters = { + TopicArn = aws_sns_topic.cli_login_topic.arn, + Message = { + Title = "STEP 1 DONE: Pending Secret Created", + Instructions = "1. Run 'Add New Hashes' job. 2. Copy TaskToken below. 3. Run CLI resume command.", + SecretName = module.secrets_manager.aws_hashing_secret_name, + "TaskToken.$" = "$$.Task.Token" + } + }, + Catch = [{ ErrorEquals = ["States.Timeout"], Next = "Fail_Timeout" }], + Next = "PromoteToCurrent" + }, + "PromoteToCurrent" : { + Type = "Task", + Resource = aws_lambda_function.promote_secret_lambda.arn, + Next = "WaitFor_DelOldHashes" + }, + "WaitFor_DelOldHashes" : { + Type = "Task", + Resource = "arn:aws:states:::sns:publish.waitForTaskToken", + TimeoutSeconds = 86400, + Parameters = { + TopicArn = aws_sns_topic.cli_login_topic.arn, + Message = { + Title = "STEP 2 DONE: Promoted to Current", + Instructions = "1. Run 'Delete Old Hashes' job. 2. Copy TaskToken below. 3. Run CLI resume command.", + SecretName = module.secrets_manager.aws_hashing_secret_name, + "TaskToken.$" = "$$.Task.Token" + } + }, + Catch = [{ ErrorEquals = ["States.Timeout"], Next = "Fail_Timeout" }], + End = true + }, + "Fail_Timeout" : { + Type = "Fail", + Error = "ManualActionTimedOut", + Cause = "User did not respond within 24 hours." + } + } + }) +} diff --git a/infrastructure/stacks/api-layer/variables.tf b/infrastructure/stacks/api-layer/variables.tf index bf5931e85..10e72784f 100644 --- a/infrastructure/stacks/api-layer/variables.tf +++ b/infrastructure/stacks/api-layer/variables.tf @@ -15,3 +15,9 @@ 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) + default = ["tom.eldridge1@nhs.net", "shweta.dongare1@nhs.net"] +} From 91d631b539ef7ef69cf44b637625fec5bdcdc8f3 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:48:01 +0000 Subject: [PATCH 02/10] ELI-597: Use github env variable for sns subscription email --- .github/workflows/base-deploy.yml | 1 + infrastructure/.gitignore | 5 +++++ infrastructure/stacks/api-layer/sns.tf | 2 +- infrastructure/stacks/api-layer/variables.tf | 3 +-- 4 files changed, 8 insertions(+), 3 deletions(-) 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/stacks/api-layer/sns.tf b/infrastructure/stacks/api-layer/sns.tf index 42ac49a01..c2647f0f4 100644 --- a/infrastructure/stacks/api-layer/sns.tf +++ b/infrastructure/stacks/api-layer/sns.tf @@ -3,7 +3,7 @@ resource "aws_sns_topic" "cli_login_topic" { } resource "aws_sns_topic_subscription" "email_targets" { - for_each = toset(var.operator_emails) + for_each = toset(var.OPERATOR_EMAILS) topic_arn = aws_sns_topic.cli_login_topic.arn protocol = "email" diff --git a/infrastructure/stacks/api-layer/variables.tf b/infrastructure/stacks/api-layer/variables.tf index 10e72784f..93bb4e2da 100644 --- a/infrastructure/stacks/api-layer/variables.tf +++ b/infrastructure/stacks/api-layer/variables.tf @@ -16,8 +16,7 @@ variable "waf_enabled_environments" { default = ["dev", "preprod", "prod"] } -variable "operator_emails" { +variable "OPERATOR_EMAILS" { description = "List of email addresses to receive manual approval notifications" type = list(string) - default = ["tom.eldridge1@nhs.net", "shweta.dongare1@nhs.net"] } From 75b207e62eb2a79c1e57f80b24a483e6ff7d747d Mon Sep 17 00:00:00 2001 From: TOEL2 Date: Fri, 9 Jan 2026 14:25:13 +0000 Subject: [PATCH 03/10] (ELI-597) solving checkov issues and addressing comments --- infrastructure/stacks/api-layer/cloudwatch.tf | 9 +++ .../stacks/api-layer/iam_policies.tf | 5 +- infrastructure/stacks/api-layer/lambda.tf | 13 +++++ .../scripts/create_pending_secret.py | 41 +++++++++++++- .../api-layer/scripts/promote_to_current.py | 56 +++++++++++++------ infrastructure/stacks/api-layer/sns.tf | 1 + .../stacks/api-layer/step_functions.tf | 36 ++++++++++-- 7 files changed, 138 insertions(+), 23 deletions(-) diff --git a/infrastructure/stacks/api-layer/cloudwatch.tf b/infrastructure/stacks/api-layer/cloudwatch.tf index b9731f366..a5e63629c 100644 --- a/infrastructure/stacks/api-layer/cloudwatch.tf +++ b/infrastructure/stacks/api-layer/cloudwatch.tf @@ -34,3 +34,12 @@ 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 = aws_lambda_function.create_secret_lambda.kms_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 ac695b2ec..9f28de576 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -597,7 +597,10 @@ resource "aws_iam_policy" "rotation_secrets_policy" { "logs:CreateLogStream", "logs:PutLogEvents" ], - Resource = "arn:aws:logs:*:*:*" + 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}:*" + ] } ] }) diff --git a/infrastructure/stacks/api-layer/lambda.tf b/infrastructure/stacks/api-layer/lambda.tf index e59a2ac03..90ba880d8 100644 --- a/infrastructure/stacks/api-layer/lambda.tf +++ b/infrastructure/stacks/api-layer/lambda.tf @@ -46,6 +46,13 @@ data "archive_file" "create_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_115: Not applicable as it will not be possible to trigger concurrently + #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 + #checkov:skip=CKV_AWS_117: Does not need to be in a VPC + filename = data.archive_file.create_zip.output_path function_name = "${terraform.workspace}-CreatePendingSecretFunction" role = aws_iam_role.rotation_lambda_role.arn @@ -66,6 +73,12 @@ data "archive_file" "promote_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_115: Not applicable as it will not be possible to trigger concurrently + #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 + #checkov:skip=CKV_AWS_117: Does not need to be in a VPC filename = data.archive_file.promote_zip.output_path function_name = "${terraform.workspace}-PromoteToCurrentFunction" role = aws_iam_role.rotation_lambda_role.arn diff --git a/infrastructure/stacks/api-layer/scripts/create_pending_secret.py b/infrastructure/stacks/api-layer/scripts/create_pending_secret.py index 344e81b16..20109617f 100644 --- a/infrastructure/stacks/api-layer/scripts/create_pending_secret.py +++ b/infrastructure/stacks/api-layer/scripts/create_pending_secret.py @@ -2,10 +2,15 @@ import secrets import string import os +import logging +import json SECRET_NAME = os.environ.get('SECRET_NAME') REGION_NAME = os.environ.get('AWS_REGION') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + def generate_password(length=32): """Generates a secure random password.""" alphabet = string.ascii_letters + string.digits + "!@#$%^&*" @@ -14,6 +19,31 @@ def generate_password(length=32): def lambda_handler(event, context): 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) + # Check if any version currently has the 'AWSPENDING' label + 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 Exception(msg) + except sm_client.exceptions.ResourceNotFoundException: + logger.info("Secret not found. Proceeding to create (assuming it will be initialized).") + pass + new_password = generate_password() try: @@ -23,7 +53,11 @@ def lambda_handler(event, context): VersionStages=['AWSPENDING'] ) - print(f"Successfully created pending version for {SECRET_NAME}") + logger.info(json.dumps({ + 'event': 'pending_version_created', + 'version_id': resp['VersionId'], + 'status': 'success' + })) return { "status": "success", "secret_name": SECRET_NAME, @@ -33,4 +67,9 @@ def lambda_handler(event, context): except sm_client.exceptions.ResourceNotFoundException: raise Exception(f"The secret '{SECRET_NAME}' was not found in region '{REGION_NAME}'.") except Exception as e: + logger.error(json.dumps({ + 'event': 'rotation_failed', + 'error': str(e), + 'type': type(e).__name__ + })) raise Exception(f"Error creating pending secret: {str(e)}") diff --git a/infrastructure/stacks/api-layer/scripts/promote_to_current.py b/infrastructure/stacks/api-layer/scripts/promote_to_current.py index 679e7e2c3..ccf14d3b6 100644 --- a/infrastructure/stacks/api-layer/scripts/promote_to_current.py +++ b/infrastructure/stacks/api-layer/scripts/promote_to_current.py @@ -1,39 +1,51 @@ -import boto3 import os +import logging +import boto3 +import json 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, context): sm_client = boto3.client('secretsmanager', region_name=REGION_NAME) - print(f"Starting promotion for secret: {SECRET_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_id = None for version_id, stages in metadata['VersionIdsToStages'].items(): if 'AWSPENDING' in stages: pending_version = version_id - if 'AWSCURRENT' in stages: - current_version_id = version_id + break if not pending_version: - print("No version with label 'AWSPENDING' found. Nothing to do.") + logger.warning(json.dumps({ + 'event': 'promotion_skipped', + 'reason': 'no_pending_version_found', + 'secret_name': SECRET_NAME + })) return {"status": "skipped", "reason": "no_pending_version"} - if pending_version != current_version_id: - print(f"Promoting {pending_version} to AWSCURRENT...") - update_kwargs = { - 'SecretId': SECRET_NAME, - 'VersionStage': 'AWSCURRENT', - 'MoveToVersionId': pending_version - } - if current_version_id: - update_kwargs['RemoveFromVersionId'] = current_version_id + logger.info(json.dumps({ + 'event': 'promoting_version', + 'pending_version_id': pending_version, + 'action': 'swap_AWSCURRENT' + })) - sm_client.update_secret_version_stage(**update_kwargs) + sm_client.update_secret_version_stage( + SecretId=SECRET_NAME, + VersionStage='AWSCURRENT', + MoveToVersionId=pending_version + ) sm_client.update_secret_version_stage( SecretId=SECRET_NAME, @@ -41,6 +53,12 @@ def lambda_handler(event, context): RemoveFromVersionId=pending_version ) + logger.info(json.dumps({ + 'event': 'promotion_complete', + 'new_current_version': pending_version, + 'status': 'success' + })) + return { 'status': 'success', 'action': 'promoted_and_cleaned', @@ -48,5 +66,9 @@ def lambda_handler(event, context): } except Exception as e: - print(f"Error promoting secret: {str(e)}") + logger.error(json.dumps({ + 'event': 'promotion_failed', + 'error': str(e), + 'type': type(e).__name__ + })) raise e diff --git a/infrastructure/stacks/api-layer/sns.tf b/infrastructure/stacks/api-layer/sns.tf index c2647f0f4..0d9e63eb4 100644 --- a/infrastructure/stacks/api-layer/sns.tf +++ b/infrastructure/stacks/api-layer/sns.tf @@ -1,4 +1,5 @@ resource "aws_sns_topic" "cli_login_topic" { + #checkov:skip=CKV_AWS_26: Topic contains nothing sensitive so no encryption required name = "cli-login-notifications" } diff --git a/infrastructure/stacks/api-layer/step_functions.tf b/infrastructure/stacks/api-layer/step_functions.tf index 50c8d53ee..0f1225307 100644 --- a/infrastructure/stacks/api-layer/step_functions.tf +++ b/infrastructure/stacks/api-layer/step_functions.tf @@ -1,7 +1,14 @@ 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", @@ -9,6 +16,7 @@ resource "aws_sfn_state_machine" "rotation_machine" { "CreatePendingVersion" : { Type = "Task", Resource = aws_lambda_function.create_secret_lambda.arn, + Catch = [{ ErrorEquals = ["States.ALL"], Next = "NotifyFailure" }], Next = "WaitFor_AddNewHashes" }, "WaitFor_AddNewHashes" : { @@ -24,12 +32,16 @@ resource "aws_sfn_state_machine" "rotation_machine" { "TaskToken.$" = "$$.Task.Token" } }, - Catch = [{ ErrorEquals = ["States.Timeout"], Next = "Fail_Timeout" }], - Next = "PromoteToCurrent" + Catch = [ + { ErrorEquals = ["States.Timeout"], Next = "Fail_Timeout" }, + { 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" : { @@ -45,13 +57,29 @@ resource "aws_sfn_state_machine" "rotation_machine" { "TaskToken.$" = "$$.Task.Token" } }, - Catch = [{ ErrorEquals = ["States.Timeout"], Next = "Fail_Timeout" }], - End = true + Catch = [ + { ErrorEquals = ["States.Timeout"], Next = "Fail_Timeout" }, + { ErrorEquals = ["States.ALL"], Next = "NotifyFailure" } + ], + End = true }, "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.cli_login_topic.arn, + Subject = "CRITICAL: Secret Rotation Failed", + "Message.$" = "$.Cause" + }, + Next = "Fail_Generic" + }, + "Fail_Generic" : { + Type = "Fail" } } }) From 671c1b8515b111e4720ab8a1838b580f4e09a769 Mon Sep 17 00:00:00 2001 From: TOEL2 Date: Fri, 9 Jan 2026 15:15:02 +0000 Subject: [PATCH 04/10] (ELI-597) linting --- .../scripts/create_pending_secret.py | 82 +++++++++---------- .../api-layer/scripts/promote_to_current.py | 80 ++++++++---------- .../model/campaign_config.py | 2 +- 3 files changed, 76 insertions(+), 88 deletions(-) diff --git a/infrastructure/stacks/api-layer/scripts/create_pending_secret.py b/infrastructure/stacks/api-layer/scripts/create_pending_secret.py index 20109617f..4a8e6bcdf 100644 --- a/infrastructure/stacks/api-layer/scripts/create_pending_secret.py +++ b/infrastructure/stacks/api-layer/scripts/create_pending_secret.py @@ -1,75 +1,71 @@ -import boto3 +import json +import logging +import os import secrets import string -import os -import logging -import json -SECRET_NAME = os.environ.get('SECRET_NAME') -REGION_NAME = os.environ.get('AWS_REGION') +import boto3 + +SECRET_NAME = os.environ.get("SECRET_NAME") +REGION_NAME = os.environ.get("AWS_REGION") logger = logging.getLogger() logger.setLevel(logging.INFO) + def generate_password(length=32): """Generates a secure random password.""" alphabet = string.ascii_letters + string.digits + "!@#$%^&*" - return ''.join(secrets.choice(alphabet) for i in range(length)) + return "".join(secrets.choice(alphabet) for i in range(length)) + def lambda_handler(event, context): - sm_client = boto3.client('secretsmanager', region_name=REGION_NAME) + 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' - })) + 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) # Check if any version currently has the 'AWSPENDING' label - for version_id, stages in metadata.get('VersionIdsToStages', {}).items(): - if 'AWSPENDING' in stages: + 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 - })) + logger.warning( + json.dumps( + { + "event": "rotation_aborted", + "reason": "pending_version_exists", + "pending_version_id": version_id, + } + ) + ) raise Exception(msg) except sm_client.exceptions.ResourceNotFoundException: logger.info("Secret not found. Proceeding to create (assuming it will be initialized).") - pass new_password = generate_password() try: - resp = sm_client.put_secret_value( - SecretId=SECRET_NAME, - SecretString=new_password, - VersionStages=['AWSPENDING'] - ) + 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'] - } + 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: raise Exception(f"The secret '{SECRET_NAME}' was not found in region '{REGION_NAME}'.") except Exception as e: - logger.error(json.dumps({ - 'event': 'rotation_failed', - 'error': str(e), - 'type': type(e).__name__ - })) - raise Exception(f"Error creating pending secret: {str(e)}") + logger.error(json.dumps({"event": "rotation_failed", "error": str(e), "type": type(e).__name__})) + raise Exception(f"Error creating pending secret: {e!s}") diff --git a/infrastructure/stacks/api-layer/scripts/promote_to_current.py b/infrastructure/stacks/api-layer/scripts/promote_to_current.py index ccf14d3b6..4d502aa94 100644 --- a/infrastructure/stacks/api-layer/scripts/promote_to_current.py +++ b/infrastructure/stacks/api-layer/scripts/promote_to_current.py @@ -1,74 +1,66 @@ -import os +import json import logging +import os + import boto3 -import json -SECRET_NAME = os.environ.get('SECRET_NAME') -REGION_NAME = os.environ.get('AWS_REGION') +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, context): - 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' - })) + 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 - for version_id, stages in metadata['VersionIdsToStages'].items(): - if 'AWSPENDING' in stages: + for version_id, stages in metadata["VersionIdsToStages"].items(): + if "AWSPENDING" in stages: pending_version = version_id break if not pending_version: - logger.warning(json.dumps({ - 'event': 'promotion_skipped', - 'reason': 'no_pending_version_found', - 'secret_name': SECRET_NAME - })) + logger.warning( + json.dumps( + {"event": "promotion_skipped", "reason": "no_pending_version_found", "secret_name": SECRET_NAME} + ) + ) return {"status": "skipped", "reason": "no_pending_version"} - logger.info(json.dumps({ - 'event': 'promoting_version', - 'pending_version_id': pending_version, - 'action': 'swap_AWSCURRENT' - })) + logger.info( + json.dumps( + {"event": "promoting_version", "pending_version_id": pending_version, "action": "swap_AWSCURRENT"} + ) + ) sm_client.update_secret_version_stage( - SecretId=SECRET_NAME, - VersionStage='AWSCURRENT', - MoveToVersionId=pending_version + SecretId=SECRET_NAME, VersionStage="AWSCURRENT", MoveToVersionId=pending_version ) sm_client.update_secret_version_stage( - SecretId=SECRET_NAME, - VersionStage='AWSPENDING', - RemoveFromVersionId=pending_version + SecretId=SECRET_NAME, VersionStage="AWSPENDING", RemoveFromVersionId=pending_version ) - logger.info(json.dumps({ - 'event': 'promotion_complete', - 'new_current_version': pending_version, - 'status': 'success' - })) + 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 - } + return {"status": "success", "action": "promoted_and_cleaned", "new_current_version": pending_version} except Exception as e: - logger.error(json.dumps({ - 'event': 'promotion_failed', - 'error': str(e), - 'type': type(e).__name__ - })) + logger.error(json.dumps({"event": "promotion_failed", "error": str(e), "type": type(e).__name__})) raise e diff --git a/src/eligibility_signposting_api/model/campaign_config.py b/src/eligibility_signposting_api/model/campaign_config.py index 73199dc07..dec7d80c7 100644 --- a/src/eligibility_signposting_api/model/campaign_config.py +++ b/src/eligibility_signposting_api/model/campaign_config.py @@ -158,7 +158,7 @@ class IterationRule(BaseModel): model_config = {"populate_by_name": True, "extra": "ignore"} @field_validator("rule_stop", mode="before") - def parse_yn_to_bool(cls, v: str | bool) -> bool: # noqa: N805, FBT001 + def parse_yn_to_bool(cls, v: str | bool) -> bool: # noqa: N805 if isinstance(v, str): return v.upper() == "Y" return v From 99919c810075accb70ea5b225bb72b8a811ed652 Mon Sep 17 00:00:00 2001 From: TOEL2 Date: Fri, 9 Jan 2026 15:52:34 +0000 Subject: [PATCH 05/10] (ELI-597) linting --- .../stacks/api-layer/scripts/__init__.py | 0 .../scripts/create_pending_secret.py | 25 ++++++---- .../api-layer/scripts/promote_to_current.py | 47 ++++++++++--------- 3 files changed, 42 insertions(+), 30 deletions(-) create mode 100644 infrastructure/stacks/api-layer/scripts/__init__.py 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 index 4a8e6bcdf..45aed2e5d 100644 --- a/infrastructure/stacks/api-layer/scripts/create_pending_secret.py +++ b/infrastructure/stacks/api-layer/scripts/create_pending_secret.py @@ -5,6 +5,7 @@ import string import boto3 +from mangum.types import LambdaContext, LambdaEvent SECRET_NAME = os.environ.get("SECRET_NAME") REGION_NAME = os.environ.get("AWS_REGION") @@ -13,13 +14,20 @@ logger.setLevel(logging.INFO) -def generate_password(length=32): +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 i in range(length)) + return "".join(secrets.choice(alphabet) for _ in range(length)) -def lambda_handler(event, context): +def lambda_handler( + event: LambdaEvent, # noqa: ARG001 + context: LambdaContext, +) -> dict: sm_client = boto3.client("secretsmanager", region_name=REGION_NAME) logger.info( @@ -50,7 +58,7 @@ def lambda_handler(event, context): ) ) - raise Exception(msg) + raise PendingVersionExistsError(msg) except sm_client.exceptions.ResourceNotFoundException: logger.info("Secret not found. Proceeding to create (assuming it will be initialized).") @@ -64,8 +72,9 @@ def lambda_handler(event, context): ) return {"status": "success", "secret_name": SECRET_NAME, "version_id": resp["VersionId"]} - except sm_client.exceptions.ResourceNotFoundException: - raise Exception(f"The secret '{SECRET_NAME}' was not found in region '{REGION_NAME}'.") + 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.error(json.dumps({"event": "rotation_failed", "error": str(e), "type": type(e).__name__})) - raise Exception(f"Error creating pending secret: {e!s}") + 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 index 4d502aa94..2b862a662 100644 --- a/infrastructure/stacks/api-layer/scripts/promote_to_current.py +++ b/infrastructure/stacks/api-layer/scripts/promote_to_current.py @@ -3,6 +3,7 @@ import os import boto3 +from mangum.types import LambdaContext, LambdaEvent SECRET_NAME = os.environ.get("SECRET_NAME") REGION_NAME = os.environ.get("AWS_REGION") @@ -11,7 +12,10 @@ logger.setLevel(logging.INFO) -def lambda_handler(event, context): +def lambda_handler( + event: LambdaEvent, # noqa: ARG001 + context: LambdaContext, +) -> dict: sm_client = boto3.client("secretsmanager", region_name=REGION_NAME) logger.info( json.dumps( @@ -33,34 +37,33 @@ def lambda_handler(event, context): pending_version = version_id break - if not pending_version: - logger.warning( + if pending_version: + logger.info( json.dumps( - {"event": "promotion_skipped", "reason": "no_pending_version_found", "secret_name": SECRET_NAME} + {"event": "promoting_version", "pending_version_id": pending_version, "action": "swap_AWSCURRENT"} ) ) - return {"status": "skipped", "reason": "no_pending_version"} - logger.info( - json.dumps( - {"event": "promoting_version", "pending_version_id": pending_version, "action": "swap_AWSCURRENT"} + sm_client.update_secret_version_stage( + SecretId=SECRET_NAME, VersionStage="AWSCURRENT", MoveToVersionId=pending_version ) - ) - - sm_client.update_secret_version_stage( - SecretId=SECRET_NAME, VersionStage="AWSCURRENT", MoveToVersionId=pending_version - ) - sm_client.update_secret_version_stage( - SecretId=SECRET_NAME, VersionStage="AWSPENDING", RemoveFromVersionId=pending_version - ) + 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"}) - ) + 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} + return {"status": "success", "action": "promoted_and_cleaned", "new_current_version": pending_version} except Exception as e: - logger.error(json.dumps({"event": "promotion_failed", "error": str(e), "type": type(e).__name__})) - raise 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"} From 9d7ca863f4120aff3d7607066baada565a5eb73a Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:58:02 +0000 Subject: [PATCH 06/10] ELI-597: Adds FBT001 ignore back --- src/eligibility_signposting_api/model/campaign_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eligibility_signposting_api/model/campaign_config.py b/src/eligibility_signposting_api/model/campaign_config.py index dec7d80c7..73199dc07 100644 --- a/src/eligibility_signposting_api/model/campaign_config.py +++ b/src/eligibility_signposting_api/model/campaign_config.py @@ -158,7 +158,7 @@ class IterationRule(BaseModel): model_config = {"populate_by_name": True, "extra": "ignore"} @field_validator("rule_stop", mode="before") - def parse_yn_to_bool(cls, v: str | bool) -> bool: # noqa: N805 + def parse_yn_to_bool(cls, v: str | bool) -> bool: # noqa: N805, FBT001 if isinstance(v, str): return v.upper() == "Y" return v From a5b39110c22891f11639a372b014c89b5e705b57 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:02:50 +0000 Subject: [PATCH 07/10] ELI-597: Formats SNS emails and changes preprod and prod cron --- .../api-layer/secret_rotation_scheduler.tf | 28 +++++- .../stacks/api-layer/step_functions.tf | 91 ++++++++++++++++--- 2 files changed, 100 insertions(+), 19 deletions(-) diff --git a/infrastructure/stacks/api-layer/secret_rotation_scheduler.tf b/infrastructure/stacks/api-layer/secret_rotation_scheduler.tf index 7a1d029cb..9ff3d59ec 100644 --- a/infrastructure/stacks/api-layer/secret_rotation_scheduler.tf +++ b/infrastructure/stacks/api-layer/secret_rotation_scheduler.tf @@ -1,13 +1,31 @@ +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 rotation on the 1st day of every 3rd month" - - # Run at 08:00 UTC, on the 1st day of the month, every 3 months (Jan, Apr, Jul, Oct) - schedule_expression = "cron(0 8 1 */3 ? *)" + description = "Triggers secret rotation (Enabled for: ${var.environment})" + schedule_expression = local.rotation_schedules[var.environment] } resource "aws_cloudwatch_event_target" "rotation_target" { - rule = aws_cloudwatch_event_rule.rotation_schedule.name + 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/step_functions.tf b/infrastructure/stacks/api-layer/step_functions.tf index 0f1225307..dfc21a32f 100644 --- a/infrastructure/stacks/api-layer/step_functions.tf +++ b/infrastructure/stacks/api-layer/step_functions.tf @@ -24,13 +24,8 @@ resource "aws_sfn_state_machine" "rotation_machine" { Resource = "arn:aws:states:::sns:publish.waitForTaskToken", TimeoutSeconds = 86400, Parameters = { - TopicArn = aws_sns_topic.cli_login_topic.arn, - Message = { - Title = "STEP 1 DONE: Pending Secret Created", - Instructions = "1. Run 'Add New Hashes' job. 2. Copy TaskToken below. 3. Run CLI resume command.", - SecretName = module.secrets_manager.aws_hashing_secret_name, - "TaskToken.$" = "$$.Task.Token" - } + TopicArn = aws_sns_topic.cli_login_topic.arn, + "Message.$" = local.add_jobs_message }, Catch = [ { ErrorEquals = ["States.Timeout"], Next = "Fail_Timeout" }, @@ -50,12 +45,7 @@ resource "aws_sfn_state_machine" "rotation_machine" { TimeoutSeconds = 86400, Parameters = { TopicArn = aws_sns_topic.cli_login_topic.arn, - Message = { - Title = "STEP 2 DONE: Promoted to Current", - Instructions = "1. Run 'Delete Old Hashes' job. 2. Copy TaskToken below. 3. Run CLI resume command.", - SecretName = module.secrets_manager.aws_hashing_secret_name, - "TaskToken.$" = "$$.Task.Token" - } + "Message.$" = local.delete_jobs_message }, Catch = [ { ErrorEquals = ["States.Timeout"], Next = "Fail_Timeout" }, @@ -74,7 +64,7 @@ resource "aws_sfn_state_machine" "rotation_machine" { Parameters = { TopicArn = aws_sns_topic.cli_login_topic.arn, Subject = "CRITICAL: Secret Rotation Failed", - "Message.$" = "$.Cause" + "Message.$" = local.failure_message }, Next = "Fail_Generic" }, @@ -84,3 +74,76 @@ resource "aws_sfn_state_machine" "rotation_machine" { } }) } + +locals { + add_jobs_message = < + +====================================================== +', $.Cause) +EOT +} From 6828c1f46ad12741b0b9f1c5bcdd4ca00fb66c3c Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:31:15 +0000 Subject: [PATCH 08/10] ELI-597: Adds vpc and encryption of rotation kms --- infrastructure/modules/secrets_manager/kms.tf | 36 ++++++++++++ .../modules/secrets_manager/outputs.tf | 8 +++ .../stacks/api-layer/iam_policies.tf | 10 +++- infrastructure/stacks/api-layer/iam_roles.tf | 5 ++ infrastructure/stacks/api-layer/lambda.tf | 39 +++++++------ infrastructure/stacks/api-layer/sns.tf | 8 +-- .../stacks/api-layer/step_functions.tf | 55 ++++++++++++++++--- 7 files changed, 132 insertions(+), 29 deletions(-) diff --git a/infrastructure/modules/secrets_manager/kms.tf b/infrastructure/modules/secrets_manager/kms.tf index abb9e1d16..c09708953 100644 --- a/infrastructure/modules/secrets_manager/kms.tf +++ b/infrastructure/modules/secrets_manager/kms.tf @@ -57,3 +57,39 @@ 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 = "*" + } + ] + }) +} diff --git a/infrastructure/modules/secrets_manager/outputs.tf b/infrastructure/modules/secrets_manager/outputs.tf index 7b60341fb..7a45ede74 100644 --- a/infrastructure/modules/secrets_manager/outputs.tf +++ b/infrastructure/modules/secrets_manager/outputs.tf @@ -9,3 +9,11 @@ output "aws_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/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index 9f28de576..ae69ffc0b 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -627,7 +627,15 @@ resource "aws_iam_policy" "rotation_sfn_policy" { { Effect = "Allow", Action = "sns:Publish", - Resource = aws_sns_topic.cli_login_topic.arn + Resource = aws_sns_topic.secret_rotation.arn + }, + { + Effect = "Allow", + Action = [ + "kms:Decrypt", + "kms:GenerateDataKey" + ], + Resource = module.secrets_manager.rotation_sns_key_arn } ] }) diff --git a/infrastructure/stacks/api-layer/iam_roles.tf b/infrastructure/stacks/api-layer/iam_roles.tf index 7d349b595..e4c3dbe23 100644 --- a/infrastructure/stacks/api-layer/iam_roles.tf +++ b/infrastructure/stacks/api-layer/iam_roles.tf @@ -137,3 +137,8 @@ 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 90ba880d8..9b31fee49 100644 --- a/infrastructure/stacks/api-layer/lambda.tf +++ b/infrastructure/stacks/api-layer/lambda.tf @@ -47,22 +47,24 @@ data "archive_file" "create_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_115: Not applicable as it will not be possible to trigger concurrently #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 - #checkov:skip=CKV_AWS_117: Does not need to be in a VPC - - 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 + 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 @@ -74,19 +76,22 @@ data "archive_file" "promote_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_115: Not applicable as it will not be possible to trigger concurrently #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 - #checkov:skip=CKV_AWS_117: Does not need to be in a VPC - 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 + 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/sns.tf b/infrastructure/stacks/api-layer/sns.tf index 0d9e63eb4..25a1acd9e 100644 --- a/infrastructure/stacks/api-layer/sns.tf +++ b/infrastructure/stacks/api-layer/sns.tf @@ -1,12 +1,12 @@ -resource "aws_sns_topic" "cli_login_topic" { - #checkov:skip=CKV_AWS_26: Topic contains nothing sensitive so no encryption required - name = "cli-login-notifications" +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.cli_login_topic.arn + 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 index dfc21a32f..1560584bf 100644 --- a/infrastructure/stacks/api-layer/step_functions.tf +++ b/infrastructure/stacks/api-layer/step_functions.tf @@ -16,7 +16,7 @@ resource "aws_sfn_state_machine" "rotation_machine" { "CreatePendingVersion" : { Type = "Task", Resource = aws_lambda_function.create_secret_lambda.arn, - Catch = [{ ErrorEquals = ["States.ALL"], Next = "NotifyFailure" }], + Catch = [{ ErrorEquals = ["States.ALL"], Next = "NotifyFailure" }], Next = "WaitFor_AddNewHashes" }, "WaitFor_AddNewHashes" : { @@ -24,11 +24,11 @@ resource "aws_sfn_state_machine" "rotation_machine" { Resource = "arn:aws:states:::sns:publish.waitForTaskToken", TimeoutSeconds = 86400, Parameters = { - TopicArn = aws_sns_topic.cli_login_topic.arn, + TopicArn = aws_sns_topic.secret_rotation.arn, "Message.$" = local.add_jobs_message }, Catch = [ - { ErrorEquals = ["States.Timeout"], Next = "Fail_Timeout" }, + { ErrorEquals = ["States.Timeout"], Next = "NotifyTimeout" }, { ErrorEquals = ["States.ALL"], Next = "NotifyFailure" } ], Next = "PromoteToCurrent" @@ -36,7 +36,7 @@ resource "aws_sfn_state_machine" "rotation_machine" { "PromoteToCurrent" : { Type = "Task", Resource = aws_lambda_function.promote_secret_lambda.arn, - Catch = [{ ErrorEquals = ["States.ALL"], Next = "NotifyFailure" }], + Catch = [{ ErrorEquals = ["States.ALL"], Next = "NotifyFailure" }], Next = "WaitFor_DelOldHashes" }, "WaitFor_DelOldHashes" : { @@ -44,15 +44,27 @@ resource "aws_sfn_state_machine" "rotation_machine" { Resource = "arn:aws:states:::sns:publish.waitForTaskToken", TimeoutSeconds = 86400, Parameters = { - TopicArn = aws_sns_topic.cli_login_topic.arn, + TopicArn = aws_sns_topic.secret_rotation.arn, "Message.$" = local.delete_jobs_message }, Catch = [ - { ErrorEquals = ["States.Timeout"], Next = "Fail_Timeout" }, + { 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", @@ -62,7 +74,7 @@ resource "aws_sfn_state_machine" "rotation_machine" { Type = "Task", Resource = "arn:aws:states:::sns:publish", Parameters = { - TopicArn = aws_sns_topic.cli_login_topic.arn, + TopicArn = aws_sns_topic.secret_rotation.arn, Subject = "CRITICAL: Secret Rotation Failed", "Message.$" = local.failure_message }, @@ -145,5 +157,34 @@ aws secretsmanager update-secret-version-stage --secret-id ${module.secrets_mana ====================================================== ', $.Cause) +EOT + + timeout_message = < + +====================================================== +') EOT } From fb95b148f9b0efa2ec57f45158b73074d5ee7b55 Mon Sep 17 00:00:00 2001 From: TOEL2 Date: Mon, 12 Jan 2026 16:26:08 +0000 Subject: [PATCH 09/10] [ELI-597] fixing problems and addressing comments --- infrastructure/modules/secrets_manager/kms.tf | 20 ++++++++++++++ infrastructure/stacks/api-layer/cloudwatch.tf | 5 +--- .../stacks/api-layer/iam_policies.tf | 14 ++++++++++ .../scripts/create_pending_secret.py | 6 ++--- .../api-layer/scripts/promote_to_current.py | 26 ++++++++++++------- .../model/campaign_config.py | 2 +- 6 files changed, 55 insertions(+), 18 deletions(-) diff --git a/infrastructure/modules/secrets_manager/kms.tf b/infrastructure/modules/secrets_manager/kms.tf index c09708953..81f722595 100644 --- a/infrastructure/modules/secrets_manager/kms.tf +++ b/infrastructure/modules/secrets_manager/kms.tf @@ -89,6 +89,26 @@ resource "aws_kms_key" "rotation_sns_cmk" { "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/stacks/api-layer/cloudwatch.tf b/infrastructure/stacks/api-layer/cloudwatch.tf index a5e63629c..6f9ca738a 100644 --- a/infrastructure/stacks/api-layer/cloudwatch.tf +++ b/infrastructure/stacks/api-layer/cloudwatch.tf @@ -37,9 +37,6 @@ resource "aws_cloudwatch_log_stream" "firehose_audit_stream" { resource "aws_cloudwatch_log_group" "rotation_sfn_logs" { name = "/aws/stepfunctions/SecretRotationWorkflow" - kms_key_id = aws_lambda_function.create_secret_lambda.kms_key_arn - - - + 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 ae69ffc0b..0fd67e453 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -636,6 +636,20 @@ resource "aws_iam_policy" "rotation_sfn_policy" { "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 = "*" } ] }) diff --git a/infrastructure/stacks/api-layer/scripts/create_pending_secret.py b/infrastructure/stacks/api-layer/scripts/create_pending_secret.py index 45aed2e5d..5eaf3c130 100644 --- a/infrastructure/stacks/api-layer/scripts/create_pending_secret.py +++ b/infrastructure/stacks/api-layer/scripts/create_pending_secret.py @@ -5,7 +5,6 @@ import string import boto3 -from mangum.types import LambdaContext, LambdaEvent SECRET_NAME = os.environ.get("SECRET_NAME") REGION_NAME = os.environ.get("AWS_REGION") @@ -25,8 +24,8 @@ def generate_password(length: int = 32) -> str: def lambda_handler( - event: LambdaEvent, # noqa: ARG001 - context: LambdaContext, + event: dict, # noqa: ARG001 + context: object, ) -> dict: sm_client = boto3.client("secretsmanager", region_name=REGION_NAME) @@ -43,7 +42,6 @@ def lambda_handler( try: metadata = sm_client.describe_secret(SecretId=SECRET_NAME) - # Check if any version currently has the 'AWSPENDING' label for version_id, stages in metadata.get("VersionIdsToStages", {}).items(): if "AWSPENDING" in stages: msg = f"Pending version already exists with version_id: {version_id}." diff --git a/infrastructure/stacks/api-layer/scripts/promote_to_current.py b/infrastructure/stacks/api-layer/scripts/promote_to_current.py index 2b862a662..84f2053e0 100644 --- a/infrastructure/stacks/api-layer/scripts/promote_to_current.py +++ b/infrastructure/stacks/api-layer/scripts/promote_to_current.py @@ -3,7 +3,6 @@ import os import boto3 -from mangum.types import LambdaContext, LambdaEvent SECRET_NAME = os.environ.get("SECRET_NAME") REGION_NAME = os.environ.get("AWS_REGION") @@ -13,8 +12,8 @@ def lambda_handler( - event: LambdaEvent, # noqa: ARG001 - context: LambdaContext, + event: dict, # noqa: ARG001 + context: object, ) -> dict: sm_client = boto3.client("secretsmanager", region_name=REGION_NAME) logger.info( @@ -31,22 +30,31 @@ def lambda_handler( 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 - break + if "AWSCURRENT" in stages: + current_version = version_id if pending_version: logger.info( json.dumps( - {"event": "promoting_version", "pending_version_id": pending_version, "action": "swap_AWSCURRENT"} + { + "event": "promoting_version", + "pending_version_id": pending_version, + "old_current_version_id": current_version, + "action": "swap_AWSCURRENT", + } ) ) - sm_client.update_secret_version_stage( - SecretId=SECRET_NAME, VersionStage="AWSCURRENT", MoveToVersionId=pending_version - ) + 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 diff --git a/src/eligibility_signposting_api/model/campaign_config.py b/src/eligibility_signposting_api/model/campaign_config.py index 73199dc07..dec7d80c7 100644 --- a/src/eligibility_signposting_api/model/campaign_config.py +++ b/src/eligibility_signposting_api/model/campaign_config.py @@ -158,7 +158,7 @@ class IterationRule(BaseModel): model_config = {"populate_by_name": True, "extra": "ignore"} @field_validator("rule_stop", mode="before") - def parse_yn_to_bool(cls, v: str | bool) -> bool: # noqa: N805, FBT001 + def parse_yn_to_bool(cls, v: str | bool) -> bool: # noqa: N805 if isinstance(v, str): return v.upper() == "Y" return v From 3d3c3d2904cb224ea8dae8981f69cbb10274fe29 Mon Sep 17 00:00:00 2001 From: TOEL2 Date: Mon, 12 Jan 2026 16:33:37 +0000 Subject: [PATCH 10/10] [ELI-597] linting error --- src/eligibility_signposting_api/model/campaign_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eligibility_signposting_api/model/campaign_config.py b/src/eligibility_signposting_api/model/campaign_config.py index dec7d80c7..73199dc07 100644 --- a/src/eligibility_signposting_api/model/campaign_config.py +++ b/src/eligibility_signposting_api/model/campaign_config.py @@ -158,7 +158,7 @@ class IterationRule(BaseModel): model_config = {"populate_by_name": True, "extra": "ignore"} @field_validator("rule_stop", mode="before") - def parse_yn_to_bool(cls, v: str | bool) -> bool: # noqa: N805 + def parse_yn_to_bool(cls, v: str | bool) -> bool: # noqa: N805, FBT001 if isinstance(v, str): return v.upper() == "Y" return v