From 87bc026fb1a13c4ca204f3b4c7409677dc4a64d7 Mon Sep 17 00:00:00 2001 From: Greg Tyler Date: Fri, 20 Oct 2023 17:08:48 +0100 Subject: [PATCH] Add API Gateway Use OpenAPI schema to define REST API, then add logging and IAM execution permissions. Add an account-level IAM role for logging to CloudWatch. Remove lambda function URLs. Test against REST API base URL. Fixes VEGA-2014 #minor --- .github/workflows/account-deploy.yml | 65 ++++++++++ .github/workflows/env-deploy.yml | 6 +- .github/workflows/env-destroy.yml | 2 + .github/workflows/env-test.yml | 6 +- .github/workflows/workflow-pr.yml | 11 +- Makefile | 4 +- docs/openapi/openapi.yaml | 112 +++++++++++++++++- terraform/account/.envrc | 6 + terraform/account/.terraform.lock.hcl | 25 ++++ terraform/account/apigateway.tf | 20 ++++ terraform/account/terraform.tf | 31 +++++ terraform/account/terraform.tfvars.json | 9 ++ terraform/account/variables.tf | 35 ++++++ terraform/environment/outputs.tf | 5 + terraform/environment/region/apigateway.tf | 124 ++++++++++++++++++++ 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 + 21 files changed, 482 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/account-deploy.yml create mode 100644 terraform/account/.envrc create mode 100644 terraform/account/.terraform.lock.hcl create mode 100644 terraform/account/apigateway.tf create mode 100644 terraform/account/terraform.tf create mode 100644 terraform/account/terraform.tfvars.json create mode 100644 terraform/account/variables.tf create mode 100644 terraform/environment/region/apigateway.tf diff --git a/.github/workflows/account-deploy.yml b/.github/workflows/account-deploy.yml new file mode 100644 index 00000000..27e5ebde --- /dev/null +++ b/.github/workflows/account-deploy.yml @@ -0,0 +1,65 @@ +name: "[Job] Plan/Deploy to Account" + +on: + workflow_call: + inputs: + workspace_name: + description: "The terraform workspace to target for account actions" + required: true + type: string + secrets: + aws_access_key_id: + description: "AWS Access Key ID" + required: true + aws_secret_access_key: + description: "AWS Secret Access Key" + required: true + +jobs: + terraform_account_workflow: + runs-on: ubuntu-latest + environment: + name: ${{ inputs.workspace_name }} account + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: "0" + - uses: unfor19/install-aws-cli-action@v1 + - uses: hashicorp/setup-terraform@v2 + with: + terraform_version: 1.4.6 + terraform_wrapper: false + - name: Configure AWS Credentials For Terraform + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.aws_access_key_id }} + aws-secret-access-key: ${{ secrets.aws_secret_access_key }} + aws-region: eu-west-1 + role-duration-seconds: 3600 + role-session-name: OPGLpaStoreGithubAction + + - name: Lint Terraform + run: terraform fmt -check -recursive + working-directory: ./terraform/account + continue-on-error: true + + - name: Terraform Init + run: terraform init -input=false + working-directory: ./terraform/account + + - name: Terraform Plan + env: + TF_WORKSPACE: ${{ inputs.workspace_name }} + run: | + terraform workspace show + echo "plan_summary=$(terraform plan -no-color -lock-timeout=300s -input=false -parallelism=30 | grep -ioE 'Plan: [[:digit:]]+ to add, [[:digit:]]+ to change, [[:digit:]]+ to destroy|No changes. Your infrastructure matches the configuration.')" >> $GITHUB_OUTPUT + terraform plan -lock-timeout=300s -input=false -parallelism=30 + working-directory: ./terraform/account + + - name: Terraform Apply + if: github.ref == 'refs/heads/main' + env: + TF_WORKSPACE: ${{ inputs.workspace_name }} + run: | + terraform apply -lock-timeout=300s -input=false -auto-approve -parallelism=30 + working-directory: ./terraform/account 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..0583e949 100644 --- a/.github/workflows/workflow-pr.yml +++ b/.github/workflows/workflow-pr.yml @@ -71,6 +71,15 @@ jobs: aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + plan-dev-account: + name: TF Plan Dev Account + uses: ./.github/workflows/account-deploy.yml + with: + workspace_name: development + secrets: + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + deploy-pr-env: name: Deploy PR Environment needs: [build, generate-tags, generate-environment-workspace-name] @@ -88,7 +97,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/account/.envrc b/terraform/account/.envrc new file mode 100644 index 00000000..1ce1470c --- /dev/null +++ b/terraform/account/.envrc @@ -0,0 +1,6 @@ +# Terraform +export TF_WORKSPACE=development +export TF_VAR_default_role=operator +export TF_VAR_management_role=operator + +export TF_CLI_ARGS_init="-backend-config=role_arn=arn:aws:iam::311462405659:role/operator" diff --git a/terraform/account/.terraform.lock.hcl b/terraform/account/.terraform.lock.hcl new file mode 100644 index 00000000..81f95737 --- /dev/null +++ b/terraform/account/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.22.0" + constraints = ">= 5.8.0" + hashes = [ + "h1:4oAjE3Fn/vXruaORPWH1lH7q/+oPEqxNm6+KjOMeMrI=", + "zh:09b8475cd519c945423b1e1183b71a4209dd2927e0d289a88c5abeecb53c1753", + "zh:2448e0c3ce9b991a5dd70f6a42d842366a6a2460cf63b31fb9bc5d2cc92ced19", + "zh:3b9fc2bf6714a9a9ab25eae3e56cead3d3917bc1b6d8b9fb3111c4198a790c72", + "zh:4fbd28ad5380529a36c54d7a96c9768df1288c625d28b8fa3a50d4fc2176ef0f", + "zh:54d550f190702a7edc2d459952d025e259a8c0b0ff7df3f15bbcc148539214bf", + "zh:638f406d084ac96f3a0b0a5ce8aa71a5a2a781a56ba96e3a235d3982b89eef0d", + "zh:69d4c175b13b6916b5c9398172cc384e7af46cb737b45870ab9907f12e82a28a", + "zh:81edec181a67255d25caf5e7ffe6d5e8f9373849b9e8f5e0705f277640abb18e", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a66efb2b3cf7be8116728ae5782d7550f23f3719da2ed3c10228d29c44b7dc84", + "zh:ae754478d0bfa42195d16cf46091fab7c1c075ebc965d919338e36aed45add78", + "zh:e0603ad0061c43aa1cb52740b1e700b8afb55667d7ee01c1cc1ceb6f983d4c9d", + "zh:e4cb701d0185884eed0492a66eff17251f5b4971d30e81acd5e0a55627059fc8", + "zh:f7db2fcf69679925dde1ae326526242fd61ba1f83f614b1f6d9d68c925417e51", + "zh:fef331b9b62bc26d900ae937cc662281ff30794edf48aebfe8997d0e16835f6d", + ] +} diff --git a/terraform/account/apigateway.tf b/terraform/account/apigateway.tf new file mode 100644 index 00000000..8a468a8a --- /dev/null +++ b/terraform/account/apigateway.tf @@ -0,0 +1,20 @@ +resource "aws_iam_role" "api_gateway_cloudwatch" { + name = "api-gateway-cloudwatch-global" + assume_role_policy = data.aws_iam_policy_document.api_gateway_assume_role.json +} + +data "aws_iam_policy_document" "api_gateway_assume_role" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["apigateway.amazonaws.com"] + } + } +} + +resource "aws_iam_role_policy_attachment" "api_gateway_log_to_cloudwatch" { + role = aws_iam_role.api_gateway_cloudwatch.id + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" +} diff --git a/terraform/account/terraform.tf b/terraform/account/terraform.tf new file mode 100644 index 00000000..631705a9 --- /dev/null +++ b/terraform/account/terraform.tf @@ -0,0 +1,31 @@ +terraform { + backend "s3" { + bucket = "opg.terraform.state" + key = "opg-data-lpa-deed-account/terraform.tfstate" + encrypt = true + region = "eu-west-1" + role_arn = "arn:aws:iam::311462405659:role/lpa-store-ci" + dynamodb_table = "remote_lock" + } + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.8.0" + } + } + required_version = ">= 1.4.0" +} + +provider "aws" { + region = "eu-west-1" + + assume_role { + role_arn = "arn:aws:iam::${local.account.account_id}:role/${var.default_role}" + session_name = "terraform-session" + } + + default_tags { + tags = local.default_tags + } +} diff --git a/terraform/account/terraform.tfvars.json b/terraform/account/terraform.tfvars.json new file mode 100644 index 00000000..3f8aac20 --- /dev/null +++ b/terraform/account/terraform.tfvars.json @@ -0,0 +1,9 @@ +{ + "accounts": { + "development": { + "account_id": "493907465011", + "account_name": "development", + "is_production": false + } + } +} diff --git a/terraform/account/variables.tf b/terraform/account/variables.tf new file mode 100644 index 00000000..6138be3e --- /dev/null +++ b/terraform/account/variables.tf @@ -0,0 +1,35 @@ +variable "accounts" { + type = map( + object({ + account_id = string + account_name = string + is_production = bool + }) + ) +} + +locals { + account_name = lower(replace(terraform.workspace, "_", "-")) + account = var.accounts[local.account_name] + + mandatory_moj_tags = { + business-unit = "OPG" + application = "opg-data-lpa-deed" + account = local.account.account_name + is-production = local.account.is_production + owner = "opgteam@digital.justice.gov.uk" + } + + optional_tags = { + source-code = "https://github.com/ministryofjustice/opg-data-lpa-deed" + infrastructure-support = "opgteam@digital.justice.gov.uk" + } + + default_tags = merge(local.mandatory_moj_tags, local.optional_tags) +} + +variable "default_role" { + description = "Role to assume in LPA Store account" + type = string + default = "lpa-store-ci" +} 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..d43bd3f4 --- /dev/null +++ b/terraform/environment/region/apigateway.tf @@ -0,0 +1,124 @@ +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_role" "api_gateway_cloudwatch" { + name = "api-gateway-cloudwatch-global" + + provider = aws.region +} + +resource "aws_api_gateway_account" "api_gateway" { + cloudwatch_role_arn = data.aws_iam_role.api_gateway_cloudwatch.arn +} + + +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 +}