From 61a0c6117ad915e84706be015c656fa0961f1dc4 Mon Sep 17 00:00:00 2001 From: Greg Tyler Date: Fri, 20 Oct 2023 16:23:47 +0100 Subject: [PATCH] Add API Gateway Use OpenAPI schema to define REST API, then add logging and IAM execution permissions. Remove lambda function URLs. Test against REST API base URL. Fixes VEGA-2014 #minor --- .github/workflows/env-deploy.yml | 6 +- .github/workflows/env-destroy.yml | 2 + .github/workflows/env-test.yml | 6 +- .github/workflows/workflow-pr.yml | 2 +- Makefile | 4 +- docs/openapi/openapi.yaml | 112 ++++++++++++++++++- terraform/environment/.envrc | 2 +- terraform/environment/outputs.tf | 5 + terraform/environment/region/apigateway.tf | 113 ++++++++++++++++++++ terraform/environment/region/outputs.tf | 4 + terraform/environment/region/variables.tf | 9 ++ terraform/environment/regions.tf | 2 + terraform/environment/terraform.tfvars.json | 12 ++- terraform/environment/variables.tf | 1 + terraform/modules/lambda/outputs.tf | 5 + 15 files changed, 272 insertions(+), 13 deletions(-) create mode 100644 terraform/environment/region/apigateway.tf diff --git a/.github/workflows/env-deploy.yml b/.github/workflows/env-deploy.yml index 25c504e0..1192989e 100644 --- a/.github/workflows/env-deploy.yml +++ b/.github/workflows/env-deploy.yml @@ -22,8 +22,8 @@ on: description: "Github Token" required: true outputs: - create_url: - description: "URL of the create endpoint" + base_url: + description: "Base URL of API" value: ${{ jobs.terraform_environment_workflow.outputs.url }} jobs: @@ -85,5 +85,5 @@ jobs: TF_WORKSPACE: ${{ inputs.workspace_name }} TF_VAR_app_version: ${{ inputs.version_tag }} run: | - echo "url=$(terraform output -raw lambda_url)" >> $GITHUB_OUTPUT + echo "url=$(terraform output -raw base_url)" >> $GITHUB_OUTPUT working-directory: ./terraform/environment diff --git a/.github/workflows/env-destroy.yml b/.github/workflows/env-destroy.yml index 025ba0c9..ec95d2f9 100644 --- a/.github/workflows/env-destroy.yml +++ b/.github/workflows/env-destroy.yml @@ -54,6 +54,8 @@ jobs: working-directory: ./terraform/environment - name: Destroy deployment environment + env: + GH_TOKEN: ${{ github.token }} run: | gh api \ --method DELETE \ diff --git a/.github/workflows/env-test.yml b/.github/workflows/env-test.yml index 305bc3f4..c67686e4 100644 --- a/.github/workflows/env-test.yml +++ b/.github/workflows/env-test.yml @@ -3,8 +3,8 @@ name: "[Job] Test environment" on: workflow_call: inputs: - create_url: - description: "URL of the create endpoint" + base_url: + description: "Base URL of API" required: true type: string secrets: @@ -48,5 +48,5 @@ jobs: role-session-name: GitHubActions - name: POST to server env: - URL: ${{ inputs.create_url }} + URL: ${{ inputs.base_url }} run: make test-api diff --git a/.github/workflows/workflow-pr.yml b/.github/workflows/workflow-pr.yml index 2d6d5085..fdf54b15 100644 --- a/.github/workflows/workflow-pr.yml +++ b/.github/workflows/workflow-pr.yml @@ -88,7 +88,7 @@ jobs: needs: [deploy-pr-env] uses: ./.github/workflows/env-test.yml with: - create_url: ${{ needs.deploy-pr-env.outputs.create_url }} + base_url: ${{ needs.deploy-pr-env.outputs.base_url }} secrets: aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/Makefile b/Makefile index 6eec8b31..88304e8a 100644 --- a/Makefile +++ b/Makefile @@ -12,11 +12,11 @@ up: down: docker compose down -test-api: URL ?= http://localhost:9000/create +test-api: URL ?= http://localhost:9000/ test-api: go build -o ./signer/test-api ./signer && \ chmod +x ./signer/test-api && \ - ./signer/test-api POST $(URL) '{"uid":"M-AL9A-7EY3-075D","version":"1"}' + ./signer/test-api PUT $(URL)/M-AL9A-7EY3-075D '{"uid":"M-AL9A-7EY3-075D","version":"1"}' create-tables: docker compose run --rm aws dynamodb describe-table --table-name deeds || \ diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 80d77650..c1a63701 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -14,4 +14,114 @@ servers: description: Development security: - {} -paths: {} +paths: + /lpas/{uid}: + parameters: + - name: uid + in: path + required: true + description: The UID of the complaint + schema: + type: string + pattern: "M-([A-Z0-9]{4})-([A-Z0-9]{4})-([A-Z0-9]{4})" + example: M-789Q-P4DF-4UX3 + put: + operationId: putLpa + summary: Store an LPA + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: false + responses: + "201": + description: Case created + "400": + description: Invalid request + content: + application/json: + schema: + $ref: "#/components/schemas/BadRequestError" + x-amazon-apigateway-auth: + type: "AWS_IAM" + x-amazon-apigateway-integration: + uri: ${lambda_create_invoke_arn} + httpMethod: "POST" + type: "aws_proxy" + contentHandling: "CONVERT_TO_TEXT" + /health: + get: + operationId: healthCheck + summary: Health check endpoint for external services to consume + responses: + 200: + description: Healthy + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: OK + additionalProperties: false + 503: + description: Service unavailable + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: Unhealthy + additionalProperties: false + x-amazon-apigateway-auth: + type: "AWS_IAM" + x-amazon-apigateway-integration: + type: "mock" + responses: + default: + statusCode: 200 + responseTemplates: + application/json: '{"status":"ok", "statusCode":200}' + requestTemplates: + application/json: '{"statusCode": 200}' + passthroughBehavior: "when_no_templates" + +components: + schemas: + AbstractError: + type: object + required: + - code + - detail + properties: + code: + type: string + detail: + type: string + BadRequestError: + allOf: + - $ref: "#/components/schemas/AbstractError" + - type: object + properties: + code: + enum: ["INVALID_REQUEST"] + errors: + type: array + items: + type: object + required: + - source + - detail + properties: + source: + type: string + format: jsonpointer + detail: + type: string + example: + - source: "/uid" + detail: "invalid uid format" diff --git a/terraform/environment/.envrc b/terraform/environment/.envrc index 1ce1470c..e2073833 100644 --- a/terraform/environment/.envrc +++ b/terraform/environment/.envrc @@ -1,5 +1,5 @@ # Terraform -export TF_WORKSPACE=development +export TF_WORKSPACE=10vega2104a export TF_VAR_default_role=operator export TF_VAR_management_role=operator diff --git a/terraform/environment/outputs.tf b/terraform/environment/outputs.tf index b5315fdd..c9be79ff 100644 --- a/terraform/environment/outputs.tf +++ b/terraform/environment/outputs.tf @@ -2,3 +2,8 @@ output "lambda_url" { description = "Public URL of 'create' Lambda function" value = module.eu_west_1.lambda_url } + +output "base_url" { + description = "Base URL of API" + value = module.eu_west_1.base_url +} diff --git a/terraform/environment/region/apigateway.tf b/terraform/environment/region/apigateway.tf new file mode 100644 index 00000000..a5b58025 --- /dev/null +++ b/terraform/environment/region/apigateway.tf @@ -0,0 +1,113 @@ +locals { + stage_name = "current" + template_file = templatefile("../../docs/openapi/openapi.yaml", { + lambda_create_invoke_arn = module.lambda["create"].invoke_arn + }) +} + +resource "aws_api_gateway_rest_api" "lpa_store" { + name = "lpa-store-${var.environment_name}" + description = "API Gateway for LPA Store - ${var.environment_name}" + body = local.template_file + + endpoint_configuration { + types = ["REGIONAL"] + } + + provider = aws.region +} + + +resource "aws_api_gateway_rest_api_policy" "lpa_store" { + rest_api_id = aws_api_gateway_rest_api.lpa_store.id + policy = data.aws_iam_policy_document.lpa_store.json + + provider = aws.region +} + + +resource "aws_api_gateway_deployment" "lpa_store" { + rest_api_id = aws_api_gateway_rest_api.lpa_store.id + + triggers = { + redeployment = sha1(jsonencode([ + aws_api_gateway_rest_api.lpa_store.body, + var.allowed_arns + ])) + } + + lifecycle { + create_before_destroy = true + } + + depends_on = [ + aws_api_gateway_rest_api.lpa_store, + aws_api_gateway_rest_api_policy.lpa_store + ] + + provider = aws.region +} + +resource "aws_api_gateway_stage" "current" { + depends_on = [aws_cloudwatch_log_group.lpa_store] + deployment_id = aws_api_gateway_deployment.lpa_store.id + rest_api_id = aws_api_gateway_rest_api.lpa_store.id + stage_name = local.stage_name + xray_tracing_enabled = true + + access_log_settings { + destination_arn = aws_cloudwatch_log_group.lpa_store.arn + format = join("", [ + "{\"requestId\":\"$context.requestId\",", + "\"ip\":\"$context.identity.sourceIp\",", + "\"caller\":\"$context.identity.caller\",", + "\"user\":\"$context.identity.user\",", + "\"requestTime\":\"$context.requestTime\",", + "\"httpMethod\":\"$context.httpMethod\"", + "\"resourcePath\":\"$context.resourcePath\",", + "\"status\":\"$context.status\",", + "\"protocol\":\"$context.protocol\",", + "\"responseLength\":\"$context.responseLength\"}" + ]) + } + + provider = aws.region +} + +resource "aws_cloudwatch_log_group" "lpa_store" { + name = "API-Gateway-Execution-Logs-${aws_api_gateway_rest_api.lpa_store.name}-${local.stage_name}" + kms_key_id = aws_kms_key.cloudwatch.arn + retention_in_days = 400 + + provider = aws.region +} + +resource "aws_api_gateway_method_settings" "lpa_store_gateway_settings" { + rest_api_id = aws_api_gateway_rest_api.lpa_store.id + stage_name = aws_api_gateway_stage.current.stage_name + method_path = "*/*" + + settings { + metrics_enabled = true + logging_level = "INFO" + } + + provider = aws.region +} + +data "aws_iam_policy_document" "lpa_store" { + policy_id = "lpa-store-${var.environment_name}-${data.aws_region.current.name}-resource-policy" + + statement { + sid = "${local.policy_region_prefix}AllowExecutionFromAllowedARNs" + effect = "Allow" + + principals { + type = "AWS" + identifiers = var.allowed_arns + } + + actions = ["execute-api:Invoke"] + resources = ["*"] + } +} diff --git a/terraform/environment/region/outputs.tf b/terraform/environment/region/outputs.tf index b001474c..42d16681 100644 --- a/terraform/environment/region/outputs.tf +++ b/terraform/environment/region/outputs.tf @@ -2,3 +2,7 @@ output "lambda_url" { description = "Public URL of 'create' Lambda function" value = module.lambda["create"].function_url } + +output "base_url" { + value = aws_api_gateway_stage.current.invoke_url +} diff --git a/terraform/environment/region/variables.tf b/terraform/environment/region/variables.tf index 8d8518dc..5c1cf4c6 100644 --- a/terraform/environment/region/variables.tf +++ b/terraform/environment/region/variables.tf @@ -17,3 +17,12 @@ variable "dynamodb_name" { description = "Name of DynamoDB table" type = string } + +variable "allowed_arns" { + description = "List of external ARNs allowed to access the API Gateway" + type = list(string) +} + +locals { + policy_region_prefix = lower(replace(data.aws_region.current.name, "-", "")) +} diff --git a/terraform/environment/regions.tf b/terraform/environment/regions.tf index bd2f9bac..d5f7d29a 100644 --- a/terraform/environment/regions.tf +++ b/terraform/environment/regions.tf @@ -5,6 +5,7 @@ module "eu_west_1" { dynamodb_arn = aws_dynamodb_table.deeds_table.arn dynamodb_name = aws_dynamodb_table.deeds_table.name environment_name = local.environment_name + allowed_arns = local.environment.allowed_arns providers = { aws.region = aws.eu_west_1 @@ -19,6 +20,7 @@ module "eu_west_2" { dynamodb_arn = aws_dynamodb_table_replica.deeds_table.arn dynamodb_name = aws_dynamodb_table.deeds_table.name environment_name = local.environment_name + allowed_arns = local.environment.allowed_arns providers = { aws.region = aws.eu_west_2 diff --git a/terraform/environment/terraform.tfvars.json b/terraform/environment/terraform.tfvars.json index be484434..5a4673f7 100644 --- a/terraform/environment/terraform.tfvars.json +++ b/terraform/environment/terraform.tfvars.json @@ -3,12 +3,20 @@ "default": { "account_id": "493907465011", "account_name": "development", - "is_production": false + "is_production": false, + "allowed_arns": [ + "arn:aws:iam::493907465011:role/operator", + "arn:aws:iam::493907465011:role/lpa-store-ci" + ] }, "development": { "account_id": "493907465011", "account_name": "development", - "is_production": false + "is_production": false, + "allowed_arns": [ + "arn:aws:iam::493907465011:role/operator", + "arn:aws:iam::493907465011:role/lpa-store-ci" + ] } } } diff --git a/terraform/environment/variables.tf b/terraform/environment/variables.tf index 7f816db7..e02ca8de 100644 --- a/terraform/environment/variables.tf +++ b/terraform/environment/variables.tf @@ -24,6 +24,7 @@ variable "environments" { account_id = string account_name = string is_production = bool + allowed_arns = list(string) }) ) } diff --git a/terraform/modules/lambda/outputs.tf b/terraform/modules/lambda/outputs.tf index 6c23ced8..7467f69b 100644 --- a/terraform/modules/lambda/outputs.tf +++ b/terraform/modules/lambda/outputs.tf @@ -7,3 +7,8 @@ output "iam_role_id" { description = "ID of IAM role created for lambda" value = aws_iam_role.lambda.id } + +output "invoke_arn" { + description = "value" + value = aws_lambda_function.main.invoke_arn +}