diff --git a/samcli/hook_packages/terraform/hooks/prepare/property_builder.py b/samcli/hook_packages/terraform/hooks/prepare/property_builder.py index add29c68b2..a8910f112a 100644 --- a/samcli/hook_packages/terraform/hooks/prepare/property_builder.py +++ b/samcli/hook_packages/terraform/hooks/prepare/property_builder.py @@ -1,6 +1,9 @@ """ Terraform prepare property builder """ +import logging +from json import loads +from json.decoder import JSONDecodeError from typing import Any, Dict, Optional from samcli.hook_packages.terraform.hooks.prepare.resource_linking import _resolve_resource_attribute @@ -24,6 +27,8 @@ from samcli.lib.utils.resources import AWS_LAMBDA_FUNCTION as CFN_AWS_LAMBDA_FUNCTION from samcli.lib.utils.resources import AWS_LAMBDA_LAYERVERSION as CFN_AWS_LAMBDA_LAYER_VERSION +LOG = logging.getLogger(__name__) + REMOTE_DUMMY_VALUE = "<>" TF_AWS_LAMBDA_FUNCTION = "aws_lambda_function" TF_AWS_LAMBDA_LAYER_VERSION = "aws_lambda_layer_version" @@ -211,6 +216,35 @@ def _check_image_config_value(image_config: Any) -> bool: return True +def _get_json_body(tf_properties: dict, resource: TFResource) -> Any: + """ + Gets the JSON formatted body value from the API Gateway if there is one + + Parameters + ---------- + tf_properties: dict + Properties of the terraform AWS Lambda function resource + resource: TFResource + Configuration terraform resource + + Returns + ------- + Any + Returns a dictonary if there is a valid body to parse, otherwise return original value + """ + body = tf_properties.get("body") + + if isinstance(body, str): + try: + return loads(body) + except JSONDecodeError: + pass + + LOG.debug(f"Failed to load JSON body for API Gateway body, returning original value: '{body}'") + + return body + + AWS_LAMBDA_FUNCTION_PROPERTY_BUILDER_MAPPING: PropertyBuilderMapping = { "FunctionName": _get_property_extractor("function_name"), "Architectures": _get_property_extractor("architectures"), @@ -234,7 +268,7 @@ def _check_image_config_value(image_config: Any) -> bool: AWS_API_GATEWAY_REST_API_PROPERTY_BUILDER_MAPPING: PropertyBuilderMapping = { "Name": _get_property_extractor("name"), - "Body": _get_property_extractor("body"), + "Body": _get_json_body, "Parameters": _get_property_extractor("parameters"), "BinaryMediaTypes": _get_property_extractor("binary_media_types"), } diff --git a/samcli/hook_packages/terraform/hooks/prepare/resources/apigw.py b/samcli/hook_packages/terraform/hooks/prepare/resources/apigw.py index 05f1676624..356c744e12 100644 --- a/samcli/hook_packages/terraform/hooks/prepare/resources/apigw.py +++ b/samcli/hook_packages/terraform/hooks/prepare/resources/apigw.py @@ -66,7 +66,7 @@ def _unsupported_reference_field(field: str, resource: Dict, config_resource: TF False otherwise """ return bool( - not resource.get(field) + not (resource.get(field) or resource.get("values", {}).get(field)) and config_resource.attributes.get(field) and isinstance(config_resource.attributes.get(field), References) ) diff --git a/tests/integration/local/start_api/test_start_api_with_terraform_application.py b/tests/integration/local/start_api/test_start_api_with_terraform_application.py index 59b088b54b..9206d8dba2 100644 --- a/tests/integration/local/start_api/test_start_api_with_terraform_application.py +++ b/tests/integration/local/start_api/test_start_api_with_terraform_application.py @@ -1,9 +1,10 @@ import shutil import os from pathlib import Path +from subprocess import CalledProcessError, CompletedProcess, run from typing import Optional from unittest import skipIf -from http.client import HTTPConnection +from parameterized import parameterized import pytest import requests @@ -38,6 +39,48 @@ def tearDown(self) -> None: pass +class TerraformStartApiIntegrationApplyBase(TerraformStartApiIntegrationBase): + terraform_application: str + run_command_timeout = 300 + + @classmethod + def setUpClass(cls): + # init terraform project to populate deploy-only values + cls._run_command(["terraform", "init", "-input=false"]) + cls._run_command(["terraform", "apply", "-auto-approve", "-input=false"]) + + super(TerraformStartApiIntegrationApplyBase, cls).setUpClass() + + @staticmethod + def get_integ_dir(): + return Path(__file__).resolve().parents[2] + + @classmethod + def tearDownClass(cls) -> None: + try: + cls._run_command(["terraform", "apply", "-destroy", "-auto-approve", "-input=false"]) + except CalledProcessError: + # skip, command can fail here if there isn't an applied project to destroy + # (eg. failed to apply in setup) + pass + + try: + os.remove(str(Path(cls.project_directory / "terraform.tfstate"))) # type: ignore + os.remove(str(Path(cls.project_directory / "terraform.tfstate.backup"))) # type: ignore + except (FileNotFoundError, PermissionError): + pass + + super(TerraformStartApiIntegrationApplyBase, cls).tearDownClass() + + @classmethod + def _run_command(cls, command) -> CompletedProcess: + test_data_folder = ( + Path(cls.get_integ_dir()) / "testdata" / "start_api" / "terraform" / cls.terraform_application + ) + + return run(command, cwd=test_data_folder, check=True, capture_output=True, timeout=cls.run_command_timeout) + + @skipIf( not CI_OVERRIDE, "Skip Terraform test cases unless running in CI", @@ -54,3 +97,38 @@ def test_successful_request(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"message": "hello world"}) + + +@skipIf( + not CI_OVERRIDE, + "Skip Terraform test cases unless running in CI", +) +@pytest.mark.flaky(reruns=3) +class TestStartApiTerraformApplicationOpenApiAuthorizer(TerraformStartApiIntegrationApplyBase): + terraform_application = "lambda-auth-openapi" + + def setUp(self): + self.url = "http://127.0.0.1:{}".format(self.port) + + @parameterized.expand( + [ + ("/hello", {"headers": {"myheader": "123"}}), + ("/hello-request", {"headers": {"myheader": "123"}, "params": {"mystring": "456"}}), + ] + ) + def test_successful_request(self, endpoint, params): + response = requests.get(self.url + endpoint, timeout=300, **params) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"message": "from authorizer"}) + + @parameterized.expand( + [ + ("/hello", {"headers": {"missin": "123"}}), + ("/hello-request", {"headers": {"notcorrect": "123"}, "params": {"abcde": "456"}}), + ] + ) + def test_missing_identity_sources(self, endpoint, params): + response = requests.get(self.url + endpoint, timeout=300, **params) + + self.assertEqual(response.status_code, 401) diff --git a/tests/integration/testdata/start_api/terraform/lambda-auth-openapi/lambda-functions.zip b/tests/integration/testdata/start_api/terraform/lambda-auth-openapi/lambda-functions.zip new file mode 100644 index 0000000000..36c2644634 Binary files /dev/null and b/tests/integration/testdata/start_api/terraform/lambda-auth-openapi/lambda-functions.zip differ diff --git a/tests/integration/testdata/start_api/terraform/lambda-auth-openapi/main.tf b/tests/integration/testdata/start_api/terraform/lambda-auth-openapi/main.tf new file mode 100644 index 0000000000..8005fb4e95 --- /dev/null +++ b/tests/integration/testdata/start_api/terraform/lambda-auth-openapi/main.tf @@ -0,0 +1,112 @@ +provider "aws" {} + +data "aws_region" "current" {} + +resource "aws_api_gateway_authorizer" "header_authorizer" { + name = "header-authorizer-open-api" + rest_api_id = aws_api_gateway_rest_api.api.id + authorizer_uri = aws_lambda_function.authorizer.invoke_arn + authorizer_credentials = aws_iam_role.invocation_role.arn + identity_source = "method.request.header.myheader" + identity_validation_expression = "^123$" +} + +resource "aws_lambda_function" "authorizer" { + filename = "lambda-functions.zip" + function_name = "authorizer-open-api" + role = aws_iam_role.invocation_role.arn + handler = "handlers.auth_handler" + runtime = "python3.8" + source_code_hash = filebase64sha256("lambda-functions.zip") +} + +resource "aws_lambda_function" "hello_endpoint" { + filename = "lambda-functions.zip" + function_name = "hello-lambda-open-api" + role = aws_iam_role.invocation_role.arn + handler = "handlers.hello_handler" + runtime = "python3.8" + source_code_hash = filebase64sha256("lambda-functions.zip") +} + +resource "aws_api_gateway_rest_api" "api" { + name = "api-open-api" + body = jsonencode({ + swagger = "2.0" + info = { + title = "api-body" + version = "1.0" + } + securityDefinitions = { + TokenAuthorizer = { + type = "apiKey" + in = "header" + name = "myheader" + x-amazon-apigateway-authtype = "custom" + x-amazon-apigateway-authorizer = { + type = "TOKEN" + authorizerUri = "arn:aws:apigateway:${data.aws_region.current.name}:lambda:path/2015-03-31/functions/${aws_lambda_function.authorizer.arn}/invocations" + } + } + RequestAuthorizer = { + type = "apiKey" + in = "unused" + name = "unused" + x-amazon-apigateway-authtype = "custom" + x-amazon-apigateway-authorizer = { + type = "REQUEST" + identitySource = "method.request.header.myheader, method.request.querystring.mystring" + authorizerUri = "arn:aws:apigateway:${data.aws_region.current.name}:lambda:path/2015-03-31/functions/${aws_lambda_function.authorizer.arn}/invocations" + } + } + } + paths = { + "/hello" = { + get = { + security = [ + {TokenAuthorizer = []} + ] + x-amazon-apigateway-integration = { + httpMethod = "GET" + payloadFormatVersion = "1.0" + type = "AWS_PROXY" + uri = "arn:aws:apigateway:${data.aws_region.current.name}:lambda:path/2015-03-31/functions/${aws_lambda_function.hello_endpoint.arn}/invocations" + } + } + } + "/hello-request" = { + get = { + security = [ + {RequestAuthorizer = []} + ] + x-amazon-apigateway-integration = { + httpMethod = "GET" + payloadFormatVersion = "1.0" + type = "AWS_PROXY" + uri = "arn:aws:apigateway:${data.aws_region.current.name}:lambda:path/2015-03-31/functions/${aws_lambda_function.hello_endpoint.arn}/invocations" + } + } + } + } + }) +} + +resource "aws_iam_role" "invocation_role" { + name = "iam-lambda-open-api" + path = "/" + assume_role_policy = <