diff --git a/infrastructure/modules/api-gateway/.terraform.lock.hcl b/infrastructure/modules/api-gateway/.terraform.lock.hcl index 8c6b0923..27a6db38 100644 --- a/infrastructure/modules/api-gateway/.terraform.lock.hcl +++ b/infrastructure/modules/api-gateway/.terraform.lock.hcl @@ -3,7 +3,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.50.0" - constraints = ">= 6.49.0" + constraints = ">= 6.14.0, >= 6.42.0" hashes = [ "h1:8y10QFtGLHl3pF/R1/hO7VCPHTexm1whc0BfuG4uruw=", "h1:D8uNiOpl3UkAX4zI5T47ALMiRFXTa1XfdQC+TBu3RmE=", @@ -28,28 +28,3 @@ provider "registry.terraform.io/hashicorp/aws" { "zh:ead82831683619124597a1f170dd31e9b293e9cf22f558cb166d5e734fcd11e4", ] } - -provider "registry.terraform.io/hashicorp/random" { - version = "3.9.0" - constraints = ">= 3.9.0" - hashes = [ - "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", - "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", - "h1:lVDv+0AjDjrLfpmaJbWqUmIw/k3/AHXLc3N4m55SNdo=", - "h1:o0s5Mk9NXMP60nlheO1r0LsDGGratFb3oL0t7bD2QnM=", - "h1:q/uaUTBdKgAmZESrwsoeDQff9uUA/cI/N5ZKNgVwa9c=", - "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", - "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", - "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", - "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", - "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", - "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", - "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", - "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", - "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", - "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", - "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", - ] -} diff --git a/infrastructure/modules/api-gateway/context.tf b/infrastructure/modules/api-gateway/context.tf new file mode 100644 index 00000000..9a4652b0 --- /dev/null +++ b/infrastructure/modules/api-gateway/context.tf @@ -0,0 +1,377 @@ +# tflint-ignore-file: terraform_standard_module_structure, terraform_unused_declarations +# +# ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/NHSDigital/screening-terraform-modules-aws/blob/master/infrastructure/modules/tags/exports/context.tf +# and then place it in your Terraform module to automatically get +# tag module standard configuration inputs suitable for passing +# to other modules. +# +# curl -sL https://raw.githubusercontent.com/NHSDigital/screening-terraform-modules-aws/master/infrastructure/modules/tags/exports/context.tf -o context.tf +# +# Modules should access the whole context as `module.this.context` +# to get the input variables with nulls for defaults, +# for example `context = module.this.context`, +# and access individual variables as `module.this.`, +# with final values filled in. +# +# For example, when using defaults, `module.this.context.delimiter` +# will be null, and `module.this.delimiter` will be `-` (hyphen). +# + +module "this" { + # tflint-ignore: terraform_module_pinned_source + source = "../tags" + + enabled = var.enabled + service = var.service + project = var.project + region = var.region + environment = var.environment + stack = var.stack + workspace = var.workspace + name = var.name + delimiter = var.delimiter + attributes = var.attributes + tags = var.tags + additional_tag_map = var.additional_tag_map + label_order = var.label_order + regex_replace_chars = var.regex_replace_chars + id_length_limit = var.id_length_limit + label_key_case = var.label_key_case + label_value_case = var.label_value_case + terraform_source = coalesce(var.terraform_source, path.module) + descriptor_formats = var.descriptor_formats + labels_as_tags = var.labels_as_tags + + context = var.context +} + +# Copy contents of screening-terraform-modules-aws/tags/variables.tf here +# tflint-ignore: terraform_unused_declarations +variable "aws_region" { + type = string + description = "The AWS region" + default = "eu-west-2" + validation { + condition = contains(["eu-west-1", "eu-west-2", "us-east-1"], var.aws_region) + error_message = "AWS Region must be one of eu-west-1, eu-west-2, us-east-1" + } +} + +variable "context" { + type = any + default = { + enabled = true + service = null + project = null + region = null + environment = null + stack = null + workspace = null + name = null + delimiter = null + attributes = [] + tags = {} + additional_tag_map = {} + regex_replace_chars = null + label_order = [] + id_length_limit = null + label_key_case = null + label_value_case = null + terraform_source = null + descriptor_formats = {} + # Note: we have to use [] instead of null for unset lists due to + # https://github.com/hashicorp/terraform/issues/28137 + # which was not fixed until Terraform 1.0.0, + # but we want the default to be all the labels in `label_order` + # and we want users to be able to prevent all tag generation + # by setting `labels_as_tags` to `[]`, so we need + # a different sentinel to indicate "default" + labels_as_tags = ["unset"] + } + description = <<-EOT + Single object for setting entire context at once. + See description of individual variables for details. + Leave string and numeric variables as `null` to use default value. + Individual variable settings (non-null) override settings in context object, + except for attributes, tags, and additional_tag_map, which are merged. + EOT + + validation { + condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`." + } + + validation { + condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "terraform_source" { + type = string + default = null + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." +} + +variable "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "service" { + type = string + default = null + description = "ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique" +} + +variable "region" { + type = string + default = null + description = "ID element _(Rarely used, not included by default)_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region" +} + +variable "project" { + type = string + default = null + description = "ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api`" +} +variable "stack" { + type = string + default = null + description = "ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks`" +} +variable "workspace" { + type = string + default = null + description = "ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces" +} +variable "environment" { + type = string + default = null + description = "ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat'" +} + +variable "name" { + type = string + default = null + description = <<-EOT + ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'. + This is the only ID element not also included as a `tag`. + The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. + EOT +} + +variable "delimiter" { + type = string + default = null + description = <<-EOT + Delimiter to be used between ID elements. + Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. + EOT +} + +variable "attributes" { + type = list(string) + default = [] + description = <<-EOT + ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`, + in the order they appear in the list. New attributes are appended to the + end of the list. The elements of the list are joined by the `delimiter` + and treated as a single ID element. + EOT +} + +variable "labels_as_tags" { + type = set(string) + default = ["default"] + description = <<-EOT + Set of labels (ID elements) to include as tags in the `tags` output. + Default is to include all labels. + Tags with empty values will not be included in the `tags` output. + Set to `[]` to suppress all generated tags. + **Notes:** + The value of the `name` tag, if included, will be the `id`, not the `name`. + Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be + changed in later chained modules. Attempts to change it will be silently ignored. + EOT +} + +variable "tags" { + type = map(string) + default = {} + description = <<-EOT + Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`). + Neither the tag keys nor the tag values will be modified by this module. + EOT +} + +variable "additional_tag_map" { + type = map(string) + default = {} + description = <<-EOT + Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`. + This is for some rare cases where resources want additional configuration of tags + and therefore take a list of maps with tag key, value, and additional configuration. + EOT +} + +variable "label_order" { + type = list(string) + default = null + description = <<-EOT + The order in which the labels (ID elements) appear in the `id`. + Defaults to ["namespace", "environment", "stage", "name", "attributes"]. + You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. + EOT +} + +variable "regex_replace_chars" { + type = string + default = null + description = <<-EOT + Terraform regular expression (regex) string. + Characters matching the regex will be removed from the ID elements. + If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. + EOT +} + +variable "id_length_limit" { + type = number + default = null + description = <<-EOT + Limit `id` to this many characters (minimum 6). + Set to `0` for unlimited length. + Set to `null` for keep the existing setting, which defaults to `0`. + Does not affect `id_full`. + EOT + validation { + condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 + error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." + } +} + +variable "label_key_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of the `tags` keys (label names) for tags generated by this module. + Does not affect keys of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper`. + Default value: `title`. + EOT + + validation { + condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) + error_message = "Allowed values: `lower`, `title`, `upper`." + } +} + +variable "label_value_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of ID elements (labels) as included in `id`, + set as tag values, and output by this module individually. + Does not affect values of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper` and `none` (no transformation). + Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs. + Default value: `lower`. + EOT + + validation { + condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "descriptor_formats" { + type = any + default = {} + description = <<-EOT + Describe additional descriptors to be output in the `descriptors` output map. + Map of maps. Keys are names of descriptors. Values are maps of the form + `{ + format = string + labels = list(string) + }` + (Type is `any` so the map values can later be enhanced to provide additional options.) + `format` is a Terraform format string to be passed to the `format()` function. + `labels` is a list of labels, in order, to pass to `format()` function. + Label values will be normalized before being passed to `format()` so they will be + identical to how they appear in `id`. + Default is `{}` (`descriptors` output will be empty). + EOT +} + +variable "owner" { + type = string + description = "The name and or NHS.net email address of the service owner" + default = "None" +} + +variable "tag_version" { + type = string + description = "Used to identify the tagging version in use" + default = "1.0" +} + +variable "data_classification" { + type = string + description = "Used to identify the data classification of the resource, e.g 1-5" + default = "n/a" + validation { + condition = contains(["n/a", "1", "2", "3", "4", "5"], var.data_classification) + error_message = "Data Classification must be \"n/a\" or between 1-5" + } +} + +variable "data_type" { + type = string + description = "The tag data_type" + default = "None" + validation { + condition = contains(["None", "PCD", "PID", "Anonymised", "UserAccount", "Audit"], var.data_type) + error_message = "Data Type must be one of None, PCD, PID, Anonymised, UserAccount, Audit" + } +} + + +variable "public_facing" { + type = bool + description = "Whether this resource is public facing" + default = false +} + +variable "service_category" { + type = string + description = "The tag service_category" + default = "n/a" + validation { + condition = contains(["n/a", "Bronze", "Silver", "Gold", "Platinum"], var.service_category) + error_message = "The Service Category must be one of n/a, Bronze, Silver, Gold, Platinum" + } +} +variable "on_off_pattern" { + type = string + description = "Used to turn resources on and off based on a time pattern" + default = "n/a" +} + +variable "application_role" { + type = string + description = "The role the application is performing" + default = "General" +} + +variable "tool" { + type = string + description = "The tool used to deploy the resource" + default = "Terraform" +} + +#### End of copy of screening-terraform-modules-aws/tags/variables.tf diff --git a/infrastructure/modules/api-gateway/locals.tf b/infrastructure/modules/api-gateway/locals.tf new file mode 100644 index 00000000..3ea0736a --- /dev/null +++ b/infrastructure/modules/api-gateway/locals.tf @@ -0,0 +1,15 @@ +locals { + api_name = coalesce(var.custom_name, module.this.id) + + access_log_format = jsonencode({ + requestId = "$context.requestId" + ip = "$context.identity.sourceIp" + requestTime = "$context.requestTime" + httpMethod = "$context.httpMethod" + routeKey = "$context.routeKey" + status = "$context.status" + protocol = "$context.protocol" + responseLength = "$context.responseLength" + integrationErrorMessage = "$context.integrationErrorMessage" + }) +} diff --git a/infrastructure/modules/api-gateway/main.tf b/infrastructure/modules/api-gateway/main.tf index 1b5d1aed..4b110bd9 100644 --- a/infrastructure/modules/api-gateway/main.tf +++ b/infrastructure/modules/api-gateway/main.tf @@ -1,252 +1,81 @@ -########### -# Secrets # -########### -resource "random_password" "api_auth_token" { - length = 20 - special = false -} +################################################################ +# API Gateway HTTP API +# +# Minimal NHS wrapper for the GitHub ARC webhook publisher use case. +# Creates a single API Gateway v2 HTTP API with: +# +# * Lambda proxy integration for a webhook endpoint +# * Single route definition +# * Default stage with auto deploy enabled +# * Access logging to CloudWatch Logs +# * Lambda invoke permission scoped to this API +# +# Naming and tagging are derived from context.tf via module.this. +################################################################ -resource "aws_secretsmanager_secret" "api_token" { - name = "${var.name_prefix}-${var.api_gateway_name}-api" - description = "Auth token for api gateway" +resource "aws_cloudwatch_log_group" "this" { + count = module.this.enabled ? 1 : 0 - dynamic "replica" { - for_each = var.secret_replication_regions - content { - region = replica.value - } - } -} + name = "/aws/apigateway/${local.api_name}" + retention_in_days = var.access_log_retention_in_days -resource "aws_secretsmanager_secret_version" "api_token" { - secret_id = aws_secretsmanager_secret.api_token.id - secret_string = random_password.api_auth_token.result + tags = module.this.tags } +resource "aws_apigatewayv2_api" "this" { + count = module.this.enabled ? 1 : 0 + name = local.api_name + protocol_type = "HTTP" + description = var.description -################ -# API Gateway # -################ - -# API Gateway REST API -resource "aws_api_gateway_rest_api" "api" { - name = "${var.name_prefix}-${var.api_gateway_name}" - description = var.api_gateway_description - - endpoint_configuration { - types = ["REGIONAL"] - } - - lifecycle { - create_before_destroy = true - } + tags = module.this.tags } +resource "aws_apigatewayv2_integration" "this" { + count = module.this.enabled ? 1 : 0 - -# API Resource -resource "aws_api_gateway_resource" "api_resource" { - rest_api_id = aws_api_gateway_rest_api.api.id - parent_id = aws_api_gateway_rest_api.api.root_resource_id - path_part = var.api_path_part + api_id = aws_apigatewayv2_api.this[0].id + integration_type = "AWS_PROXY" + integration_method = "POST" + integration_uri = var.lambda_invoke_arn + payload_format_version = "2.0" + timeout_milliseconds = var.integration_timeout_milliseconds } -resource "aws_api_gateway_method" "post_method" { - rest_api_id = aws_api_gateway_rest_api.api.id - resource_id = aws_api_gateway_resource.api_resource.id - http_method = var.http_method - authorization = "NONE" - api_key_required = true -} +resource "aws_apigatewayv2_route" "this" { + count = module.this.enabled ? 1 : 0 -# Integration with Lambda -resource "aws_api_gateway_integration" "lambda_integration" { - rest_api_id = aws_api_gateway_rest_api.api.id - resource_id = aws_api_gateway_resource.api_resource.id - http_method = aws_api_gateway_method.post_method.http_method - integration_http_method = "POST" - type = "AWS_PROXY" - uri = var.aws_lambda_arn + api_id = aws_apigatewayv2_api.this[0].id + route_key = var.route_key + target = "integrations/${aws_apigatewayv2_integration.this[0].id}" } -# Lambda Permission for API Gateway -resource "aws_lambda_permission" "api_gateway" { - statement_id = "AllowAPIGatewayInvoke" - action = "lambda:InvokeFunction" - function_name = var.aws_lambda_name - principal = "apigateway.amazonaws.com" - source_arn = "${aws_api_gateway_rest_api.api.execution_arn}/*/*" -} - -# Deploy API Gateway -resource "aws_api_gateway_deployment" "deployment" { - depends_on = [aws_api_gateway_integration.lambda_integration] - - rest_api_id = aws_api_gateway_rest_api.api.id +resource "aws_apigatewayv2_stage" "this" { + count = module.this.enabled ? 1 : 0 - triggers = { - redeployment_trigger = sha1(jsonencode(aws_api_gateway_integration.lambda_integration)) - } - - lifecycle { - create_before_destroy = true - } -} - -resource "aws_api_gateway_stage" "stage" { - deployment_id = aws_api_gateway_deployment.deployment.id - rest_api_id = aws_api_gateway_rest_api.api.id - stage_name = var.stage_name - xray_tracing_enabled = true + api_id = aws_apigatewayv2_api.this[0].id + name = var.stage_name + auto_deploy = true access_log_settings { - destination_arn = aws_cloudwatch_log_group.log_group.arn - format = "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"user\":\"$context.identity.user\",\"requestTime\":\"$context.requestTime\",\"httpMethod\":\"$context.httpMethod\",\"resourcePath\":\"$context.resourcePath\",\"status\":\"$context.status\",\"protocol\":\"$context.protocol\",\"responseLength\":\"$context.responseLength\"}" + destination_arn = aws_cloudwatch_log_group.this[0].arn + format = local.access_log_format } -} - -########################## -# API Key and Usage Plan # -########################## -resource "aws_api_gateway_api_key" "my_api_key" { - name = "${var.name_prefix}-${var.api_gateway_name}-api-key-${var.api_gateway_name}" - enabled = true - value = aws_secretsmanager_secret_version.api_token.secret_string -} -resource "aws_api_gateway_usage_plan" "usage_plan" { - depends_on = [aws_api_gateway_stage.stage] - name = "${var.name_prefix}-${var.api_gateway_name}-usage-plan" - description = "The usage plan used for the ${var.name_prefix}-${var.api_gateway_name} endpoint" - - api_stages { - api_id = aws_api_gateway_rest_api.api.id - stage = aws_api_gateway_stage.stage.stage_name - } - - quota_settings { - limit = 1000 - period = "WEEK" + default_route_settings { + detailed_metrics_enabled = var.enable_detailed_metrics } - throttle_settings { - burst_limit = 25 - rate_limit = 50 - } + tags = module.this.tags } -resource "aws_api_gateway_usage_plan_key" "usage_plan_key" { - key_id = aws_api_gateway_api_key.my_api_key.id - key_type = "API_KEY" - usage_plan_id = aws_api_gateway_usage_plan.usage_plan.id -} - - -#################### -# cloudwatch # -#################### +resource "aws_lambda_permission" "this" { + count = module.this.enabled ? 1 : 0 -resource "aws_cloudwatch_log_group" "log_group" { - name = "${var.name_prefix}-api-gateway-Execution-Logs_${aws_api_gateway_rest_api.api.id}/${var.name_prefix}-${var.api_gateway_name}" - retention_in_days = 365 -} - - -############################################### -# IAM roles API Gateway logs account settings # -############################################### -resource "aws_iam_role" "apigateway_cloudwatch" { - name = "${var.name_prefix}-apigateway-cloudwatch-logs" - - assume_role_policy = jsonencode({ - Version = "2012-10-17", - Statement = [{ - Effect = "Allow", - Principal = { - Service = "apigateway.amazonaws.com" - }, - Action = "sts:AssumeRole" - }] - }) -} - -resource "aws_iam_role_policy_attachment" "apigateway_logs" { - role = aws_iam_role.apigateway_cloudwatch.name - policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" -} - -resource "aws_api_gateway_account" "account" { - cloudwatch_role_arn = aws_iam_role.apigateway_cloudwatch.arn -} - -###################### -# route 53 and Cert # -###################### - -resource "aws_acm_certificate" "cert" { - count = var.certificate_arn == null ? 1 : 0 - domain_name = var.hosted_zone_name - subject_alternative_names = ["${var.domain_name_prefix}.${var.hosted_zone_name}"] - validation_method = "DNS" -} - -resource "aws_route53_record" "cert_validation" { - for_each = var.certificate_arn == null ? { - for dvo in aws_acm_certificate.cert[0].domain_validation_options : - dvo.domain_name => { - name = dvo.resource_record_name - type = dvo.resource_record_type - record = dvo.resource_record_value - } - } : {} - - zone_id = var.route53_hosted_zone_id - name = each.value.name - type = each.value.type - ttl = 300 - records = [each.value.record] -} - -resource "aws_acm_certificate_validation" "cert_validation" { - count = var.certificate_arn == null ? 1 : 0 - certificate_arn = aws_acm_certificate.cert[0].arn - - validation_record_fqdns = [ - for record in aws_route53_record.cert_validation : record.fqdn - ] -} - -# Use existing wildcard certificate from DNS stack or newly created certificate -resource "aws_api_gateway_domain_name" "gateway_domain_name" { - domain_name = "${var.domain_name_prefix}.${var.hosted_zone_name}" - regional_certificate_arn = var.certificate_arn != null ? var.certificate_arn : aws_acm_certificate_validation.cert_validation[0].certificate_arn - security_policy = "TLS_1_2" - - endpoint_configuration { - types = ["REGIONAL"] - } -} - - -resource "aws_route53_record" "route53_record" { - name = "${var.domain_name_prefix}.${var.hosted_zone_name}" - type = "A" - zone_id = var.route53_hosted_zone_id - - alias { - evaluate_target_health = true - name = aws_api_gateway_domain_name.gateway_domain_name.regional_domain_name - zone_id = aws_api_gateway_domain_name.gateway_domain_name.regional_zone_id - } -} - -#map to custom domain dont hit default API Gateway domain -resource "aws_api_gateway_base_path_mapping" "custom_domain_mapping" { - api_id = aws_api_gateway_rest_api.api.id - stage_name = aws_api_gateway_stage.stage.stage_name - domain_name = aws_api_gateway_domain_name.gateway_domain_name.domain_name - base_path = var.stage_name - - depends_on = [aws_api_gateway_stage.stage] + statement_id = "AllowExecutionFromApiGateway-${replace(local.api_name, "[^a-zA-Z0-9]", "")}" + action = "lambda:InvokeFunction" + function_name = var.lambda_function_name_or_arn + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.this[0].execution_arn}/*" } diff --git a/infrastructure/modules/api-gateway/outputs.tf b/infrastructure/modules/api-gateway/outputs.tf index 8e68c1f5..b5289876 100644 --- a/infrastructure/modules/api-gateway/outputs.tf +++ b/infrastructure/modules/api-gateway/outputs.tf @@ -1,24 +1,29 @@ -output "api_gateway_id" { - description = "The ID of the API Gateway" - value = aws_api_gateway_rest_api.api.id +output "api_id" { + description = "The ID of the HTTP API." + value = try(aws_apigatewayv2_api.this[0].id, null) } -output "api_gateway_url" { - description = "The URL of the API Gateway custom domain" - value = "https://${aws_api_gateway_domain_name.gateway_domain_name.domain_name}/${var.api_path_part}" +output "api_endpoint" { + description = "The base invoke URL of the HTTP API." + value = try(aws_apigatewayv2_api.this[0].api_endpoint, null) } -output "api_gateway_invoke_url" { - description = "The invoke URL of the API Gateway stage" - value = aws_api_gateway_stage.stage.invoke_url +output "execution_arn" { + description = "The execution ARN of the HTTP API, suitable for IAM policies or permissions." + value = try(aws_apigatewayv2_api.this[0].execution_arn, null) } -output "api_key_id" { - description = "The ID of the API key" - value = aws_api_gateway_api_key.my_api_key.id +output "stage_name" { + description = "The deployed stage name." + value = try(aws_apigatewayv2_stage.this[0].name, null) } -output "api_key_secret_arn" { - description = "The ARN of the API key secret in Secrets Manager" - value = aws_secretsmanager_secret.api_token.arn +output "route_id" { + description = "The ID of the webhook route." + value = try(aws_apigatewayv2_route.this[0].id, null) +} + +output "integration_id" { + description = "The ID of the Lambda proxy integration." + value = try(aws_apigatewayv2_integration.this[0].id, null) } diff --git a/infrastructure/modules/api-gateway/readme.md b/infrastructure/modules/api-gateway/readme.md index e85c23da..85800fc4 100644 --- a/infrastructure/modules/api-gateway/readme.md +++ b/infrastructure/modules/api-gateway/readme.md @@ -1,5 +1,101 @@ # API Gateway +Minimal NHS Screening wrapper for an API Gateway v2 HTTP API fronting a Lambda +webhook endpoint. This module is intended for small webhook publisher use cases +such as the future shared GitHub runner or ARC stack, and consumes the shared +`context.tf` for naming and tagging. + +## What this module enforces + +|Control|How it is enforced| +|---|---| +|Protocol|Creates an API Gateway v2 HTTP API only; no REST API resources are used| +|Integration|Creates a Lambda proxy integration with payload format version `2.0`| +|Stage deployment|Creates a single stage with `auto_deploy = true`| +|Logging|Enables API access logging to CloudWatch Logs| +|Invoke permissions|Creates Lambda permission scoped to the API execution ARN| +|Tagging|Tags supported resources via `module.this.tags`| +|Creation gate|Resource creation is gated by `module.this.enabled`| + +## Usage + +### Minimal webhook endpoint + +```hcl +module "github_arc_webhook_api" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/api-gateway?ref=" + + enabled = local.deploy_github_arc + name = "github-arc-webhook" + workspace = terraform.workspace + tags = module.tags.tags + labels_as_tags = [] + + lambda_invoke_arn = module.webhook_publisher.lambda_function_invoke_arn + lambda_function_name_or_arn = module.webhook_publisher.lambda_function_name +} +``` + +### Explicit route and stage + +```hcl +module "webhook_api" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/api-gateway?ref=" + + enabled = true + name = "webhook" + workspace = terraform.workspace + tags = module.tags.tags + labels_as_tags = [] + + route_key = "POST /arc/webhook" + stage_name = "live" + + lambda_invoke_arn = aws_lambda_function.publisher.invoke_arn + lambda_function_name_or_arn = aws_lambda_function.publisher.function_name +} +``` + +### Custom API name with longer log retention + +```hcl +module "runner_webhook_api" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/api-gateway?ref=" + + enabled = true + name = "github-runner-webhook" + workspace = terraform.workspace + tags = module.tags.tags + labels_as_tags = [] + + custom_name = "github-runner-webhook-${terraform.workspace}" + description = "Webhook endpoint for GitHub ARC event publishing." + access_log_retention_in_days = 90 + + lambda_invoke_arn = module.publisher.lambda_function_invoke_arn + lambda_function_name_or_arn = module.publisher.lambda_function_arn +} +``` + +## Conventions + +* `custom_name` is optional. When omitted, the API name is derived from + `module.this.id` so it follows the same naming and tagging pattern as other + wrappers in this repository. +* `stage_name` defaults to `$default` to keep the caller experience simple and + avoid separate deployment resources. +* `route_key` defaults to `POST /webhook`, which matches the primary ARC webhook + endpoint use case. +* The module expects the Lambda invoke ARN for the integration and a Lambda + function name or ARN for the invoke permission. + +## What this module does NOT do + +* Create REST API v1 resources. +* Create API keys, usage plans, or Secrets Manager tokens. +* Create custom domains, ACM certificates, or Route 53 records. +* Manage multiple routes, authorisers, CORS policies, or VPC links. + @@ -7,78 +103,19 @@ | Name | Version | | ---- | ------- | -| [terraform](#requirement\_terraform) | >= 1.5.7 | -| [aws](#requirement\_aws) | >= 6.49.0 | -| [random](#requirement\_random) | >= 3.9.0 | - -## Providers - -| Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.50.0 | -| [random](#provider\_random) | 3.9.0 | - -## Modules - -No modules. - -## Resources - -| Name | Type | -| ---- | ---- | -| [aws_acm_certificate.cert](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate) | resource | -| [aws_acm_certificate_validation.cert_validation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate_validation) | resource | -| [aws_api_gateway_account.account](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_account) | resource | -| [aws_api_gateway_api_key.my_api_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_api_key) | resource | -| [aws_api_gateway_base_path_mapping.custom_domain_mapping](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_base_path_mapping) | resource | -| [aws_api_gateway_deployment.deployment](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_deployment) | resource | -| [aws_api_gateway_domain_name.gateway_domain_name](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_domain_name) | resource | -| [aws_api_gateway_integration.lambda_integration](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_integration) | resource | -| [aws_api_gateway_method.post_method](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_method) | resource | -| [aws_api_gateway_resource.api_resource](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_resource) | resource | -| [aws_api_gateway_rest_api.api](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_rest_api) | resource | -| [aws_api_gateway_stage.stage](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_stage) | resource | -| [aws_api_gateway_usage_plan.usage_plan](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_usage_plan) | resource | -| [aws_api_gateway_usage_plan_key.usage_plan_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_usage_plan_key) | resource | -| [aws_cloudwatch_log_group.log_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | -| [aws_iam_role.apigateway_cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | -| [aws_iam_role_policy_attachment.apigateway_logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_lambda_permission.api_gateway](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | -| [aws_route53_record.cert_validation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | -| [aws_route53_record.route53_record](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | -| [aws_secretsmanager_secret.api_token](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret) | resource | -| [aws_secretsmanager_secret_version.api_token](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret_version) | resource | -| [random_password.api_auth_token](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/password) | resource | - -## Inputs - -| Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | -| [api\_gateway\_description](#input\_api\_gateway\_description) | Description for the API Gateway | `string` | n/a | yes | -| [api\_gateway\_name](#input\_api\_gateway\_name) | the name of the API Gateway | `string` | n/a | yes | -| [api\_path\_part](#input\_api\_path\_part) | the url path for the API | `string` | n/a | yes | -| [aws\_account\_id](#input\_aws\_account\_id) | AWS account ID for the deployment context | `string` | n/a | yes | -| [aws\_lambda\_arn](#input\_aws\_lambda\_arn) | Lambda function ARN | `string` | n/a | yes | -| [aws\_lambda\_name](#input\_aws\_lambda\_name) | Lambda function name | `string` | n/a | yes | -| [aws\_region](#input\_aws\_region) | The AWS region where the API Gateway is deployed | `string` | `"eu-west-2"` | no | -| [certificate\_arn](#input\_certificate\_arn) | The ARN of the ACM certificate to use for the custom domain (optional, will create if not provided) | `string` | `null` | no | -| [domain\_name\_prefix](#input\_domain\_name\_prefix) | Prefix for the custom domain name | `string` | n/a | yes | -| [hosted\_zone\_name](#input\_hosted\_zone\_name) | The hosted zone name for the custom domain | `string` | n/a | yes | -| [http\_method](#input\_http\_method) | The HTTP method to use for the API Gateway | `string` | n/a | yes | -| [name\_prefix](#input\_name\_prefix) | Prefix for naming resources | `string` | n/a | yes | -| [route53\_hosted\_zone\_id](#input\_route53\_hosted\_zone\_id) | The ID of the Route53 hosted zone | `string` | n/a | yes | -| [secret\_replication\_regions](#input\_secret\_replication\_regions) | List of additional regions where created secrets should be replicated | `list(string)` | n/a | yes | -| [stage\_name](#input\_stage\_name) | the API stage name | `string` | n/a | yes | +| terraform | >= 1.13 | +| aws | >= 6.42 | ## Outputs | Name | Description | | ---- | ----------- | -| [api\_gateway\_id](#output\_api\_gateway\_id) | The ID of the API Gateway | -| [api\_gateway\_invoke\_url](#output\_api\_gateway\_invoke\_url) | The invoke URL of the API Gateway stage | -| [api\_gateway\_url](#output\_api\_gateway\_url) | The URL of the API Gateway custom domain | -| [api\_key\_id](#output\_api\_key\_id) | The ID of the API key | -| [api\_key\_secret\_arn](#output\_api\_key\_secret\_arn) | The ARN of the API key secret in Secrets Manager | +| api_id | The ID of the HTTP API. | +| api_endpoint | The base invoke URL of the HTTP API. | +| execution_arn | The execution ARN of the HTTP API, suitable for IAM policies or permissions. | +| stage_name | The deployed stage name. | +| route_id | The ID of the webhook route. | +| integration_id | The ID of the Lambda proxy integration. | diff --git a/infrastructure/modules/api-gateway/variables.tf b/infrastructure/modules/api-gateway/variables.tf index 3d109343..55200dde 100644 --- a/infrastructure/modules/api-gateway/variables.tf +++ b/infrastructure/modules/api-gateway/variables.tf @@ -1,81 +1,97 @@ -# tflint-ignore: terraform_unused_declarations -variable "aws_account_id" { - description = "AWS account ID for the deployment context" - type = string -} +################################################################ +# API configuration +################################################################ -variable "aws_lambda_name" { - description = "Lambda function name" +variable "custom_name" { + description = "Optional explicit API name. When null, the name is derived from module.this.id." type = string + default = null } -variable "aws_lambda_arn" { - description = "Lambda function ARN" +variable "description" { + description = "Description for the HTTP API. Keep this focused on the webhook endpoint purpose." type = string + default = "HTTP API for a Lambda-backed webhook endpoint." } -# tflint-ignore: terraform_unused_declarations -variable "aws_region" { - description = "The AWS region where the API Gateway is deployed" +variable "route_key" { + description = "Route key for the webhook endpoint. Use either '$default' or 'METHOD /path', for example 'POST /webhook'." type = string - default = "eu-west-2" + default = "POST /webhook" -} - -variable "api_gateway_name" { - description = "the name of the API Gateway" - type = string -} - -variable "api_path_part" { - description = "the url path for the API" - type = string + validation { + condition = var.route_key == "$default" || can(regex("^(ANY|DELETE|GET|HEAD|OPTIONS|PATCH|POST|PUT) /.*$", var.route_key)) + error_message = "route_key must be '$default' or in the format 'METHOD /path', for example 'POST /webhook'." + } } variable "stage_name" { - description = "the API stage name" + description = "Stage name for the HTTP API. Defaults to '$default' so callers get auto-deployed changes without managing deployments." type = string -} - + default = "$default" - -variable "http_method" { - description = "The HTTP method to use for the API Gateway" - type = string + validation { + condition = can(regex("^(\\$default|[A-Za-z0-9_-]{1,128})$", var.stage_name)) + error_message = "stage_name must be '$default' or contain only letters, numbers, underscores, and hyphens." + } } -variable "api_gateway_description" { - description = "Description for the API Gateway" +################################################################ +# Lambda integration +################################################################ + +variable "lambda_invoke_arn" { + description = "Invoke ARN of the Lambda function that will receive the webhook requests." type = string + validation { + condition = can(regex("^arn:aws[a-z-]*:lambda:[^:]+:[0-9]{12}:function:.+", var.lambda_invoke_arn)) + error_message = "lambda_invoke_arn must be a valid Lambda function invoke ARN." + } } -variable "name_prefix" { - description = "Prefix for naming resources" +variable "lambda_function_name_or_arn" { + description = "Lambda function name or ARN used when granting API Gateway permission to invoke the function." type = string -} -variable "hosted_zone_name" { - description = "The hosted zone name for the custom domain" - type = string -} -variable "domain_name_prefix" { - description = "Prefix for the custom domain name" - type = string + validation { + condition = length(trimspace(var.lambda_function_name_or_arn)) > 0 + error_message = "lambda_function_name_or_arn must not be empty." + } } -variable "route53_hosted_zone_id" { - description = "The ID of the Route53 hosted zone" - type = string +variable "integration_timeout_milliseconds" { + description = "Timeout for the Lambda proxy integration in milliseconds. HTTP APIs support 50-30000 ms." + type = number + default = 30000 + + validation { + condition = var.integration_timeout_milliseconds >= 50 && var.integration_timeout_milliseconds <= 30000 + error_message = "integration_timeout_milliseconds must be between 50 and 30000." + } } -variable "certificate_arn" { - description = "The ARN of the ACM certificate to use for the custom domain (optional, will create if not provided)" - type = string - default = null +################################################################ +# Logging and metrics +################################################################ + +variable "access_log_retention_in_days" { + description = "CloudWatch Logs retention for API access logs." + type = number + default = 30 + + validation { + condition = contains([ + 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, + 365, 400, 545, 731, 1096, 1827, 2192, 2557, + 2922, 3288, 3653 + ], var.access_log_retention_in_days) + error_message = "access_log_retention_in_days must be a valid CloudWatch Logs retention value." + } } -variable "secret_replication_regions" { - description = "List of additional regions where created secrets should be replicated" - type = list(string) +variable "enable_detailed_metrics" { + description = "Whether to enable detailed CloudWatch metrics on the default route settings." + type = bool + default = false } diff --git a/infrastructure/modules/api-gateway/versions.tf b/infrastructure/modules/api-gateway/versions.tf index ca47b804..cb30fe5c 100644 --- a/infrastructure/modules/api-gateway/versions.tf +++ b/infrastructure/modules/api-gateway/versions.tf @@ -1,15 +1,10 @@ terraform { - required_version = ">= 1.5.7" + required_version = ">= 1.13" required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.49.0" - } - - random = { - source = "hashicorp/random" - version = ">= 3.9.0" + version = ">= 6.42" } } } diff --git a/infrastructure/modules/sqs/context.tf b/infrastructure/modules/sqs/context.tf new file mode 100644 index 00000000..9a4652b0 --- /dev/null +++ b/infrastructure/modules/sqs/context.tf @@ -0,0 +1,377 @@ +# tflint-ignore-file: terraform_standard_module_structure, terraform_unused_declarations +# +# ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/NHSDigital/screening-terraform-modules-aws/blob/master/infrastructure/modules/tags/exports/context.tf +# and then place it in your Terraform module to automatically get +# tag module standard configuration inputs suitable for passing +# to other modules. +# +# curl -sL https://raw.githubusercontent.com/NHSDigital/screening-terraform-modules-aws/master/infrastructure/modules/tags/exports/context.tf -o context.tf +# +# Modules should access the whole context as `module.this.context` +# to get the input variables with nulls for defaults, +# for example `context = module.this.context`, +# and access individual variables as `module.this.`, +# with final values filled in. +# +# For example, when using defaults, `module.this.context.delimiter` +# will be null, and `module.this.delimiter` will be `-` (hyphen). +# + +module "this" { + # tflint-ignore: terraform_module_pinned_source + source = "../tags" + + enabled = var.enabled + service = var.service + project = var.project + region = var.region + environment = var.environment + stack = var.stack + workspace = var.workspace + name = var.name + delimiter = var.delimiter + attributes = var.attributes + tags = var.tags + additional_tag_map = var.additional_tag_map + label_order = var.label_order + regex_replace_chars = var.regex_replace_chars + id_length_limit = var.id_length_limit + label_key_case = var.label_key_case + label_value_case = var.label_value_case + terraform_source = coalesce(var.terraform_source, path.module) + descriptor_formats = var.descriptor_formats + labels_as_tags = var.labels_as_tags + + context = var.context +} + +# Copy contents of screening-terraform-modules-aws/tags/variables.tf here +# tflint-ignore: terraform_unused_declarations +variable "aws_region" { + type = string + description = "The AWS region" + default = "eu-west-2" + validation { + condition = contains(["eu-west-1", "eu-west-2", "us-east-1"], var.aws_region) + error_message = "AWS Region must be one of eu-west-1, eu-west-2, us-east-1" + } +} + +variable "context" { + type = any + default = { + enabled = true + service = null + project = null + region = null + environment = null + stack = null + workspace = null + name = null + delimiter = null + attributes = [] + tags = {} + additional_tag_map = {} + regex_replace_chars = null + label_order = [] + id_length_limit = null + label_key_case = null + label_value_case = null + terraform_source = null + descriptor_formats = {} + # Note: we have to use [] instead of null for unset lists due to + # https://github.com/hashicorp/terraform/issues/28137 + # which was not fixed until Terraform 1.0.0, + # but we want the default to be all the labels in `label_order` + # and we want users to be able to prevent all tag generation + # by setting `labels_as_tags` to `[]`, so we need + # a different sentinel to indicate "default" + labels_as_tags = ["unset"] + } + description = <<-EOT + Single object for setting entire context at once. + See description of individual variables for details. + Leave string and numeric variables as `null` to use default value. + Individual variable settings (non-null) override settings in context object, + except for attributes, tags, and additional_tag_map, which are merged. + EOT + + validation { + condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`." + } + + validation { + condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "terraform_source" { + type = string + default = null + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." +} + +variable "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "service" { + type = string + default = null + description = "ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique" +} + +variable "region" { + type = string + default = null + description = "ID element _(Rarely used, not included by default)_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region" +} + +variable "project" { + type = string + default = null + description = "ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api`" +} +variable "stack" { + type = string + default = null + description = "ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks`" +} +variable "workspace" { + type = string + default = null + description = "ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces" +} +variable "environment" { + type = string + default = null + description = "ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat'" +} + +variable "name" { + type = string + default = null + description = <<-EOT + ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'. + This is the only ID element not also included as a `tag`. + The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. + EOT +} + +variable "delimiter" { + type = string + default = null + description = <<-EOT + Delimiter to be used between ID elements. + Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. + EOT +} + +variable "attributes" { + type = list(string) + default = [] + description = <<-EOT + ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`, + in the order they appear in the list. New attributes are appended to the + end of the list. The elements of the list are joined by the `delimiter` + and treated as a single ID element. + EOT +} + +variable "labels_as_tags" { + type = set(string) + default = ["default"] + description = <<-EOT + Set of labels (ID elements) to include as tags in the `tags` output. + Default is to include all labels. + Tags with empty values will not be included in the `tags` output. + Set to `[]` to suppress all generated tags. + **Notes:** + The value of the `name` tag, if included, will be the `id`, not the `name`. + Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be + changed in later chained modules. Attempts to change it will be silently ignored. + EOT +} + +variable "tags" { + type = map(string) + default = {} + description = <<-EOT + Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`). + Neither the tag keys nor the tag values will be modified by this module. + EOT +} + +variable "additional_tag_map" { + type = map(string) + default = {} + description = <<-EOT + Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`. + This is for some rare cases where resources want additional configuration of tags + and therefore take a list of maps with tag key, value, and additional configuration. + EOT +} + +variable "label_order" { + type = list(string) + default = null + description = <<-EOT + The order in which the labels (ID elements) appear in the `id`. + Defaults to ["namespace", "environment", "stage", "name", "attributes"]. + You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. + EOT +} + +variable "regex_replace_chars" { + type = string + default = null + description = <<-EOT + Terraform regular expression (regex) string. + Characters matching the regex will be removed from the ID elements. + If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. + EOT +} + +variable "id_length_limit" { + type = number + default = null + description = <<-EOT + Limit `id` to this many characters (minimum 6). + Set to `0` for unlimited length. + Set to `null` for keep the existing setting, which defaults to `0`. + Does not affect `id_full`. + EOT + validation { + condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 + error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." + } +} + +variable "label_key_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of the `tags` keys (label names) for tags generated by this module. + Does not affect keys of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper`. + Default value: `title`. + EOT + + validation { + condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) + error_message = "Allowed values: `lower`, `title`, `upper`." + } +} + +variable "label_value_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of ID elements (labels) as included in `id`, + set as tag values, and output by this module individually. + Does not affect values of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper` and `none` (no transformation). + Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs. + Default value: `lower`. + EOT + + validation { + condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "descriptor_formats" { + type = any + default = {} + description = <<-EOT + Describe additional descriptors to be output in the `descriptors` output map. + Map of maps. Keys are names of descriptors. Values are maps of the form + `{ + format = string + labels = list(string) + }` + (Type is `any` so the map values can later be enhanced to provide additional options.) + `format` is a Terraform format string to be passed to the `format()` function. + `labels` is a list of labels, in order, to pass to `format()` function. + Label values will be normalized before being passed to `format()` so they will be + identical to how they appear in `id`. + Default is `{}` (`descriptors` output will be empty). + EOT +} + +variable "owner" { + type = string + description = "The name and or NHS.net email address of the service owner" + default = "None" +} + +variable "tag_version" { + type = string + description = "Used to identify the tagging version in use" + default = "1.0" +} + +variable "data_classification" { + type = string + description = "Used to identify the data classification of the resource, e.g 1-5" + default = "n/a" + validation { + condition = contains(["n/a", "1", "2", "3", "4", "5"], var.data_classification) + error_message = "Data Classification must be \"n/a\" or between 1-5" + } +} + +variable "data_type" { + type = string + description = "The tag data_type" + default = "None" + validation { + condition = contains(["None", "PCD", "PID", "Anonymised", "UserAccount", "Audit"], var.data_type) + error_message = "Data Type must be one of None, PCD, PID, Anonymised, UserAccount, Audit" + } +} + + +variable "public_facing" { + type = bool + description = "Whether this resource is public facing" + default = false +} + +variable "service_category" { + type = string + description = "The tag service_category" + default = "n/a" + validation { + condition = contains(["n/a", "Bronze", "Silver", "Gold", "Platinum"], var.service_category) + error_message = "The Service Category must be one of n/a, Bronze, Silver, Gold, Platinum" + } +} +variable "on_off_pattern" { + type = string + description = "Used to turn resources on and off based on a time pattern" + default = "n/a" +} + +variable "application_role" { + type = string + description = "The role the application is performing" + default = "General" +} + +variable "tool" { + type = string + description = "The tool used to deploy the resource" + default = "Terraform" +} + +#### End of copy of screening-terraform-modules-aws/tags/variables.tf diff --git a/infrastructure/modules/sqs/data.tf b/infrastructure/modules/sqs/data.tf new file mode 100644 index 00000000..2795f5c6 --- /dev/null +++ b/infrastructure/modules/sqs/data.tf @@ -0,0 +1,59 @@ +data "aws_iam_policy_document" "queue" { + count = module.this.enabled ? 1 : 0 + + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["sqs:*"] + resources = [aws_sqs_queue.this[0].arn] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } + + dynamic "statement" { + for_each = var.publisher_statements + + content { + sid = substr(regexreplace(statement.key, "[^A-Za-z0-9]", ""), 0, 80) + effect = "Allow" + + actions = ["sqs:SendMessage"] + resources = [aws_sqs_queue.this[0].arn] + + principals { + type = statement.value.principals.type + identifiers = statement.value.principals.identifiers + } + + dynamic "condition" { + for_each = length(statement.value.source_arns) > 0 ? [statement.value.source_arns] : [] + + content { + test = "ArnEquals" + variable = "aws:SourceArn" + values = condition.value + } + } + + dynamic "condition" { + for_each = length(statement.value.source_accounts) > 0 ? [statement.value.source_accounts] : [] + + content { + test = "StringEquals" + variable = "aws:SourceAccount" + values = condition.value + } + } + } + } +} diff --git a/infrastructure/modules/sqs/locals.tf b/infrastructure/modules/sqs/locals.tf new file mode 100644 index 00000000..011b1bf7 --- /dev/null +++ b/infrastructure/modules/sqs/locals.tf @@ -0,0 +1,10 @@ +locals { + queue_name = coalesce(var.custom_name, module.this.id) + + dead_letter_queue_name = "${local.queue_name}-dlq" + + dead_letter_visibility_timeout_seconds = coalesce( + var.dead_letter_queue.visibility_timeout_seconds, + var.visibility_timeout_seconds + ) +} diff --git a/infrastructure/modules/sqs/main.tf b/infrastructure/modules/sqs/main.tf index 9e0ae4a9..8f7ad2ca 100644 --- a/infrastructure/modules/sqs/main.tf +++ b/infrastructure/modules/sqs/main.tf @@ -1,53 +1,50 @@ -########################### -# SQS # -########################### - -resource "aws_sqs_queue" "sqs_queue" { - name = "${var.name_prefix}-${var.stack_name}" - delay_seconds = 0 - max_message_size = 2048 - receive_wait_time_seconds = 0 - visibility_timeout_seconds = 120 - fifo_queue = false - redrive_policy = "{\"deadLetterTargetArn\":\"${aws_sqs_queue.queue.arn}\",\"maxReceiveCount\":4}" - depends_on = [aws_sqs_queue.queue] +################################################################ +# SQS queue +# +# Minimal NHS wrapper for a standard SQS queue used for event +# delivery workloads such as GitHub ARC workflow_job events. +# +# * Standard queue only +# * SQS-managed server-side encryption enabled +# * Optional dead-letter queue with redrive policy +# * Optional publisher policy statements limited to SendMessage +# * Queue policy denies insecure transport +# +# Naming and tagging are derived from context.tf via module.this. +################################################################ + +resource "aws_sqs_queue" "dead_letter" { + count = module.this.enabled && var.dead_letter_queue.create ? 1 : 0 + + name = local.dead_letter_queue_name + visibility_timeout_seconds = local.dead_letter_visibility_timeout_seconds + message_retention_seconds = var.dead_letter_queue.message_retention_seconds + receive_wait_time_seconds = var.dead_letter_queue.receive_wait_time_seconds + sqs_managed_sse_enabled = true + + tags = module.this.tags } -# Deadletter queue for messages that can't be delivered -resource "aws_sqs_queue" "queue" { - name = "${var.name_prefix}-${var.stack_name}-deadletter-queue" - delay_seconds = 90 - max_message_size = 2048 - message_retention_seconds = 86400 - receive_wait_time_seconds = 10 - visibility_timeout_seconds = 120 - fifo_queue = false - content_based_deduplication = false +resource "aws_sqs_queue" "this" { + count = module.this.enabled ? 1 : 0 + + name = local.queue_name + visibility_timeout_seconds = var.visibility_timeout_seconds + message_retention_seconds = var.message_retention_seconds + receive_wait_time_seconds = var.receive_wait_time_seconds + sqs_managed_sse_enabled = true + + redrive_policy = var.dead_letter_queue.create ? jsonencode({ + deadLetterTargetArn = aws_sqs_queue.dead_letter[0].arn + maxReceiveCount = var.dead_letter_queue.max_receive_count + }) : null + + tags = module.this.tags } -resource "aws_sqs_queue_policy" "allow_sns_publish" { - queue_url = aws_sqs_queue.sqs_queue.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "Allow-SNS-SendMessage" - Effect = "Allow" - - Principal = { - Service = "sns.amazonaws.com" - } - - Action = "sqs:SendMessage" - Resource = aws_sqs_queue.sqs_queue.arn - - Condition = { - ArnLike = { - "aws:SourceArn" = var.topic_arn - } - } - } - ] - }) +resource "aws_sqs_queue_policy" "this" { + count = module.this.enabled ? 1 : 0 + + queue_url = aws_sqs_queue.this[0].id + policy = data.aws_iam_policy_document.queue[0].json } diff --git a/infrastructure/modules/sqs/outputs.tf b/infrastructure/modules/sqs/outputs.tf index 0b736498..5976a855 100644 --- a/infrastructure/modules/sqs/outputs.tf +++ b/infrastructure/modules/sqs/outputs.tf @@ -1,4 +1,24 @@ -output "arn" { - description = "ARN of the primary SQS queue." - value = aws_sqs_queue.sqs_queue.arn +output "queue_id" { + description = "The ID of the primary queue. For SQS this is the queue URL identifier." + value = try(aws_sqs_queue.this[0].id, null) +} + +output "queue_arn" { + description = "The ARN of the primary queue." + value = try(aws_sqs_queue.this[0].arn, null) +} + +output "queue_url" { + description = "The URL of the primary queue." + value = try(aws_sqs_queue.this[0].id, null) +} + +output "queue_name" { + description = "The name of the primary queue." + value = module.this.enabled ? local.queue_name : null +} + +output "dlq_arn" { + description = "The ARN of the dead-letter queue when created, otherwise null." + value = try(aws_sqs_queue.dead_letter[0].arn, null) } diff --git a/infrastructure/modules/sqs/readme.md b/infrastructure/modules/sqs/readme.md index f8544285..47ce4be2 100644 --- a/infrastructure/modules/sqs/readme.md +++ b/infrastructure/modules/sqs/readme.md @@ -1,46 +1,122 @@ # SQS - - - -## Requirements +Minimal NHS Screening wrapper for a standard SQS queue used for event delivery +workloads such as GitHub ARC `workflow_job` events. The module keeps the API +small, enables server-side encryption by default, and consumes the shared +`context.tf` for naming and tagging. -| Name | Version | -| ---- | ------- | -| [terraform](#requirement\_terraform) | >= 1.13 | -| [aws](#requirement\_aws) | >= 6.42 | +## What this module enforces -## Providers +|Control|How it is enforced| +|---|---| +|Queue type|Creates a standard queue only; FIFO is intentionally excluded| +|Encryption at rest|Enables SQS-managed server-side encryption on the primary queue and optional DLQ| +|Transport security|Applies a queue policy that denies insecure transport| +|Publisher scope|Optional publisher statements are limited to `sqs:SendMessage` on this queue| +|Dead-letter queue|Optional DLQ is wired with the retry policy automatically| +|Tagging|Tags supported resources via `module.this.tags`| +|Creation gate|Resource creation is gated by `module.this.enabled`| -| Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.50.0 | +## Usage + +### Minimal queue + +```hcl +module "workflow_job_queue" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/sqs?ref=" + + enabled = local.deploy_github_arc + name = "workflow-job-events" + workspace = terraform.workspace + tags = module.tags.tags + labels_as_tags = [] +} +``` + +### Queue with publisher permissions + +```hcl +module "workflow_job_queue" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/sqs?ref=" -## Modules + enabled = true + name = "workflow-job-events" + workspace = terraform.workspace + tags = module.tags.tags + labels_as_tags = [] -No modules. + publisher_statements = { + eventbridge = { + principals = { + type = "Service" + identifiers = ["events.amazonaws.com"] + } + source_arns = [aws_cloudwatch_event_rule.github_arc.arn] + } + } +} +``` -## Resources +### Queue with dead-letter queue and tuned settings -| Name | Type | -| ---- | ---- | -| [aws_sqs_queue.queue](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | -| [aws_sqs_queue.sqs_queue](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | -| [aws_sqs_queue_policy.allow_sns_publish](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | +```hcl +module "workflow_job_queue" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/sqs?ref=" -## Inputs + enabled = true + name = "workflow-job-events" + workspace = terraform.workspace + tags = module.tags.tags + labels_as_tags = [] + visibility_timeout_seconds = 180 + message_retention_seconds = 604800 + receive_wait_time_seconds = 20 -| Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | -| [name\_prefix](#input\_name\_prefix) | The account, environment etc | `string` | n/a | yes | -| [stack\_name](#input\_stack\_name) | Name of stack calling the module to use in resource naming | `string` | n/a | yes | -| [topic\_arn](#input\_topic\_arn) | Source SNS topic arn | `string` | n/a | yes | + dead_letter_queue = { + create = true + max_receive_count = 3 + } +} +``` + +## Conventions + +* `custom_name` is optional. When omitted, the queue name is derived from + `module.this.id` so stacks can consume the module with the same ergonomics as + other wrappers in this repository. +* Long polling defaults to `20` seconds to reduce empty receives for event-driven + consumers. +* When `dead_letter_queue.create = true`, the DLQ name is derived automatically + as `-dlq` and the retry policy is applied to the primary queue. +* Publisher policy statements are optional and constrained to `sqs:SendMessage` + so the module stays generic without becoming a full IAM policy builder. + +## What this module does NOT do + +* Create FIFO queues or content-based duplicate-message suppression settings. +* Support arbitrary queue policy JSON or arbitrary IAM actions. +* Support custom KMS keys, SNS subscriptions, or EventBridge rules. +* Attach policies to the DLQ separately from the primary queue. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| terraform | >= 1.13 | +| aws | >= 6.42 | ## Outputs | Name | Description | | ---- | ----------- | -| [arn](#output\_arn) | ARN of the primary SQS queue. | +| queue_id | The ID of the primary queue. For SQS this is the queue URL identifier. | +| queue_arn | The ARN of the primary queue. | +| queue_url | The URL of the primary queue. | +| queue_name | The name of the primary queue. | +| dlq_arn | The ARN of the dead-letter queue when created, otherwise null. | diff --git a/infrastructure/modules/sqs/variables.tf b/infrastructure/modules/sqs/variables.tf index 4441f5b8..720fb0ac 100644 --- a/infrastructure/modules/sqs/variables.tf +++ b/infrastructure/modules/sqs/variables.tf @@ -1,22 +1,146 @@ -###################### -# Common -###################### +################################################################ +# Queue configuration +################################################################ -variable "name_prefix" { - description = "The account, environment etc" +variable "custom_name" { + description = "Optional explicit queue name. When null, the queue name is derived from module.this.id." type = string + default = null + + validation { + condition = var.custom_name == null || can(regex("^[A-Za-z0-9_-]{1,76}$", var.custom_name)) + error_message = "custom_name must be null or contain only letters, numbers, hyphens, and underscores, with a maximum length of 76 characters to allow an optional '-dlq' suffix." + } } -###################### -# Module -###################### +variable "visibility_timeout_seconds" { + description = "Visibility timeout for the primary queue in seconds." + type = number + default = 300 -variable "stack_name" { - description = "Name of stack calling the module to use in resource naming" - type = string + validation { + condition = var.visibility_timeout_seconds >= 0 && var.visibility_timeout_seconds <= 43200 + error_message = "visibility_timeout_seconds must be between 0 and 43200." + } } -variable "topic_arn" { - description = "Source SNS topic arn" - type = string +variable "message_retention_seconds" { + description = "Message retention period for the primary queue in seconds." + type = number + default = 345600 + + validation { + condition = var.message_retention_seconds >= 60 && var.message_retention_seconds <= 1209600 + error_message = "message_retention_seconds must be between 60 and 1209600." + } +} + +variable "receive_wait_time_seconds" { + description = "Receive wait time for long polling on the primary queue in seconds." + type = number + default = 20 + + validation { + condition = var.receive_wait_time_seconds >= 0 && var.receive_wait_time_seconds <= 20 + error_message = "receive_wait_time_seconds must be between 0 and 20." + } +} + +################################################################ +# Publisher access +################################################################ + +variable "publisher_statements" { + description = <<-EOT + Optional queue policy statements that allow publishers to send messages. + Each statement is constrained to `sqs:SendMessage` on this queue. + + Example: + publisher_statements = { + github_events = { + principals = { + type = "Service" + identifiers = ["events.amazonaws.com"] + } + source_arns = ["arn:aws:events:eu-west-2:123456789012:rule/github-arc"] + } + } + EOT + type = map(object({ + principals = object({ + type = string + identifiers = list(string) + }) + source_arns = optional(list(string), []) + source_accounts = optional(list(string), []) + })) + default = {} + + validation { + condition = alltrue([ + for statement_name, statement in var.publisher_statements : ( + length(trimspace(statement_name)) > 0 && + contains(["AWS", "Service"], statement.principals.type) && + length(statement.principals.identifiers) > 0 + ) + ]) + error_message = "Each publisher statement must have a non-empty key, a principal type of 'AWS' or 'Service', and at least one principal identifier." + } + + validation { + condition = alltrue(flatten([ + for statement_name, statement in var.publisher_statements : [ + for source_account in statement.source_accounts : can(regex("^[0-9]{12}$", source_account)) + ] + ])) + error_message = "Each source account in publisher_statements must be a 12-digit AWS account ID." + } +} + +################################################################ +# Dead-letter queue +################################################################ + +variable "dead_letter_queue" { + description = <<-EOT + Optional dead-letter queue configuration. + + Defaults: + - create: false + - max_receive_count: 5 + - message_retention_seconds: 1209600 (14 days) + - receive_wait_time_seconds: 20 + - visibility_timeout_seconds: null (inherits primary queue visibility timeout) + EOT + type = object({ + create = optional(bool, false) + max_receive_count = optional(number, 5) + message_retention_seconds = optional(number, 1209600) + receive_wait_time_seconds = optional(number, 20) + visibility_timeout_seconds = optional(number) + }) + default = {} + + validation { + condition = var.dead_letter_queue.max_receive_count >= 1 && var.dead_letter_queue.max_receive_count <= 1000 + error_message = "dead_letter_queue.max_receive_count must be between 1 and 1000." + } + + validation { + condition = var.dead_letter_queue.message_retention_seconds >= 60 && var.dead_letter_queue.message_retention_seconds <= 1209600 + error_message = "dead_letter_queue.message_retention_seconds must be between 60 and 1209600." + } + + validation { + condition = var.dead_letter_queue.receive_wait_time_seconds >= 0 && var.dead_letter_queue.receive_wait_time_seconds <= 20 + error_message = "dead_letter_queue.receive_wait_time_seconds must be between 0 and 20." + } + + validation { + condition = var.dead_letter_queue.visibility_timeout_seconds == null || ( + var.dead_letter_queue.visibility_timeout_seconds >= 0 && + var.dead_letter_queue.visibility_timeout_seconds <= 43200 + ) + error_message = "dead_letter_queue.visibility_timeout_seconds must be null or between 0 and 43200." + } }