diff --git a/.github/workflows/deploy-personal-dev-environment.yml b/.github/workflows/deploy-personal-dev-environment.yml index 001a9781..d431de09 100644 --- a/.github/workflows/deploy-personal-dev-environment.yml +++ b/.github/workflows/deploy-personal-dev-environment.yml @@ -201,7 +201,12 @@ jobs: restart_services: name: Restart services runs-on: ubuntu-latest - needs: ["push_frontend_docker_image", "push_backend_docker_image", "push_ingestion_docker_image"] + needs: + [ + "push_frontend_docker_image", + "push_backend_docker_image", + "push_ingestion_docker_image", + ] steps: - uses: actions/checkout@v4 diff --git a/scripts/_secrets.sh b/scripts/_secrets.sh index 9b9c358b..d5fca887 100644 --- a/scripts/_secrets.sh +++ b/scripts/_secrets.sh @@ -56,7 +56,9 @@ function _delete_all_secrets() { "uhd-${env}-feature-flags-api-keys" "uhd-${env}-esri-api-key" "uhd-${env}-esri-maps-service-credentials" - "uhd-${env}-slack-webhook-url") + "uhd-${env}-slack-webhook-url" + "uhd-${env}-cognito-service-credentials" + "uhd-${env}-auth-secret") for ((i=1; i<=${#secret_ids[@]}; ++i)); do _delete_secret "${secret_ids[i]}" diff --git a/terraform/20-app/api-gateway.tf b/terraform/20-app/api-gateway.tf new file mode 100644 index 00000000..d506b897 --- /dev/null +++ b/terraform/20-app/api-gateway.tf @@ -0,0 +1,13 @@ +module "api_gateway" { + source = "../modules/api-gateway" + name = "${local.prefix}-api-gateway" + description = "API Gateway for ${local.prefix}" + api_gateway_stage_name = var.api_gateway_stage_name + lambda_role_arn = module.cognito.cognito_lambda_role_arn + cognito_user_pool_arn = module.cognito.cognito_user_pool_arn + region = local.region + resource_path_part = "{proxy+}" + lambda_invoke_arn = module.api_gateway.lambda_alias_arn + lambda_function_arn = module.api_gateway.api_gateway_lambda_arn + prefix = local.prefix +} \ No newline at end of file diff --git a/terraform/20-app/api-keys.tf b/terraform/20-app/api-keys.tf index 0bd84981..2be4ff37 100644 --- a/terraform/20-app/api-keys.tf +++ b/terraform/20-app/api-keys.tf @@ -49,8 +49,18 @@ resource "random_password" "feature_flags_admin_user_password" { special = true } +resource "random_password" "auth_secret" { + length = 32 + min_numeric = 1 + min_lower = 1 + min_upper = 1 + min_special = 1 + special = true +} + locals { feature_flags_x_auth = random_password.feature_flags_x_auth.result feature_flags_client_api_key = "*:production.${random_password.feature_flags_client_api_key.result}" private_api_key = "${random_password.private_api_key_prefix.result}.${random_password.private_api_key_suffix.result}" + auth_secret = random_password.auth_secret.result } diff --git a/terraform/20-app/cloud-front.front-end.tf b/terraform/20-app/cloud-front.front-end.tf index 77f32edb..3dd6cf1b 100644 --- a/terraform/20-app/cloud-front.front-end.tf +++ b/terraform/20-app/cloud-front.front-end.tf @@ -66,7 +66,7 @@ module "cloudfront_front_end" { } : {} } - ordered_cache_behavior = [ + ordered_cache_behavior = flatten(concat([ # Behaviour to bypass cloudfront for health check { path_pattern = "api/health" @@ -81,6 +81,21 @@ module "cloudfront_front_end" { viewer_protocol_policy = "redirect-to-https" query_string = false }, + # Behaviour to enable cookie forwarding for auth endpoints + local.is_auth ? [ + { + path_pattern = "/api/auth/*" + allowed_methods = ["HEAD", "DELETE", "POST", "GET", "OPTIONS", "PUT", "PATCH"] + cache_policy_id = local.managed_caching_disabled_policy_id + cached_methods = ["GET", "HEAD"] + compress = true + origin_request_policy_id = aws_cloudfront_origin_request_policy.front_end_auth.id + response_headers_policy_id = aws_cloudfront_response_headers_policy.front_end.id + target_origin_id = "alb" + use_forwarded_values = false + viewer_protocol_policy = "redirect-to-https" + } + ] : [], # Behaviour to bypass CDN for the dynamic alert pages { path_pattern = "/" @@ -142,7 +157,7 @@ module "cloudfront_front_end" { use_forwarded_values = false viewer_protocol_policy = "redirect-to-https" }, - ] + ])) custom_error_response = [ { @@ -169,7 +184,16 @@ resource "aws_cloudfront_origin_request_policy" "front_end" { cookies_config { cookie_behavior = "whitelist" cookies { - items = ["UKHSAConsentGDPR"] + items = flatten(concat(["UKHSAConsentGDPR", local.is_auth ? [ + "__Secure-authjs.callback-url", # Stores the redirect destination after authentication + "__Secure-authjs.csrf-token", # CSRF token required for authentication flows + "__Secure-authjs.session-token", # Main session token + "__Secure-authjs.session-token.0", # Split session token (if size exceeds 4KB) + "__Secure-authjs.session-token.1", # Additional split session token + "__Secure-authjs.session-token.2", # Additional split session token + "__Secure-authjs.session-token.3", # Additional split session token + "__Secure-authjs.session-token.4", # Additional split session token (safety margin) + ] : []])) } } headers_config { @@ -180,6 +204,29 @@ resource "aws_cloudfront_origin_request_policy" "front_end" { } } +resource "aws_cloudfront_origin_request_policy" "front_end_auth" { + name = "${local.prefix}-front-end-auth" + + cookies_config { + cookie_behavior = "all" + } + + headers_config { + header_behavior = "whitelist" + headers { + items = [ + "Accept", + "Content-Type", + "Set-Cookie", + ] + } + } + + query_strings_config { + query_string_behavior = "all" + } +} + ################################################################################ # Cache policies ################################################################################ diff --git a/terraform/20-app/cognito.tf b/terraform/20-app/cognito.tf new file mode 100644 index 00000000..184d4840 --- /dev/null +++ b/terraform/20-app/cognito.tf @@ -0,0 +1,60 @@ +module "cognito" { + source = "../modules/cognito" + sns_role_arn = aws_iam_role.cognito_sns_role.arn + user_pool_name = "${local.prefix}-user-pool" + client_name = "${local.prefix}-client" + user_pool_domain = "${local.prefix}-domain" + callback_urls = concat( + ["https://${terraform.workspace}.dev.ukhsa-dashboard.data.gov.uk/api/auth/callback/cognito"], + local.is_dev ? ["http://localhost:3000/api/auth/callback/cognito", "http://localhost:3001/api/auth/callback/cognito"] : [] + ) + logout_urls = concat( + ["https://${terraform.workspace}.dev.ukhsa-dashboard.data.gov.uk"], + local.is_dev ? ["http://localhost:3000", "http://localhost:3001"] : [] + ) + region = local.region + + ukhsa_oidc_client_id = "ukhsa-oidc-client-id" + ukhsa_oidc_client_secret = "ukhsa-oidc-client-secret" + ukhsa_oidc_issuer_url = "https://example.com/issuer" + ukhsa_oidc_attributes_url = "https://example.com/attributes" + + lambda_role_arn = aws_iam_role.cognito_lambda_role.arn + prefix = local.prefix +} + +module "app_security_group" { + source = "terraform-aws-modules/security-group/aws" + version = "~> 5.0" + + name = "${local.prefix}-security-group" + description = "Security group for the application" + vpc_id = module.vpc.vpc_id + + computed_ingress_with_source_security_group_id = [ + { + rule = "postgresql-tcp" + source_security_group_id = module.aurora_db_app.security_group_id + } + ] + number_of_computed_ingress_with_source_security_group_id = 1 + + egress_rules = ["all-all"] + + tags = { + project_name = local.project + env = terraform.workspace + } +} + +output "cognito_user_pool_id" { + description = "Cognito User Pool ID" + value = module.cognito.cognito_user_pool_id + sensitive = true +} + +output "cognito_user_pool_client_id" { + description = "Cognito User Pool Client ID" + value = module.cognito.cognito_user_pool_client_id + sensitive = true +} \ No newline at end of file diff --git a/terraform/20-app/ecs.service.front-end.tf b/terraform/20-app/ecs.service.front-end.tf index 0176038b..30ad6cc3 100644 --- a/terraform/20-app/ecs.service.front-end.tf +++ b/terraform/20-app/ecs.service.front-end.tf @@ -73,7 +73,15 @@ module "ecs_service_front_end" { { name = "AUTH_ENABLED", value = local.is_auth - } + }, + { + name = "AUTH_DOMAIN" + value = module.cognito.cognito_oauth_url + }, + { + name = "NEXTAUTH_URL" + value = local.urls.front_end + }, ] secrets = [ { @@ -107,6 +115,22 @@ module "ecs_service_front_end" { { name = "ESRI_CLIENT_SECRET" valueFrom = "${aws_secretsmanager_secret.esri_maps_service_credentials.arn}:client_secret::" + }, + { + name = "AUTH_SECRET" + valueFrom = "${aws_secretsmanager_secret.auth_secret.arn}:auth_secret::" + }, + { + name = "AUTH_CLIENT_URL" + valueFrom = "${aws_secretsmanager_secret.cognito_service_credentials.arn}:client_url::" + }, + { + name = "AUTH_CLIENT_ID" + valueFrom = "${aws_secretsmanager_secret.cognito_service_credentials.arn}:client_id::" + }, + { + name = "AUTH_CLIENT_SECRET" + valueFrom = "${aws_secretsmanager_secret.cognito_service_credentials.arn}:client_secret::" } ] } diff --git a/terraform/20-app/main.tf b/terraform/20-app/main.tf index 2c2e71c9..78ece68d 100644 --- a/terraform/20-app/main.tf +++ b/terraform/20-app/main.tf @@ -28,3 +28,4 @@ terraform { key = "app/state.tfstate" } } + diff --git a/terraform/20-app/outputs.tf b/terraform/20-app/outputs.tf index be4ba397..20f7cdb2 100644 --- a/terraform/20-app/outputs.tf +++ b/terraform/20-app/outputs.tf @@ -84,3 +84,4 @@ output "lambda" { ingestion_lambda_arn = module.lambda_ingestion.lambda_function_arn } } + diff --git a/terraform/20-app/secret-manager.tf b/terraform/20-app/secret-manager.tf index 40d93b8c..1b3549fd 100644 --- a/terraform/20-app/secret-manager.tf +++ b/terraform/20-app/secret-manager.tf @@ -140,6 +140,42 @@ resource "aws_secretsmanager_secret_version" "google_analytics_credentials" { }) } +################################################################################ +# Cognito +################################################################################ + +resource "aws_secretsmanager_secret" "cognito_service_credentials" { + name = "${local.prefix}-cognito-service-credentials" + description = "These are the credentials required for AWS Congito service." + kms_key_id = module.kms_secrets_app_engineer.key_id +} + +resource "aws_secretsmanager_secret_version" "cognito_service_credentials" { + secret_id = aws_secretsmanager_secret.cognito_service_credentials.id + secret_string = jsonencode({ + client_url = module.cognito.cognito_user_pool_issuer_endpoint + client_id = module.cognito.cognito_user_pool_client_id + client_secret = module.cognito.cognito_user_pool_client_secret + }) +} + +################################################################################ +# NextAuth +################################################################################ + +resource "aws_secretsmanager_secret" "auth_secret" { + name = "${local.prefix}-auth-secret" + description = "Used to encrypt the NextAuth.js JWT" + kms_key_id = module.kms_secrets_app_operator.key_id +} + +resource "aws_secretsmanager_secret_version" "auth_secret" { + secret_id = aws_secretsmanager_secret.auth_secret.id + secret_string = jsonencode({ + auth_secret = local.auth_secret + }) +} + ################################################################################ # ESRI maps credentials ################################################################################ diff --git a/terraform/20-app/sns.cognito.tf b/terraform/20-app/sns.cognito.tf new file mode 100644 index 00000000..36ce0137 --- /dev/null +++ b/terraform/20-app/sns.cognito.tf @@ -0,0 +1,69 @@ +module "cognito_sns" { + source = "terraform-aws-modules/sns/aws" + version = "6.1.2" + + name = "${local.prefix}-cognito-topic" + + subscriptions = [ + { + protocol = "email" + endpoint = var.cognito_admin_email + } + ] +} + +resource "aws_iam_role" "cognito_sns_role" { + name = "${local.prefix}-cognito-sns-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Principal = { + Service = "cognito-idp.amazonaws.com" + }, + Action = "sts:AssumeRole" + } + ] + }) +} + +resource "aws_iam_policy" "cognito_sns_policy" { + name = "${local.prefix}-cognito-sns-policy" + description = "Allows Cognito to publish messages to the SNS topic" + + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Sid = "AllowCognitoToPublish", + Effect = "Allow", + Action = ["sns:Publish"], + Resource = module.cognito_sns.topic_arn + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "cognito_sns_policy_attachment" { + role = aws_iam_role.cognito_sns_role.id + policy_arn = aws_iam_policy.cognito_sns_policy.arn +} + +resource "aws_iam_role" "cognito_lambda_role" { + name = "${local.prefix}-lambda-execution-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Principal = { + Service = "lambda.amazonaws.com" + }, + Action = "sts:AssumeRole" + } + ] + }) +} \ No newline at end of file diff --git a/terraform/20-app/vars.tf b/terraform/20-app/vars.tf index 7868ffef..815d2dec 100644 --- a/terraform/20-app/vars.tf +++ b/terraform/20-app/vars.tf @@ -31,3 +31,15 @@ variable "single_nat_gateway" { } variable "halo_account_type" {} + +variable "api_gateway_stage_name" { + description = "The stage name for API Gateway (e.g. dev or live)" + type = string + default = "dev" +} + +variable "cognito_admin_email" { + description = "Admin email address for Cognito SNS notifications" + type = string + default = "Afaan.Ashiq@ukhsa.gov.uk" +} \ No newline at end of file diff --git a/terraform/modules/api-gateway/api_gateway_lambda.js b/terraform/modules/api-gateway/api_gateway_lambda.js new file mode 100644 index 00000000..753144a0 --- /dev/null +++ b/terraform/modules/api-gateway/api_gateway_lambda.js @@ -0,0 +1,11 @@ +exports.handler = async (event) => { + console.log("Received event:", JSON.stringify(event, null, 2)); + + return { + statusCode: 200, + body: JSON.stringify({ message: "Hello from API Gateway via Lambda!" }), + headers: { + "Content-Type": "application/json" + } + }; +}; \ No newline at end of file diff --git a/terraform/modules/api-gateway/api_gateway_lambda.zip b/terraform/modules/api-gateway/api_gateway_lambda.zip new file mode 100644 index 00000000..dd58261d Binary files /dev/null and b/terraform/modules/api-gateway/api_gateway_lambda.zip differ diff --git a/terraform/modules/api-gateway/iam.tf b/terraform/modules/api-gateway/iam.tf new file mode 100644 index 00000000..c2afe1d6 --- /dev/null +++ b/terraform/modules/api-gateway/iam.tf @@ -0,0 +1,61 @@ +resource "aws_api_gateway_account" "account" { + cloudwatch_role_arn = aws_iam_role.api_gateway_cloudwatch_role.arn + + depends_on = [ + aws_iam_role.api_gateway_cloudwatch_role, + aws_iam_role_policy.api_gateway_cloudwatch_policy + ] +} + +resource "aws_iam_role" "api_gateway_cloudwatch_role" { + name = "${var.prefix}-api-gateway-cloudwatch-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Principal = { + Service = "apigateway.amazonaws.com" + }, + Action = "sts:AssumeRole" + } + ] + }) +} + +resource "aws_iam_role_policy" "api_gateway_cloudwatch_policy" { + role = aws_iam_role.api_gateway_cloudwatch_role.id + + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams", + "logs:GetLogEvents", + "logs:FilterLogEvents" + ], + Resource = [ + "arn:aws:logs:${var.region}:${data.aws_caller_identity.current.account_id}:log-group:/aws/apigateway/*" + ] + }, + { + Effect = "Allow", + Action = [ + "apigateway:GET", + "apigateway:PUT", + "apigateway:POST", + "apigateway:DELETE", + "apigateway:PATCH" + ], + Resource = aws_api_gateway_rest_api.api_gateway.execution_arn + } + ] + }) +} \ No newline at end of file diff --git a/terraform/modules/api-gateway/lambda.tf b/terraform/modules/api-gateway/lambda.tf new file mode 100644 index 00000000..d37497d5 --- /dev/null +++ b/terraform/modules/api-gateway/lambda.tf @@ -0,0 +1,37 @@ +resource "aws_lambda_function" "api_gateway_lambda" { + function_name = "${var.prefix}-api-gateway-lambda" + runtime = "nodejs18.x" + role = var.lambda_role_arn + handler = "api_gateway_lambda.handler" + + source_code_hash = filebase64sha256("${path.module}/api_gateway_lambda.zip") + filename = "${path.module}/api_gateway_lambda.zip" + timeout = 15 + publish = true + description = "Handles API Gateway requests for the ${var.prefix} service" +} + +resource "aws_lambda_alias" "live" { + name = "live" + description = "Alias pointing to the live version of the Lambda function" + function_name = aws_lambda_function.api_gateway_lambda.arn + function_version = "$LATEST" +} + +resource "aws_lambda_alias" "dev" { + name = "dev" + description = "Alias pointing to the dev version of the Lambda function" + function_name = aws_lambda_function.api_gateway_lambda.arn + function_version = "$LATEST" +} + +resource "aws_lambda_permission" "allow_api_gateway" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = lookup({ + "live" = aws_lambda_alias.live.arn, + "dev" = aws_lambda_alias.dev.arn + }, var.lambda_alias, aws_lambda_alias.live.arn) + principal = "apigateway.amazonaws.com" + source_arn = "arn:aws:execute-api:${var.region}:${data.aws_caller_identity.current.account_id}:${aws_api_gateway_rest_api.api_gateway.id}/*/*/*" +} \ No newline at end of file diff --git a/terraform/modules/api-gateway/main.tf b/terraform/modules/api-gateway/main.tf new file mode 100644 index 00000000..2a6791bf --- /dev/null +++ b/terraform/modules/api-gateway/main.tf @@ -0,0 +1,70 @@ +data "aws_caller_identity" "current" {} + +resource "aws_api_gateway_rest_api" "api_gateway" { + name = var.name + description = var.description +} + +resource "aws_api_gateway_resource" "proxy" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + parent_id = aws_api_gateway_rest_api.api_gateway.root_resource_id + path_part = "{proxy+}" +} + +resource "aws_api_gateway_method" "proxy" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.proxy.id + http_method = "ANY" + authorization = "COGNITO_USER_POOLS" + authorizer_id = aws_api_gateway_authorizer.cognito.id +} + +resource "aws_api_gateway_authorizer" "cognito" { + name = "${var.prefix}-cognito-authorizer" + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + type = "COGNITO_USER_POOLS" + provider_arns = [var.cognito_user_pool_arn] +} + +resource "aws_api_gateway_integration" "proxy_integration" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.proxy.id + http_method = aws_api_gateway_method.proxy.http_method + integration_http_method = "POST" + type = "AWS_PROXY" + uri = "arn:aws:apigateway:${var.region}:lambda:path/2015-03-31/functions/${lookup({ + "live" = aws_lambda_alias.live.arn, + "dev" = aws_lambda_alias.dev.arn +}, var.lambda_alias, aws_lambda_alias.live.arn)}/invocations" +} + +resource "aws_api_gateway_deployment" "deployment" { + depends_on = [ + aws_api_gateway_method.proxy, + aws_api_gateway_integration.proxy_integration + ] + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + + triggers = { + redeployment = timestamp() + } + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_api_gateway_stage" "stage" { + stage_name = var.api_gateway_stage_name + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + deployment_id = aws_api_gateway_deployment.deployment.id + + description = "Stage for ${var.api_gateway_stage_name}" + variables = { + lambda_alias = var.lambda_alias + } + + lifecycle { + prevent_destroy = false + } +} diff --git a/terraform/modules/api-gateway/outputs.tf b/terraform/modules/api-gateway/outputs.tf new file mode 100644 index 00000000..02c241b0 --- /dev/null +++ b/terraform/modules/api-gateway/outputs.tf @@ -0,0 +1,32 @@ +output "api_gateway_id" { + description = "The ID of the API Gateway" + value = aws_api_gateway_rest_api.api_gateway.id +} + +output "api_gateway_url" { + description = "The invoke URL of the API Gateway" + value = aws_api_gateway_deployment.deployment.invoke_url +} + +output "api_gateway_stage_name" { + description = "The stage name of the API Gateway deployment" + value = aws_api_gateway_stage.stage.stage_name +} + +output "api_gateway_lambda_invoke_arn" { + description = "The invoke ARN for the API Gateway Lambda function alias" + value = var.lambda_alias == "live" ? aws_lambda_alias.live.arn : aws_lambda_alias.dev.arn +} + +output "lambda_alias_arn" { + description = "The ARN of the selected Lambda alias" + value = lookup({ + "live" = aws_lambda_alias.live.arn, + "dev" = aws_lambda_alias.dev.arn + }, var.lambda_alias, aws_lambda_alias.live.arn) +} + +output "api_gateway_lambda_arn" { + description = "The ARN of the API Gateway Lambda function" + value = aws_lambda_function.api_gateway_lambda.arn +} \ No newline at end of file diff --git a/terraform/modules/api-gateway/vars.tf b/terraform/modules/api-gateway/vars.tf new file mode 100644 index 00000000..89b0ac5d --- /dev/null +++ b/terraform/modules/api-gateway/vars.tf @@ -0,0 +1,112 @@ +variable "name" { + description = "The name of the API Gateway" + type = string + + validation { + condition = length(var.name) > 0 + error_message = "The 'name' variable must be a non-empty string." + } +} + +variable "description" { + description = "The description of the API Gateway" + type = string + default = "API Gateway for the application" + + validation { + condition = length(var.description) > 0 + error_message = "The 'description' variable must be a non-empty string." + } +} + +variable "api_gateway_stage_name" { + description = "The stage name for API Gateway (e.g. dev or live)" + type = string + default = "dev" + + validation { + condition = can(regex("^[a-zA-Z0-9_-]+$", var.api_gateway_stage_name)) + error_message = "The 'stage_name' variable must only contain alphanumeric characters, dashes, or underscores." + } +} + +variable "lambda_function_arn" { + description = "The ARN of the Lambda function to integrate with API Gateway" + type = string + + validation { + condition = can(regex("^arn:aws:lambda:.*:.*:function:.*$", var.lambda_function_arn)) + error_message = "The 'lambda_function_arn' must be a valid Lambda function ARN." + } +} + +variable "cognito_user_pool_arn" { + description = "The ARN of the Cognito User Pool for authorizing requests" + type = string + + validation { + condition = can(regex("^arn:aws:cognito-idp:.*:.*:userpool/.*$", var.cognito_user_pool_arn)) + error_message = "The 'cognito_user_pool_arn' must be a valid Cognito User Pool ARN." + } +} + +variable "region" { + description = "The AWS region for resources" + type = string + + validation { + condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]{1}$", var.region)) + error_message = "The 'region' variable must be a valid AWS region string, e.g. 'eu-west-2'." + } +} + +variable "resource_path_part" { + description = "The resource path part for API Gateway (e.g. 'data' or '{proxy+}')" + type = string + default = "{proxy+}" + + validation { + condition = can(regex("^[a-zA-Z0-9/{}+_-]+$", var.resource_path_part)) + error_message = "The 'resource_path_part' must be a valid path segment containing alphanumeric characters, slashes, curly braces, dashes, or underscores." + } +} + +variable "lambda_invoke_arn" { + description = "The ARN of the Lambda function for API Gateway" + type = string + + validation { + condition = can(regex("^arn:aws:lambda:.*:.*:function:.*$", var.lambda_invoke_arn)) + error_message = "The 'lambda_invoke_arn' must be a valid Lambda function ARN." + } +} + +variable "lambda_role_arn" { + description = "IAM Role ARN for the Lambda function" + type = string + + validation { + condition = can(regex("^arn:aws:iam::\\d{12}:role/.+$", var.lambda_role_arn)) + error_message = "The 'lambda_role_arn' must be a valid IAM Role ARN." + } +} + +variable "lambda_alias" { + description = "The alias for the Lambda function (e.g. live, dev)" + type = string + default = "live" + + validation { + condition = contains(["dev", "live"], var.lambda_alias) + error_message = "Invalid alias provided. Allowed values are 'dev' or 'live'." + } +} + +variable "prefix" { + description = "Prefix for naming resources" + type = string + validation { + condition = can(regex("^[a-zA-Z0-9_-]+$", var.prefix)) + error_message = "Prefix must only contain letters, numbers, hyphens, or underscores." + } +} \ No newline at end of file diff --git a/terraform/modules/cognito/main.tf b/terraform/modules/cognito/main.tf new file mode 100644 index 00000000..e4b6d33c --- /dev/null +++ b/terraform/modules/cognito/main.tf @@ -0,0 +1,96 @@ +data "aws_caller_identity" "current" {} + +resource "aws_cognito_user_pool" "user_pool" { + name = var.user_pool_name + + username_attributes = ["email"] + + mfa_configuration = "OFF" + + password_policy { + minimum_length = 8 + require_lowercase = true + require_numbers = true + require_symbols = false + require_uppercase = true + } + + auto_verified_attributes = ["email"] + + account_recovery_setting { + recovery_mechanism { + name = "verified_email" + priority = 1 + } + } +} + +resource "aws_cognito_user_pool_client" "user_pool_client" { + depends_on = [ + aws_cognito_identity_provider.ukhsa_oidc_idp + ] + + name = var.client_name + user_pool_id = aws_cognito_user_pool.user_pool.id + generate_secret = true + + allowed_oauth_flows = ["code"] + allowed_oauth_flows_user_pool_client = true + allowed_oauth_scopes = ["openid", "email", "profile", "aws.cognito.signin.user.admin"] + + access_token_validity = 60 # 60 minutes + id_token_validity = 60 # 60 minutes + refresh_token_validity = 30 # 30 days + + token_validity_units { + access_token = "minutes" + id_token = "minutes" + refresh_token = "days" + } + + prevent_user_existence_errors = "ENABLED" + + explicit_auth_flows = [ + "ALLOW_REFRESH_TOKEN_AUTH", + "ALLOW_USER_PASSWORD_AUTH", + ] + + callback_urls = var.callback_urls + logout_urls = var.logout_urls + + supported_identity_providers = var.enable_ukhsa_oidc ? ["COGNITO", "UKHSAOIDC"] : ["COGNITO"] +} + +resource "aws_cognito_user_pool_domain" "cognito_user_pool_domain" { + domain = var.user_pool_domain + user_pool_id = aws_cognito_user_pool.user_pool.id + + lifecycle { + ignore_changes = [domain] + } +} + +resource "aws_cognito_identity_provider" "ukhsa_oidc_idp" { + count = var.enable_ukhsa_oidc ? 1 : 0 + user_pool_id = aws_cognito_user_pool.user_pool.id + provider_name = "UKHSAOIDC" + provider_type = "OIDC" + + provider_details = { + client_id = var.ukhsa_oidc_client_id + client_secret = var.ukhsa_oidc_client_secret + oidc_issuer = var.ukhsa_oidc_issuer_url + authorize_scopes = "openid email" + attributes_request_method = "GET" + attributes_url = var.ukhsa_oidc_attributes_url + attributes_url_add_attributes = "true" + } +} + +resource "aws_cognito_user_group" "cognito_user_groups" { + for_each = toset(["Admin", "Analyst", "Viewer"]) + name = each.value + user_pool_id = aws_cognito_user_pool.user_pool.id + precedence = lookup(var.group_precedence, each.value, null) + description = "Group for ${each.value} role" +} diff --git a/terraform/modules/cognito/outputs.tf b/terraform/modules/cognito/outputs.tf new file mode 100644 index 00000000..c4dd7b05 --- /dev/null +++ b/terraform/modules/cognito/outputs.tf @@ -0,0 +1,70 @@ +output "cognito_user_pool_id" { + description = "The ID of the Cognito User Pool" + value = aws_cognito_user_pool.user_pool.id + sensitive = true +} + +output "cognito_user_pool_client_id" { + description = "The ID of the Cognito User Pool Client" + value = aws_cognito_user_pool_client.user_pool_client.id + sensitive = true +} + +output "cognito_user_pool_domain" { + description = "The domain prefix for the Cognito User Pool" + value = aws_cognito_user_pool_domain.cognito_user_pool_domain.domain + sensitive = true +} + +output "cognito_oauth_url" { + description = "The Cognito User Pool OAuth URL" + value = "https://${aws_cognito_user_pool_domain.cognito_user_pool_domain.domain}.auth.${var.region}.amazoncognito.com" + sensitive = true +} + +output "cognito_oauth_authorize_url" { + description = "The Cognito User Pool OAuth authorize URL" + value = "https://${aws_cognito_user_pool_domain.cognito_user_pool_domain.domain}.auth.${var.region}.amazoncognito.com/oauth2/authorize" + sensitive = true +} + +output "cognito_oauth_logout_url" { + description = "The Cognito User Pool OAuth logout URL" + value = "https://${aws_cognito_user_pool_domain.cognito_user_pool_domain.domain}.auth.${var.region}.amazoncognito.com/logout" + sensitive = true +} + +output "cognito_oauth_token_url" { + description = "The Cognito User Pool OAuth token URL" + value = "https://${aws_cognito_user_pool_domain.cognito_user_pool_domain.domain}.auth.${var.region}.amazoncognito.com/oauth2/token" + sensitive = true +} + +output "cognito_oauth_userinfo_url" { + description = "The Cognito User Pool OAuth userinfo URL" + value = "https://${aws_cognito_user_pool_domain.cognito_user_pool_domain.domain}.auth.${var.region}.amazoncognito.com/oauth2/userInfo" + sensitive = true +} + +output "cognito_user_pool_arn" { + description = "ARN of the Cognito User Pool" + value = aws_cognito_user_pool.user_pool.arn + sensitive = true +} + +output "cognito_user_pool_client_secret" { + description = "The Client Secret for Cognito User Pool Client" + value = aws_cognito_user_pool_client.user_pool_client.client_secret + sensitive = true +} + +output "cognito_user_pool_issuer_endpoint" { + description = "The Issuer API Endpoint for Cognito User Pool" + value = "https://cognito-idp.${var.region}.amazonaws.com/${aws_cognito_user_pool.user_pool.id}" + sensitive = true +} + +output "cognito_lambda_role_arn" { + description = "The ARN of the Cognito Lambda execution role" + value = var.lambda_role_arn +} diff --git a/terraform/modules/cognito/vars.tf b/terraform/modules/cognito/vars.tf new file mode 100644 index 00000000..ed3d82cc --- /dev/null +++ b/terraform/modules/cognito/vars.tf @@ -0,0 +1,95 @@ +variable "region" { + description = "The AWS region for resources" + type = string +} + +variable "sns_role_arn" { + description = "ARN of the SNS role for MFA" + type = string + default = null + validation { + condition = can(regex("^arn:aws:iam::\\d+:role/.+", var.sns_role_arn)) + error_message = "sns_role_arn must be a valid ARN of an IAM Role." + } +} + +variable "callback_urls" { + description = "List of allowed callback URLs for OAuth flows" + type = list(string) + default = [] + validation { + condition = alltrue([for url in var.callback_urls : can(regex("^(http|https)://", url))]) + error_message = "Each callback URL must start with http:// or https://" + } +} + +variable "logout_urls" { + description = "List of allowed logout URLs for OAuth flows" + type = list(string) + default = [] +} + +variable "group_precedence" { + description = "Precedence of user groups" + type = map(number) + default = { + Admin = 1 + Analyst = 2 + Viewer = 3 + } +} + +variable "user_pool_name" { + description = "Name of the Cognito User Pool" + type = string +} + +variable "client_name" { + description = "Name of the Cognito User Pool Client" + type = string +} + +variable "user_pool_domain" { + description = "Domain for the Cognito User Pool" + type = string +} + +variable "ukhsa_oidc_client_id" { + description = "UKHSA OIDC Client ID" + type = string + default = "" +} + +variable "ukhsa_oidc_client_secret" { + description = "UKHSA OIDC Client Secret" + type = string + default = "" +} + +variable "ukhsa_oidc_issuer_url" { + description = "UKHSA OIDC Issuer URL" + type = string + default = "" +} + +variable "ukhsa_oidc_attributes_url" { + description = "UKHSA OIDC Attributes URL" + type = string + default = "" +} + +variable "enable_ukhsa_oidc" { + description = "Enable UKHSA OIDC Identity Provider" + type = bool + default = false +} + +variable "lambda_role_arn" { + description = "The ARN of the Cognito Lambda execution role" + type = string +} + +variable "prefix" { + description = "Prefix for naming resources" + type = string +}