Skip to content

Commit 524b2e9

Browse files
shweta-nhsTOEL2
andauthored
ELI-597: Automates NHS Number salt creation and rotation (#537)
* ELI-597: Initial setup for automation of secret rotation * ELI-597: Use github env variable for sns subscription email * (ELI-597) solving checkov issues and addressing comments * (ELI-597) linting * (ELI-597) linting * ELI-597: Adds FBT001 ignore back * ELI-597: Formats SNS emails and changes preprod and prod cron * ELI-597: Adds vpc and encryption of rotation kms * [ELI-597] fixing problems and addressing comments * [ELI-597] linting error --------- Co-authored-by: TOEL2 <tom.eldridge1@nhs.net>
1 parent 2b90a02 commit 524b2e9

15 files changed

Lines changed: 701 additions & 0 deletions

File tree

.github/workflows/base-deploy.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ jobs:
202202
TF_VAR_API_PRIVATE_KEY_CERT: ${{ secrets.API_PRIVATE_KEY_CERT }}
203203
TF_VAR_SPLUNK_HEC_TOKEN: ${{ secrets.SPLUNK_HEC_TOKEN }}
204204
TF_VAR_SPLUNK_HEC_ENDPOINT: ${{ secrets.SPLUNK_HEC_ENDPOINT }}
205+
TF_VAR_OPERATOR_EMAILS: ${{ vars.SECRET_ROTATION_OPERATOR_EMAILS }}
205206

206207
working-directory: ./infrastructure
207208
shell: bash

infrastructure/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,8 @@ override.tf.json
3838
# Ignore CLI configuration files
3939
.terraformrc
4040
terraform.rc
41+
42+
# Ignore secret rotation lambda zip files
43+
44+
stacks/api-layer/scripts/create_pending_secret.zip
45+
stacks/api-layer/scripts/promote_to_current.zip

infrastructure/modules/secrets_manager/kms.tf

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,59 @@ resource "aws_kms_key" "secrets_cmk" {
5757
})
5858
tags = var.tags
5959
}
60+
61+
resource "aws_kms_key" "rotation_sns_cmk" {
62+
description = "KMS key for SNS topic encryption (CLI Login Notifications)"
63+
deletion_window_in_days = 14
64+
enable_key_rotation = true
65+
66+
policy = jsonencode({
67+
Version = "2012-10-17"
68+
Statement = [
69+
{
70+
Sid = "Enable IAM User Permissions"
71+
Effect = "Allow"
72+
Principal = {
73+
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
74+
}
75+
Action = "kms:*"
76+
Resource = "*"
77+
},
78+
{
79+
Sid = "Allow SNS and EventBridge Usage"
80+
Effect = "Allow"
81+
Principal = {
82+
Service = [
83+
"sns.amazonaws.com",
84+
"events.amazonaws.com"
85+
]
86+
}
87+
Action = [
88+
"kms:Decrypt",
89+
"kms:GenerateDataKey"
90+
]
91+
Resource = "*"
92+
},
93+
{
94+
Sid = "Allow CloudWatch Logs Encryption"
95+
Effect = "Allow"
96+
Principal = {
97+
Service = "logs.${var.region}.amazonaws.com"
98+
}
99+
Action = [
100+
"kms:Encrypt*",
101+
"kms:Decrypt*",
102+
"kms:ReEncrypt*",
103+
"kms:GenerateDataKey*",
104+
"kms:Describe*"
105+
]
106+
Resource = "*"
107+
Condition = {
108+
ArnLike = {
109+
"kms:EncryptionContext:aws:logs:arn": "arn:aws:logs:${var.region}:${data.aws_caller_identity.current.account_id}:log-group:/aws/stepfunctions/SecretRotationWorkflow"
110+
}
111+
}
112+
}
113+
]
114+
})
115+
}

infrastructure/modules/secrets_manager/outputs.tf

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,14 @@ output "aws_hashing_secret_name" {
66
value = aws_secretsmanager_secret.hashing_secret.name
77
}
88

9+
output "kms_key_arn" {
10+
value = aws_kms_key.secrets_cmk.arn
11+
}
12+
13+
output "rotation_sns_key_id" {
14+
value = aws_kms_key.rotation_sns_cmk.key_id
15+
}
16+
17+
output "rotation_sns_key_arn" {
18+
value = aws_kms_key.rotation_sns_cmk.arn
19+
}

infrastructure/stacks/api-layer/cloudwatch.tf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,9 @@ resource "aws_cloudwatch_log_stream" "firehose_audit_stream" {
3434
aws_cloudwatch_log_group.firehose_audit
3535
]
3636
}
37+
38+
resource "aws_cloudwatch_log_group" "rotation_sfn_logs" {
39+
name = "/aws/stepfunctions/SecretRotationWorkflow"
40+
kms_key_id = module.secrets_manager.rotation_sns_key_arn
41+
retention_in_days = 365
42+
}

