diff --git a/.circleci/config.yml b/.circleci/config.yml index 298fc40..6b11b88 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -92,6 +92,7 @@ jobs: SAM_CONFIG_FILE: samconfig.toml.d/ci-<>.toml SAM_LAMBDA_CONFIG_ENV: <> SAM_PUBLIC_CONFIG_ENV: <>-public-access + SAM_LAMBDA_FUNCTION_CONFIG_ENV: <>-cloudfront-lambda-functions DC_DEPLOY_NAME: <> POSTGRES_DATABASE_NAME: <> steps: @@ -134,6 +135,13 @@ jobs: " no_output_timeout: 30m - run: pipenv run migratedb + - run: + name: "sam deploy lambda@edge function" + command: | + pipenv run sam deploy \ + --config-file ~/repo/${SAM_CONFIG_FILE} \ + --config-env $SAM_LAMBDA_FUNCTION_CONFIG_ENV + - run: name: printenv PUBLIC_FQDN CERTIFICATE_ARN # These envvars are stored inside CircleCI, which helpfully masks them if they're echoed. @@ -157,6 +165,7 @@ jobs: StackNameSuffix=<> \ CertificateArn=$CERTIFICATE_ARN \ PublicFqdn=$PUBLIC_FQDN \ + AuthHeaderToQueryStringARN=`python .circleci/get_cf_export_value.py AuthHeaderToQueryString` \ " - run: name: "Publish a new Sentry Release" diff --git a/.circleci/get_cf_export_value.py b/.circleci/get_cf_export_value.py new file mode 100644 index 0000000..8de6036 --- /dev/null +++ b/.circleci/get_cf_export_value.py @@ -0,0 +1,39 @@ +""" +Because us-east-1 is really the only place that AWS works properly. + +This shouldn't be a thing. But, it is a thing. + +Our main stack is in `eu-west-2` and the CloudFront +distribution is "global", meaning a very specific part of the US. + +However, when we want to attach a lambda@edge function +to the "global" resource for our `eu-west-2` deployment, the lambda +function itself needs to be in...you guessed it, a very specific part +of the US. + +Fine, but we also need a way to share the version ARN of that function +with the CloudFront distribution. The right way to do this is via the +CloudFormation Exports system, however they don't work across regions. + +This means that either Lambda@Edge only really works if you deploy to +a very specific part of the US, or if you hack some script together like +the below. + +""" + +import sys + +import boto3 + + +def get_export_value(export_name): + cf_client = boto3.client("cloudformation", region_name="us-east-1") + for export in cf_client.list_exports()["Exports"]: + if export["Name"] == export_name: + return export["Value"] + raise ValueError(f"Export {export_name} not found") + + +if __name__ == "__main__": + export_name = sys.argv[1] + print(get_export_value(export_name)) diff --git a/api_endpoints/lambda_edge/auth_header_to_query/handler.py b/api_endpoints/lambda_edge/auth_header_to_query/handler.py new file mode 100644 index 0000000..afa5623 --- /dev/null +++ b/api_endpoints/lambda_edge/auth_header_to_query/handler.py @@ -0,0 +1,42 @@ +from urllib.parse import parse_qs, urlencode + + +def lambda_handler(event, context): + request = event["Records"][0]["cf"]["request"] + + """ + This code is run between a request from the web and that request + being passed on to CloudFront: + + Request -> function -> CloudFront -> Origin + + We use it to convert the `Authorization` header into a + query string parameter. + + This is due to AWS API Gateway not being able to support + more than one authentication method using OR. + + That is, you can add header and query string authentication methods, but + they are ANDed together meaning you need to supply both. + + This function allows us to accept either by converting one method to using + the other method. + + """ + + # Check if the header exists + token_header = request["headers"].get("authorization") + if not token_header: + # If not, return early. Nothing to be done here + return request + + # Parse request querystring to get dictionary/json + params = {k: v[0] for k, v in parse_qs(request["querystring"]).items()} + # The header value is in the format of: + # {lower_case_header_name: [{"key": "Title_Case_Header_Name": "value": "value"}]} + # And the authorization header value is "Token api_key" + token = token_header[0]["value"].split(" ")[-1] + params["token"] = token + request["querystring"] = urlencode(params) + + return request diff --git a/cloudfront-lambda-functions.yaml b/cloudfront-lambda-functions.yaml new file mode 100644 index 0000000..0eb8d29 --- /dev/null +++ b/cloudfront-lambda-functions.yaml @@ -0,0 +1,20 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: "EC API public access: TLS, CDN, DNS" + +Resources: + AuthHeaderToQueryStringFunction: + Type: AWS::Serverless::Function + Properties: + Role: !Sub "arn:aws:iam::${AWS::AccountId}:role/ECApiLambdaExecutionRole" + CodeUri: ./api_endpoints/lambda_edge/auth_header_to_query + Handler: handler.lambda_handler + Runtime: python3.8 + AutoPublishAlias: live + +Outputs: + AuthHeaderToQueryStringFunctionVersion: + Description: The version ARN of the ID of the AuthHeaderToQueryStringFunction + Value: !Ref AuthHeaderToQueryStringFunction.Version + Export: + Name: "AuthHeaderToQueryString" diff --git a/public-access-template.yaml b/public-access-template.yaml index 7127b23..0ee3dfe 100644 --- a/public-access-template.yaml +++ b/public-access-template.yaml @@ -13,6 +13,9 @@ Parameters: PublicFqdn: Type: String + AuthHeaderToQueryStringARN: + Type: String + Resources: APIResponsePolicy: Type: AWS::CloudFront::ResponseHeadersPolicy @@ -47,6 +50,9 @@ Resources: RemoveHeadersConfig: Items: - Header: Vary + + + CloudFrontDistribution: Type: 'AWS::CloudFront::Distribution' Properties: @@ -117,6 +123,9 @@ Resources: - AllowedMethods: [ GET, HEAD, OPTIONS ] PathPattern: api/* TargetOriginId: StarletteAPI + LambdaFunctionAssociations: + - EventType: 'viewer-request' + LambdaFunctionARN: !Ref AuthHeaderToQueryStringARN ForwardedValues: QueryString: true Cookies: diff --git a/samconfig.toml.d/ci-production.toml b/samconfig.toml.d/ci-production.toml index 0408e3a..6151aa1 100644 --- a/samconfig.toml.d/ci-production.toml +++ b/samconfig.toml.d/ci-production.toml @@ -34,6 +34,7 @@ region = "eu-west-2" [production-public-access] [production-public-access.deploy] [production-public-access.deploy.parameters] +s3_bucket = "ec-api-deployment-artifacts-production-6jfsds0gj3f" template = "public-access-template.yaml" stack_name = "ECApiPublicAccess-production" region = "eu-west-2" @@ -45,3 +46,19 @@ fail_on_empty_changeset = false force_upload = true # Using a "parameter_overrides" setting here would block using CI envvars, as only # one overrides source is used and this source doesn't pass through shell interpolation. + +[production-cloudfront-lambda-functions] +[production-cloudfront-lambda-functions.deploy] +[production-cloudfront-lambda-functions.deploy.parameters] +template = "cloudfront-lambda-functions.yaml" +s3_bucket = "ec-api-cloudfront-functions-production-6jfsds0gj3f" +stack_name = "ECApiCloudFrontFunctions-production" +region = "us-east-1" +confirm_changeset = false +capabilities = "CAPABILITY_IAM" +tags = "dc-product=\"ec-api\" dc-environment=\"production\"" +progressbar = false +fail_on_empty_changeset = false +force_upload = true +# Using a "parameter_overrides" setting here would block using CI envvars, as only +# one overrides source is used and this source doesn't pass through shell interpolation. diff --git a/samconfig.toml.d/ci-staging.toml b/samconfig.toml.d/ci-staging.toml index 3d9fef6..9ee5529 100644 --- a/samconfig.toml.d/ci-staging.toml +++ b/samconfig.toml.d/ci-staging.toml @@ -35,6 +35,7 @@ region = "eu-west-2" [staging-public-access.deploy] [staging-public-access.deploy.parameters] template = "public-access-template.yaml" +s3_bucket = "ec-api-deployment-artifacts-staging-6jfsds0gj3f" stack_name = "ECApiPublicAccess-staging" region = "eu-west-2" confirm_changeset = false @@ -45,3 +46,19 @@ fail_on_empty_changeset = false force_upload = true # Using a "parameter_overrides" setting here would block using CI envvars, as only # one overrides source is used and this source doesn't pass through shell interpolation. + +[staging-cloudfront-lambda-functions] +[staging-cloudfront-lambda-functions.deploy] +[staging-cloudfront-lambda-functions.deploy.parameters] +template = "cloudfront-lambda-functions.yaml" +s3_bucket = "ec-api-cloudfront-functions-staging-6jfsds0gj3f" +stack_name = "ECApiCloudFrontFunctions-staging" +region = "us-east-1" +confirm_changeset = false +capabilities = "CAPABILITY_IAM" +tags = "dc-product=\"ec-api\" dc-environment=\"staging\"" +progressbar = false +fail_on_empty_changeset = false +force_upload = true +# Using a "parameter_overrides" setting here would block using CI envvars, as only +# one overrides source is used and this source doesn't pass through shell interpolation. diff --git a/template.yaml b/template.yaml index f142fa3..ad10239 100644 --- a/template.yaml +++ b/template.yaml @@ -148,12 +148,14 @@ Resources: # - token # ReauthorizeEvery: 3600 + APIAuthFunction: Type: AWS::Serverless::Function Properties: Role: !Sub "arn:aws:iam::${AWS::AccountId}:role/ECApiLambdaExecutionRole" CodeUri: ./api_endpoints/api_auth Handler: handler.lambda_handler + AutoPublishAlias: live Environment: Variables: POSTGRES_HOST: !Ref AppPostgresHost