diff --git a/.github/workflows/rootski-ci.yml b/.github/workflows/rootski-ci.yml index 101ec1b6..111a879d 100644 --- a/.github/workflows/rootski-ci.yml +++ b/.github/workflows/rootski-ci.yml @@ -54,6 +54,16 @@ jobs: - name: install docker uses: docker-practice/actions-setup-docker@1.0.8 + - name: run lambda handler tests + run: | + cd infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/ + make install + make test + + ############################# + # --- Backend API Tests --- # + ############################# + - name: install dependencies run: | # install global python dependencies diff --git a/infrastructure/iac/aws-cdk/cognito/README.md b/infrastructure/iac/aws-cdk/cognito/README.md index 146b778c..834a34dd 100644 --- a/infrastructure/iac/aws-cdk/cognito/README.md +++ b/infrastructure/iac/aws-cdk/cognito/README.md @@ -25,6 +25,7 @@ pip install -r requirements.txt AWS_DEFAULT_REGION="us-west-2" cdk deploy --profile personal ``` + **NOTE!!!** You need to hook this up with the front end. After running `cdk deploy ...`, you'll see several stack outputs. Copy/paste those into the correct JS variables in `rootski_frontend/src/aws-cognito/auth-utils.tsx`. diff --git a/infrastructure/iac/aws-cdk/cognito/app.py b/infrastructure/iac/aws-cdk/cognito/app.py index 886b174d..cfdaebf2 100644 --- a/infrastructure/iac/aws-cdk/cognito/app.py +++ b/infrastructure/iac/aws-cdk/cognito/app.py @@ -1,26 +1,22 @@ #!/usr/bin/env python3 -# For consistency with TypeScript code, `cdk` is the preferred import name for -# the CDK's core module. The following line also imports it as `core` for use -# with examples from the CDK Developer's Guide, which are in the process of -# being updated to use `cdk`. You may delete this import if you don't need it. -from aws_cdk import core +import aws_cdk as cdk from cognito.cognito_stack import CognitoStack +from cognito.ssm_cognito_jwks_custom_resource import SSMParameterWithCognitoJWKsStack -app = core.App() -CognitoStack( +app = cdk.App() + +cognito_stack = CognitoStack( app, "RootksiCognitoStack", - # If you don't specify 'env', this stack will be environment-agnostic. - # Account/Region-dependent features and context lookups will not work, - # but a single synthesized template can be deployed anywhere. - # Uncomment the next line to specialize this stack for the AWS Account - # and Region that are implied by the current CLI configuration. - # env=core.Environment(account=os.getenv('CDK_DEFAULT_ACCOUNT'), region=os.getenv('CDK_DEFAULT_REGION')), - # Uncomment the next line if you know exactly what Account and Region you - # want to deploy the stack to. */ - # env=core.Environment(account='123456789012', region='us-east-1'), - # For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html +) + +SSMParameterWithCognitoJWKsStack( + app, + "Cognito-JWKs-In-SSM-Parameter-Custom-Resource-CF", + cognito_user_pool_id=cognito_stack.cognito_user_pool.ref, + cognito_user_pool_region=cognito_stack.region, + cognito_jwks_ssm_parameter_path="/rootski/cognito/jwks.json", ) app.synth() diff --git a/infrastructure/iac/aws-cdk/cognito/cdk.json b/infrastructure/iac/aws-cdk/cognito/cdk.json index 0ad9736f..16d8a595 100644 --- a/infrastructure/iac/aws-cdk/cognito/cdk.json +++ b/infrastructure/iac/aws-cdk/cognito/cdk.json @@ -2,7 +2,6 @@ "app": "python3 app.py", "context": { "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, - "@aws-cdk/core:enableStackNameDuplicates": "true", "aws-cdk:enableDiffNoFail": "true", "@aws-cdk/core:stackRelativeExports": "true", "@aws-cdk/aws-ecr-assets:dockerIgnoreSupport": true, diff --git a/infrastructure/iac/aws-cdk/cognito/cognito/cognito_stack.py b/infrastructure/iac/aws-cdk/cognito/cognito/cognito_stack.py index d36e129a..77188cdc 100644 --- a/infrastructure/iac/aws-cdk/cognito/cognito/cognito_stack.py +++ b/infrastructure/iac/aws-cdk/cognito/cognito/cognito_stack.py @@ -8,13 +8,14 @@ from pathlib import Path from typing import Dict -import aws_cdk.aws_ssm as ssm -import yaml # For consistency with other languages, `cdk` is the preferred import name for # the CDK's core module. The following line also imports it as `core` for use # with examples from the CDK Developer's Guide, which are in the process of # being updated to use `cdk`. You may delete this import if you don't need it. -from aws_cdk import core as cdk +import aws_cdk as cdk +import aws_cdk.aws_ssm as ssm +import yaml +from aws_cdk import CfnOutput, Stack from aws_cdk.aws_cognito import ( CfnUserPool, CfnUserPoolClient, @@ -22,7 +23,7 @@ CfnUserPoolIdentityProvider, PasswordPolicy, ) -from aws_cdk.core import CfnOutput, Stack +from constructs import Construct THIS_DIR = Path(__file__).parent ROOTSKI_OAUTH_PROVIDERS_FPATH = THIS_DIR / "rootski-oauth-providers.yml" @@ -38,11 +39,11 @@ def load_oauth_config(oauth_config_path: Path): class CognitoStack(Stack): - def __init__(self, scope: cdk.Construct, construct_id: str, **kwargs) -> None: + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: super().__init__(scope, construct_id, **kwargs) # The code that defines your stack goes here - cognito_user_pool = CfnUserPool( + self.cognito_user_pool = CfnUserPool( self, id="RootskiUserPool", auto_verified_attributes=["email"], @@ -93,7 +94,7 @@ def __init__(self, scope: cdk.Construct, construct_id: str, **kwargs) -> None: id="RootskiGoogleIdentityProvider", provider_name="Google", provider_type="Google", - user_pool_id=cognito_user_pool.ref, + user_pool_id=self.cognito_user_pool.ref, attribute_mapping={"email": "email"}, provider_details={ "client_id": OAUTH_CONFIG["google"]["client_id"], @@ -103,7 +104,7 @@ def __init__(self, scope: cdk.Construct, construct_id: str, **kwargs) -> None: ) # prevent race condition where it says that the rootski user pool # doesn't have a Google provider type - google_identity_provider.add_depends_on(cognito_user_pool) + google_identity_provider.add_depends_on(self.cognito_user_pool) # redirect users here when they log in from the Cognito hosted ui callback_ur_ls = ["https://www.rootski.io", "http://localhost:3000"] @@ -119,7 +120,7 @@ def __init__(self, scope: cdk.Construct, construct_id: str, **kwargs) -> None: self, id="RootskiCognitoUserPoolClient", client_name="rootski-io-cognito-client", - user_pool_id=cognito_user_pool.ref, + user_pool_id=self.cognito_user_pool.ref, generate_secret=False, supported_identity_providers=[ "COGNITO", @@ -142,7 +143,7 @@ def __init__(self, scope: cdk.Construct, construct_id: str, **kwargs) -> None: self, id="RootskiUserPoolDomain", domain="rootski", - user_pool_id=cognito_user_pool.ref, + user_pool_id=self.cognito_user_pool.ref, ) # create SSM parameters that the backend API and other sources can read @@ -162,7 +163,7 @@ def __init__(self, scope: cdk.Construct, construct_id: str, **kwargs) -> None: self, id=f"RootskiCognitoUserPoolId{env}", parameter_name=f"/rootski/{env}/cognito/cognito_user_pool_id", - string_value=cognito_user_pool.ref, + string_value=self.cognito_user_pool.ref, type=ssm.ParameterType.STRING, ) @@ -179,7 +180,7 @@ def __init__(self, scope: cdk.Construct, construct_id: str, **kwargs) -> None: CfnOutput( scope=self, id="user-pool-id", - value=cognito_user_pool.ref, + value=self.cognito_user_pool.ref, description="ID of the cognito user pool", export_name="user-pool-id", ) diff --git a/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/.vscode/launch.json b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/.vscode/launch.json new file mode 100644 index 00000000..b30ff7c5 --- /dev/null +++ b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Current File", + "type": "python", + "request": "file", + "program": "${file}", + "purpose": ["debug-test"], + "console": "integratedTerminal", + "justMyCode": false + } + ] +} diff --git a/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/.vscode/settings.json b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/.vscode/settings.json new file mode 100644 index 00000000..be0194e5 --- /dev/null +++ b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.testing.pytestArgs": [] +} diff --git a/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/jwk_cognito_custom_resource/__init__.py b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/jwk_cognito_custom_resource/__init__.py new file mode 100644 index 00000000..827fbcf0 --- /dev/null +++ b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/jwk_cognito_custom_resource/__init__.py @@ -0,0 +1 @@ +"""AWS Lambda function to create a Cognito JWKs custom resource in an SSM parameter.""" diff --git a/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/jwk_cognito_custom_resource/custom_resource_handler.py b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/jwk_cognito_custom_resource/custom_resource_handler.py new file mode 100644 index 00000000..b67adc9d --- /dev/null +++ b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/jwk_cognito_custom_resource/custom_resource_handler.py @@ -0,0 +1,211 @@ +""" +Lambda function that handles CloudFormaiton custom resource lifecycle events. + +Context: the backend rootski API runs in an isolated VPC network without internet +access. Due to this, the backend is not able to hit the public AWS endpoint +to fetch the JSON Web Keys used to validate JWT tokens issued by the rootski +Cognito User Pool. + +However, the rootski API *is* able to access AWS SSM, including AWS SSM Parameter Store. +This lambda function defines a custom resource that performs an HTTP request to +the cognito URL to fetch the JWKs and then stores those in an SSM parameter. + +Thanks to this, the rootski API code can fetch the JWKs from SSM. + +.. note:: + + The CDK docs to a good job of explaining how to write + a custom resource lambda handler: + https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources-readme.html#provider-framework + + This file uses an official, AWS-maintained framework called ``crhelper`` + to help structure the lifecycle handler functions with best practices: + https://github.com/aws-cloudformation/custom-resource-helper +""" + +import json +import re +from typing import Match, TypedDict +from uuid import uuid4 + +import boto3 +import requests +from aws_lambda_typing.context import Context +from aws_lambda_typing.events import ( + CloudFormationCustomResourceCreate, + CloudFormationCustomResourceDelete, + CloudFormationCustomResourceEvent, + CloudFormationCustomResourceUpdate, +) +from crhelper import CfnResource +from mypy_boto3_ssm import SSMClient +from mypy_boto3_ssm.type_defs import DeleteParametersResultTypeDef, PutParameterResultTypeDef + +# Initialise the helper, all inputs are optional, this example shows the defaults +helper = CfnResource( + json_logging=False, log_level="DEBUG", boto_level="CRITICAL", sleep_on_delete=120, ssl_verify=False +) + + +class CognitoJwksCustomResourceProps(TypedDict): + """Properties for the CognitoJwksCustomResource.""" + + CognitoUserPoolId: str + CognitoUserPoolRegion: str + SSMParameterPath: str + + +try: + # Init code goes here + pass +# pylint: disable=broad-except +except Exception as e: + helper.init_failure(e) + + +################################### +# --- Custom Resource Handler --- # +################################### + + +def handler(event: CloudFormationCustomResourceEvent, context: Context): + """Use the ``crhelper`` framework to call ``create()``, ``update()`` or ``delete()`` based on the ``event`` type.""" + helper(event, context) + + +################################## +# --- Event Handlers by Type --- # +################################## + +# pylint: disable=unused-argument +@helper.create +def create(event: CloudFormationCustomResourceCreate, context: Context) -> str: + """Fetch the Cognito JSON Web Keys and create an SSM parameter containg them.""" + print("Got Create") + props: CognitoJwksCustomResourceProps = event["ResourceProperties"] + + cognito_user_pool_jwks: dict = request_jwks( + cognito_aws_region=props["CognitoUserPoolRegion"], + cognito_user_pool_id=props["CognitoUserPoolId"], + ) + + stack_region: str = parse_region_from_stack_arn(event["StackId"]) + create_ssm_parameter( + name=props["SSMParameterPath"], + value=json.dumps(cognito_user_pool_jwks, indent=2), + region=stack_region, + ) + + helper.Data.update( + { + "ParameterPath": props["SSMParameterPath"], + } + ) + + return str(uuid4()) + + +# pylint: disable=unused-argument +@helper.update +def update(event: CloudFormationCustomResourceUpdate, context: Context) -> str: + """ + Update the Cognito user pool JWKS. + + If the update resulted in a new resource being created, + return an id for the new resource. CloudFormation will send a + delete event with the old id when stack update completes. + """ + print("Got Update") + props: CognitoJwksCustomResourceProps = event["ResourceProperties"] + + helper.Data.update( + { + "ParameterPath": props["SSMParameterPath"], + } + ) + + return event["PhysicalResourceId"] + + +# pylint: disable=unused-argument +@helper.delete +def delete(event: CloudFormationCustomResourceDelete, context: Context): + """ + Delete the SSM Parameter containing Cognito user pool JWKS. + + Delete never returns anything. Should not fail if the underlying + resources are already deleted. Desired state. + """ + print("Got Delete") + + stack_region: str = parse_region_from_stack_arn(event["StackId"]) + props: CognitoJwksCustomResourceProps = event["ResourceProperties"] + + delete_ssm_parameter( + name=props["SSMParameterPath"], + region=stack_region, + ) + + +# pylint: disable=unused-argument +@helper.poll_create +def poll_create(event, context): + """ + Return a resource id or True to indicate that creation is complete. + + If True is returned an id will be generated. + """ + print("Got create poll") + return True + + +############################ +# --- Helper Functions --- # +############################ + + +def parse_region_from_stack_arn(arn: str) -> str: + """Parse the region from the stack ``arn``.""" + pattern = r"arn:aws:cloudformation:(?P.*):(?P.*):stack/(?P.*)/(?P.*)" + match: Match = re.match(pattern=pattern, string=arn) + region = match.group("region") + return region + + +def make_jwks_url(cognito_aws_region: str, cognito_user_pool_id: str) -> str: + """Make a URL to the Cognito user pool JWKS.""" + return ( + f"https://cognito-idp.{cognito_aws_region}.amazonaws.com/{cognito_user_pool_id}/.well-known/jwks.json" + ) + + +def request_jwks(cognito_aws_region: str, cognito_user_pool_id: str) -> dict: + """Request the JWKS from the Cognito user pool.""" + cognito_user_pool_jwks_url: str = make_jwks_url( + cognito_aws_region=cognito_aws_region, cognito_user_pool_id=cognito_user_pool_id + ) + response = requests.get(cognito_user_pool_jwks_url) + return response.json() + + +def create_ssm_parameter(name: str, value: str, region: str) -> PutParameterResultTypeDef: + """Create an SSM parameter with the given name and value.""" + ssm_client: SSMClient = boto3.client("ssm", region_name=region) + parameter: PutParameterResultTypeDef = ssm_client.put_parameter( + Name=name, + Value=value, + Type="String", + Overwrite=False, + ) + + return parameter + + +def delete_ssm_parameter(name: str, region: str) -> DeleteParametersResultTypeDef: + """Delete an SSM parameter with the given name and value.""" + ssm_client: SSMClient = boto3.client("ssm", region_name=region) + response: DeleteParametersResultTypeDef = ssm_client.delete_parameter( + Name=name, + ) + + return response diff --git a/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/makefile b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/makefile new file mode 100644 index 00000000..98502a6d --- /dev/null +++ b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/makefile @@ -0,0 +1,9 @@ +install: + # production requirements + pip install -e . + + # development requirements + pip install rich boto3 pytest boto3-stubs[essential,cognito-idp] mypy moto[all] docker==5.0.3 python-jose==3.3.0 + +test: + python -m pytest tests/test__custom_resource.py -xvs diff --git a/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/setup.py b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/setup.py new file mode 100644 index 00000000..7a4a782b --- /dev/null +++ b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/setup.py @@ -0,0 +1,18 @@ +"""Define a package for the SSM-Cognito-JWK custom resource.""" + +from setuptools import find_packages, setup + +setup( + name="jwk_cognito_custom_resource", + package_dir={"": "."}, + packages=find_packages(), + install_requires=[ + "requests", + # framework for writing Lambda-backed AWS custom resources + "crhelper==2.0.10", + # community-maintained types for AWS Lambda function arguments + "aws-lambda-typing==2.10.1", + # community-maintained types for AWS SSM + "boto3-stubs[ssm]", + ], +) diff --git a/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/tests/test__custom_resource.py b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/tests/test__custom_resource.py new file mode 100644 index 00000000..e0f3b75c --- /dev/null +++ b/infrastructure/iac/aws-cdk/cognito/cognito/resources/jwks_ssm_custom_resource_lambda/tests/test__custom_resource.py @@ -0,0 +1,131 @@ +"""Tests to validate the functionality of the custom resource lambda handler.""" + +import json +import os +from typing import Callable, Literal, Union + +import boto3 +import pytest +from aws_lambda_typing.context import Context +from aws_lambda_typing.events import ( + CloudFormationCustomResourceCreate, + CloudFormationCustomResourceDelete, + CloudFormationCustomResourceEvent, +) +from jwk_cognito_custom_resource.custom_resource_handler import ( + CognitoJwksCustomResourceProps, + create, + delete, + parse_region_from_stack_arn, +) +from moto import mock_cognitoidp, mock_ssm + +# from .jwk_cognito_custom_resource.custom_resource import handler, create, poll_create, update, delete, CognitoJwksCustomResourceProps +from mypy_boto3_ssm import SSMClient +from mypy_boto3_ssm.type_defs import GetParameterResultTypeDef, GetParametersByPathResultTypeDef + +TLambdaHandler = Callable[[CloudFormationCustomResourceEvent, Context], None] + +# prevent mistakes with moto/boto3 by pointing away from real accounts +os.environ["AWS_ACCESS_KEY_ID"] = "testing" +os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" +os.environ["AWS_SECURITY_TOKEN"] = "testing" +os.environ["AWS_SESSION_TOKEN"] = "testing" +os.environ["AWS_DEFAULT_REGION"] = "us-east-1" + + +##################### +# --- Constants --- # +##################### + +STACK_ID = "arn:aws:cloudformation:us-west-2:091910621680:stack/Cognito-JWKs-In-SSM-Parameter-Custom-Resource-CF/36444d20-c8e3-11ec-81f0-06fa347afb33" +RESOURCE_PROPERTIES = CognitoJwksCustomResourceProps( + CognitoUserPoolId="test-pool-id", + CognitoUserPoolRegion="us-west-2", + SSMParameterPath="/rootski/cognito/jwks.json", +) + + +def create_event( + event_type: Literal["Create", "Delete"] +) -> Union[CloudFormationCustomResourceCreate, CloudFormationCustomResourceDelete]: + """Create a CloudFormation lifecycle event to be used in test cases.""" + return { + "RequestType": event_type, + "ServiceToken": "arn:aws:lambda:us-west-2:091910621680:function:Cognito-JWKs-In-SSM-Param-SSMParameterWithCognitoJ-GGOkyUUvA1f4", + "ResponseURL": "https://cloudformation-custom-resource-response-uswest2.s3-us-west-2.amazonaws.com/arn%3Aaws%3Acloudformation%3Aus-west-2%3A091910621680%3Astack/Cognito-JWKs-In-SSM-Parameter-Custom-Resource-CF/36444d20-c8e3-11ec-81f0-06fa347afb33%7CSSMParameterWithCognitoJWKs%7C471031d4-5160-496c-9f00-6126aa96ee3d?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20220501T001223Z&X-Amz-SignedHeaders=host&X-Amz-Expires=7200&X-Amz-Credential=AKIA54RCMT6SJTABWA2S%2F20220501%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Signature=7b01a55a52ea74a68a3bd1075cfc25804bead538503a4f05c844005546325451", + "StackId": STACK_ID, + "RequestId": "471031d4-5160-496c-9f00-6126aa96ee3d", + "LogicalResourceId": "SSMParameterWithCognitoJWKs", + "ResourceType": "Custom::Rootski-CognitoJWKsInSSM", + "ResourceProperties": RESOURCE_PROPERTIES, + } + + +CREATE_EVENT: CloudFormationCustomResourceCreate = create_event(event_type="Create") +DELETE_EVENT: CloudFormationCustomResourceDelete = create_event(event_type="Delete") + + +################# +# --- Tests --- # +################# + + +@mock_cognitoidp +@mock_ssm +def test__create(): + """Verify that parameter creation works correctly.""" + create_parameter_with_assertions(event=CREATE_EVENT) + + +@mock_cognitoidp +@mock_ssm +def test__delete(): + """Verify that parameter creation works correctly.""" + create_parameter_with_assertions(event=CREATE_EVENT) + delete_parameter_with_assertions(event=DELETE_EVENT) + + +############################ +# --- Helper Functions --- # +############################ + + +def fetch_ssm_parameter(name: str, region: str) -> GetParameterResultTypeDef: + """Fetch an SSM parameter.""" + ssm_client: SSMClient = boto3.client("ssm", region_name=region) + return ssm_client.get_parameter(Name=name) + + +def create_parameter_with_assertions( + event: CloudFormationCustomResourceCreate, +) -> GetParametersByPathResultTypeDef: + """Call the creation handler and assert that the parameter was created correctly.""" + create(event=event, context=None) + + jwks_parameter_path: str = event["ResourceProperties"]["SSMParameterPath"] + result = fetch_ssm_parameter(name=jwks_parameter_path, region=parse_region_from_stack_arn(event["StackId"])) + + assert result["Parameter"]["Name"] == event["ResourceProperties"]["SSMParameterPath"] + assert result["Parameter"]["Type"] == "String" + + parameter: dict = json.loads(result["Parameter"]["Value"]) + assert "keys" in parameter.keys() + assert len(parameter["keys"]) >= 1 + + return result + + +def delete_parameter_with_assertions( + event: CloudFormationCustomResourceDelete, +) -> GetParametersByPathResultTypeDef: + """Call the deletion handler on a deletion event and assert that the parameter is deleted.""" + delete(event=event, context=None) + + # you can't import errors from botocore directly >:( + ssm_client = boto3.client("ssm") + with pytest.raises(ssm_client.exceptions.ParameterNotFound): + fetch_ssm_parameter( + name=event["ResourceProperties"]["SSMParameterPath"], + region=parse_region_from_stack_arn(event["StackId"]), + ) diff --git a/infrastructure/iac/aws-cdk/cognito/cognito/ssm_cognito_jwks_custom_resource.py b/infrastructure/iac/aws-cdk/cognito/cognito/ssm_cognito_jwks_custom_resource.py new file mode 100644 index 00000000..49eb9445 --- /dev/null +++ b/infrastructure/iac/aws-cdk/cognito/cognito/ssm_cognito_jwks_custom_resource.py @@ -0,0 +1,131 @@ +"""Stack containing a custom resource and instance of the custom resource to save JWKs in SSM.""" + +from pathlib import Path +from typing import TypedDict + +import aws_cdk as cdk +from aws_cdk import CustomResource, Stack +from aws_cdk import aws_iam as iam +from aws_cdk import aws_lambda as lambda_ +from aws_cdk import custom_resources +from constructs import Construct + +THIS_DIR = Path(__file__).parent +CUSTOM_RESOURCE_LAMBDA_DIR = (THIS_DIR / "./resources/jwks_ssm_custom_resource_lambda").resolve().absolute() + + +class CognitoJwksCustomResourceProps(TypedDict): + """Properties for the CognitoJwksCustomResource.""" + + CognitoUserPoolId: str + CognitoUserPoolRegion: str + SSMParameterPath: str + + +class SSMParameterWithCognitoJWKsStack(Stack): + """Custom resource that stores cognito JSON Web Keys in an SSM Parameter.""" + + # pylint: disable=too-many-arguments + def __init__( + self, + scope: Construct, + construct_id: str, + cognito_user_pool_id: str, + cognito_user_pool_region: str, + cognito_jwks_ssm_parameter_path: str, + **kwargs + ): + super().__init__(scope, construct_id, **kwargs) + + #: lambda function that processes CloudFormation custom resource lifecycle events + self.on_event__lambda_handler: lambda_.Function = self.make__on_event__lambda_handler() + + #: wrapper for the on_event__lambda_handler used to create instances of the custom resource + self.custom_resource_provider = custom_resources.Provider( + self, + "SSM-Parameter-With-Cognito-JWKs-Provider", + on_event_handler=self.on_event__lambda_handler, + ) + + #: instance of custom resource with information about a particular cognito user pool + self.ssm_parameter_with_cognito_jwks = CustomResource( + self, + "SSM-Parameter-With-Cognito-JWKs", + service_token=self.custom_resource_provider.service_token, + resource_type="Custom::Rootski-CognitoJWKsInSSM", + properties=CognitoJwksCustomResourceProps( + CognitoUserPoolId=cognito_user_pool_id, + CognitoUserPoolRegion=cognito_user_pool_region, + SSMParameterPath=cognito_jwks_ssm_parameter_path, + ), + ) + + def make__on_event__lambda_handler(self): + """ + Create a lambda function that processes CloudFormation custom resource lifecycle events. + + These events include Create, Read, Update, and Delete operations on the + SSM parameter that is created by this custom resource. + """ + on_event__lambda_handler = lambda_.Function( + self, + "Rootski-FastAPI-Lambda", + timeout=cdk.Duration.seconds(30), + memory_size=256, + runtime=lambda_.Runtime.PYTHON_3_8, + handler="jwk_cognito_custom_resource.custom_resource_handler.handler", + code=lambda_.Code.from_asset( + path=str(CUSTOM_RESOURCE_LAMBDA_DIR), + bundling=cdk.BundlingOptions( + # learn about this here: + # https://docs.aws.amazon.com/cdk/api/v1/python/aws_cdk.aws_lambda/README.html#bundling-asset-code + # Using this lambci image makes it so that dependencies with C-binaries compile correctly for the lambda runtime. + # The AWS CDK python images were not doing this. + image=cdk.DockerImage.from_registry(image="lambci/lambda:build-python3.8"), + command=[ + "bash", + "-c", + "mkdir -p /asset-output /asset-input" + + "&& pip install /asset-input/ -t /asset-output/" + + "&& rm -rf /asset-output/boto3 /asset-output/botocore", + ], + ), + ), + ) + + # allow lambda to do CRUD operations of the SSM parameter created by this resource + on_event__lambda_handler.role.attach_inline_policy( + policy=iam.Policy( + self, + id="Allow-Lambda-Access-To-SSM-Params", + statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + resources=[ + "arn:aws:ssm:{region}:{account}:parameter/rootski/cognito*".format( + region=cdk.Stack.of(self).region, + account=cdk.Stack.of(self).account, + ) + ], + actions=[ + # create the SSM parameter + "ssm:Put*", + # delete the SSM parameter + "ssm:Delete*", + # read the SSM parameter to verify that it was deleted + "ssm:Get*", + ], + ) + ], + ) + ) + + on_event__lambda_handler.role.add_managed_policy( + policy=iam.ManagedPolicy.from_managed_policy_arn( + self, + id="Allow-Lambda-To-Connect-To-Lightsail-VPC-via-VPC-Peering", + managed_policy_arn="arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole", + ) + ) + + return on_event__lambda_handler diff --git a/infrastructure/iac/aws-cdk/cognito/makefile b/infrastructure/iac/aws-cdk/cognito/makefile index e62c0cb0..d74d3fcd 100644 --- a/infrastructure/iac/aws-cdk/cognito/makefile +++ b/infrastructure/iac/aws-cdk/cognito/makefile @@ -11,7 +11,7 @@ PROJECT_VERSION=$$(cat version.txt) # The [dev] spec tells setup to install extra dependencies needed for development work. install: pip install -U pip - pip install -r requirements.txt + pip install -e . diff: cdk diff --profile rootski @@ -19,14 +19,6 @@ diff: deploy: cdk deploy --profile rootski -# Execute ben-py-lint against the source and test folders. -# This will update code in place for applicable tools (black, isort, etc.) -# Ben-py-lint behaves differently in test folders, consult it's documentation for more information. -lint: - black src/rootski/ tests/ -l 112 - isort src/rootski/ tests/ - radon cc raw mi hal - # execute unit tests and generate coverage reports. # Note: Generating html outputs are optional, this line can be removed if build time is an issue. unit-test: diff --git a/infrastructure/iac/aws-cdk/cognito/requirements.txt b/infrastructure/iac/aws-cdk/cognito/requirements.txt deleted file mode 100644 index d6e1198b..00000000 --- a/infrastructure/iac/aws-cdk/cognito/requirements.txt +++ /dev/null @@ -1 +0,0 @@ --e . diff --git a/infrastructure/iac/aws-cdk/cognito/setup.py b/infrastructure/iac/aws-cdk/cognito/setup.py index 6c79b56b..c9ca8ca5 100644 --- a/infrastructure/iac/aws-cdk/cognito/setup.py +++ b/infrastructure/iac/aws-cdk/cognito/setup.py @@ -7,24 +7,12 @@ setuptools.setup( name="cognito", version="0.0.1", - description="An empty CDK Python app", + description="Cognito user pool", long_description=long_description, long_description_content_type="text/markdown", author="rootski-io", package_dir={"": "cognito"}, packages=setuptools.find_packages(where="cognito"), - install_requires=["aws-cdk.core==1.146.0", "aws-cdk.aws_cognito", "PyYAML"], + install_requires=["aws-cdk-lib==2.17.0", "constructs>=10.0.0,<11.0.0", "PyYAML"], python_requires=">=3.6", - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Programming Language :: JavaScript", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Topic :: Software Development :: Code Generators", - "Topic :: Utilities", - "Typing :: Typed", - ], ) diff --git a/infrastructure/iac/aws-cdk/cognito/source.bat b/infrastructure/iac/aws-cdk/cognito/source.bat deleted file mode 100644 index 9e1a8344..00000000 --- a/infrastructure/iac/aws-cdk/cognito/source.bat +++ /dev/null @@ -1,13 +0,0 @@ -@echo off - -rem The sole purpose of this script is to make the command -rem -rem source .venv/bin/activate -rem -rem (which activates a Python virtualenv on Linux or Mac OS X) work on Windows. -rem On Windows, this command just runs this batch file (the argument is ignored). -rem -rem Now we don't need to document a Windows command for activating a virtualenv. - -echo Executing .venv\Scripts\activate.bat for you -.venv\Scripts\activate.bat