infrastructure/stacks/api-layer/iam_policies.tf

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,3 +561,101 @@ resource "aws_iam_role_policy" "external_secret_read_policy_attachment" {
561561
role = aws_iam_role.write_access_role[count.index].id
562562
policy = data.aws_iam_policy_document.secrets_access_policy.json
563563
}
564+
565+
# --- Rotation Logic Policies ---
566+
resource "aws_iam_policy" "rotation_secrets_policy" {
567+
name = "rotation_secrets_policy"
568+
description = "Allow Lambda to read/write ONLY the hashing secret"
569+
policy = jsonencode({
570+
Version = "2012-10-17",
571+
Statement = [
572+
{
573+
Sid = "ManageSecretBits",
574+
Effect = "Allow",
575+
Action = [
576+
"secretsmanager:DescribeSecret",
577+
"secretsmanager:PutSecretValue",
578+
"secretsmanager:UpdateSecretVersionStage",
579+
"secretsmanager:GetSecretValue"
580+
],
581+
Resource = module.secrets_manager.aws_hashing_secret_arn
582+
},
583+
{
584+
Sid = "AllowKMSKeyUsage",
585+
Effect = "Allow",
586+
Action = [
587+
"kms:Decrypt",
588+
"kms:GenerateDataKey"
589+
],
590+
Resource = module.secrets_manager.kms_key_arn
591+
},
592+
{
593+
Sid = "BasicLogging",
594+
Effect = "Allow",
595+
Action = [
596+
"logs:CreateLogGroup",
597+
"logs:CreateLogStream",
598+
"logs:PutLogEvents"
599+
],
600+
Resource = [
601+
"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}:*",
602+
"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}:*"
603+
]
604+
}
605+
]
606+
})
607+
}
608+
609+
resource "aws_iam_role_policy_attachment" "attach_rotation_secrets" {
610+
role = aws_iam_role.rotation_lambda_role.name
611+
policy_arn = aws_iam_policy.rotation_secrets_policy.arn
612+
}
613+
614+
resource "aws_iam_policy" "rotation_sfn_policy" {
615+
name = "rotation_sfn_policy"
616+
policy = jsonencode({
617+
Version = "2012-10-17",
618+
Statement = [
619+
{
620+
Effect = "Allow",
621+
Action = "lambda:InvokeFunction",
622+
Resource = [
623+
aws_lambda_function.create_secret_lambda.arn,
624+
aws_lambda_function.promote_secret_lambda.arn
625+
]
626+
},
627+
{
628+
Effect = "Allow",
629+
Action = "sns:Publish",
630+
Resource = aws_sns_topic.secret_rotation.arn
631+
},
632+
{
633+
Effect = "Allow",
634+
Action = [
635+
"kms:Decrypt",
636+
"kms:GenerateDataKey"
637+
],
638+
Resource = module.secrets_manager.rotation_sns_key_arn
639+
},
640+
{
641+
Effect = "Allow",
642+
Action = [
643+
"logs:CreateLogDelivery",
644+
"logs:GetLogDelivery",
645+
"logs:UpdateLogDelivery",
646+
"logs:DeleteLogDelivery",
647+
"logs:ListLogDeliveries",
648+
"logs:PutResourcePolicy",
649+
"logs:DescribeResourcePolicies",
650+
"logs:DescribeLogGroups"
651+
],
652+
Resource = "*"
653+
}
654+
]
655+
})
656+
}
657+
658+
resource "aws_iam_role_policy_attachment" "attach_rotation_sfn" {
659+
role = aws_iam_role.rotation_sfn_role.name
660+
policy_arn = aws_iam_policy.rotation_sfn_policy.arn
661+
}

infrastructure/stacks/api-layer/iam_roles.tf

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,71 @@ resource "aws_iam_role" "eligibility_audit_firehose_role" {
7474
assume_role_policy = data.aws_iam_policy_document.firehose_assume_role.json
7575
permissions_boundary = aws_iam_policy.assumed_role_permissions_boundary.arn
7676
}
77+
78+
# --- Secret Rotation Roles ---
79+
resource "aws_iam_role" "rotation_lambda_role" {
80+
name = "secret_rotation_lambda_role"
81+
assume_role_policy = jsonencode({
82+
Version = "2012-10-17",
83+
Statement = [{
84+
Action = "sts:AssumeRole",
85+
Effect = "Allow",
86+
Principal = { Service = "lambda.amazonaws.com" }
87+
}]
88+
})
89+
}
90+
91+
resource "aws_iam_role" "rotation_sfn_role" {
92+
name = "secret_rotation_workflow_role"
93+
assume_role_policy = jsonencode({
94+
Version = "2012-10-17",
95+
Statement = [{
96+
Action = "sts:AssumeRole",
97+
Effect = "Allow",
98+
Principal = { Service = "states.amazonaws.com" }
99+
}]
100+
})
101+
}
102+
103+
# -----------------------------------------------------------------------------
104+
# IAM Role: Allow EventBridge to Start the Step Function
105+
# -----------------------------------------------------------------------------
106+
107+
resource "aws_iam_role" "eventbridge_sfn_invoke_role" {
108+
name = "eventbridge_invoke_sfn_role"
109+
110+
assume_role_policy = jsonencode({
111+
Version = "2012-10-17"
112+
Statement = [
113+
{
114+
Action = "sts:AssumeRole"
115+
Effect = "Allow"
116+
Principal = { Service = "events.amazonaws.com" }
117+
}
118+
]
119+
})
120+
}
121+
122+
resource "aws_iam_policy" "eventbridge_sfn_policy" {
123+
name = "eventbridge_sfn_start_policy"
124+
policy = jsonencode({
125+
Version = "2012-10-17"
126+
Statement = [
127+
{
128+
Effect = "Allow"
129+
Action = "states:StartExecution"
130+
Resource = aws_sfn_state_machine.rotation_machine.arn
131+
}
132+
]
133+
})
134+
}
135+
136+
resource "aws_iam_role_policy_attachment" "attach_eb_sfn" {
137+
role = aws_iam_role.eventbridge_sfn_invoke_role.name
138+
policy_arn = aws_iam_policy.eventbridge_sfn_policy.arn
139+
}
140+
141+
resource "aws_iam_role_policy_attachment" "rotation_vpc_access" {
142+
role = aws_iam_role.rotation_lambda_role.name
143+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
144+
}

infrastructure/stacks/api-layer/lambda.tf

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,65 @@ module "eligibility_signposting_lambda_function" {
3333
provisioned_concurrency_count = 5
3434
api_domain_name = local.api_domain_name
3535
}
36+
37+
# -----------------------------------------------------------------------------
38+
# Secret rotation lambdas
39+
# -----------------------------------------------------------------------------
40+
41+
# 1. Generator Lambda
42+
data "archive_file" "create_zip" {
43+
type = "zip"
44+
source_file = "${path.module}/scripts/create_pending_secret.py"
45+
output_path = "${path.module}/scripts/create_pending_secret.zip"
46+
}
47+
48+
resource "aws_lambda_function" "create_secret_lambda" {
49+
#checkov:skip=CKV_AWS_116: No deadletter queue is required for this Lambda function
50+
#checkov:skip=CKV_AWS_272: Skipping code signing but flagged to create ticket to investigate on ELI-238
51+
#checkov:skip=CKV_AWS_50: No x-ray needed for this function
52+
#checkov:skip=CKV_AWS_173: No encryption needed for the secret name
53+
54+
filename = data.archive_file.create_zip.output_path
55+
function_name = "${terraform.workspace}-CreatePendingSecretFunction"
56+
role = aws_iam_role.rotation_lambda_role.arn
57+
handler = "create_pending_secret.lambda_handler"
58+
runtime = "python3.13"
59+
timeout = 30
60+
reserved_concurrent_executions = 1
61+
environment {
62+
variables = { SECRET_NAME = module.secrets_manager.aws_hashing_secret_name }
63+
}
64+
vpc_config {
65+
subnet_ids = [for s in data.aws_subnet.private_subnets : s.id]
66+
security_group_ids = [data.aws_security_group.main_sg.id]
67+
}
68+
}
69+
70+
# 2. Promoter Lambda
71+
data "archive_file" "promote_zip" {
72+
type = "zip"
73+
source_file = "${path.module}/scripts/promote_to_current.py"
74+
output_path = "${path.module}/scripts/promote_to_current.zip"
75+
}
76+
77+
resource "aws_lambda_function" "promote_secret_lambda" {
78+
#checkov:skip=CKV_AWS_116: No deadletter queue is required for this Lambda function
79+
#checkov:skip=CKV_AWS_272: Skipping code signing but flagged to create ticket to investigate on ELI-238
80+
#checkov:skip=CKV_AWS_50: No x-ray needed for this function
81+
#checkov:skip=CKV_AWS_173: No encryption needed for the secret name
82+
83+
filename = data.archive_file.promote_zip.output_path
84+
function_name = "${terraform.workspace}-PromoteToCurrentFunction"
85+
role = aws_iam_role.rotation_lambda_role.arn
86+
handler = "promote_to_current.lambda_handler"
87+
runtime = "python3.13"
88+
timeout = 30
89+
reserved_concurrent_executions = 1
90+
environment {
91+
variables = { SECRET_NAME = module.secrets_manager.aws_hashing_secret_name }
92+
}
93+
vpc_config {
94+
subnet_ids = [for s in data.aws_subnet.private_subnets : s.id]
95+
security_group_ids = [data.aws_security_group.main_sg.id]
96+
}
97+
}

infrastructure/stacks/api-layer/scripts/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)