From f01bd9968cc17a25ef561b054bddb6622f7fcbd9 Mon Sep 17 00:00:00 2001 From: kyleknap Date: Tue, 24 Oct 2017 11:58:08 -0700 Subject: [PATCH 1/4] Add support for custom authorizers with SAM --- chalice/deploy/swagger.py | 45 ++++++++---- chalice/package.py | 73 +++++++++++-------- chalice/utils.py | 15 ++++ tests/unit/deploy/test_swagger.py | 39 ++++++++++- tests/unit/test_package.py | 112 +++++++++++++++++++++++++----- 5 files changed, 224 insertions(+), 60 deletions(-) diff --git a/chalice/deploy/swagger.py b/chalice/deploy/swagger.py index 321f587ee..ba8565515 100644 --- a/chalice/deploy/swagger.py +++ b/chalice/deploy/swagger.py @@ -4,6 +4,7 @@ from chalice.app import Chalice, RouteEntry, Authorizer, CORSConfig # noqa from chalice.app import ChaliceAuthorizer +from chalice.utils import to_cfn_resource_name class SwaggerGenerator(object): @@ -69,30 +70,38 @@ def _add_route_paths(self, api, app): def _generate_security_from_auth_obj(self, api_config, authorizer): # type: (Dict[str, Any], Authorizer) -> None if isinstance(authorizer, ChaliceAuthorizer): - function_name = '%s-%s' % ( - self._deployed_resources['api_handler_name'], - authorizer.config.name - ) - arn = self._deployed_resources[ - 'lambda_functions'][function_name]['arn'] auth_config = authorizer.config config = { 'in': 'header', 'type': 'apiKey', 'name': 'Authorization', - 'x-amazon-apigateway-authtype': 'custom', - 'x-amazon-apigateway-authorizer': { - 'type': 'token', - 'authorizerCredentials': auth_config.execution_role, - 'authorizerUri': self._uri(arn), - 'authorizerResultTtlInSeconds': auth_config.ttl_seconds, - } + 'x-amazon-apigateway-authtype': 'custom' + } + api_gateway_authorizer = { + 'type': 'token', + 'authorizerUri': self._auth_uri(authorizer) } + if auth_config.execution_role is not None: + api_gateway_authorizer['authorizerCredentials'] = \ + auth_config.execution_role + if auth_config.ttl_seconds is not None: + api_gateway_authorizer['authorizerResultTtlInSeconds'] = \ + auth_config.ttl_seconds + config['x-amazon-apigateway-authorizer'] = api_gateway_authorizer else: config = authorizer.to_swagger() api_config.setdefault( 'securityDefinitions', {})[authorizer.name] = config + def _auth_uri(self, authorizer): + # type: (ChaliceAuthorizer) -> str + function_name = '%s-%s' % ( + self._deployed_resources['api_handler_name'], + authorizer.config.name + ) + return self._uri( + self._deployed_resources['lambda_functions'][function_name]['arn']) + def _add_to_security_definition(self, security, api_config, view): # type: (Any, Dict[str, Any], RouteEntry) -> None @@ -226,3 +235,13 @@ def _uri(self, lambda_arn=None): '/functions/${APIHandler.Arn}/invocations' ) } + + def _auth_uri(self, authorizer): + # type: (ChaliceAuthorizer) -> Any + return { + 'Fn::Sub': ( + 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31' + '/functions/${%s.Arn}/invocations' % to_cfn_resource_name( + authorizer.name) + ) + } diff --git a/chalice/package.py b/chalice/package.py index bcb4488af..e039f23f6 100644 --- a/chalice/package.py +++ b/chalice/package.py @@ -1,7 +1,5 @@ import os import copy -import hashlib -import re from typing import Any, Dict # noqa @@ -12,7 +10,7 @@ from chalice.deploy.deployer import ApplicationPolicyHandler from chalice.constants import DEFAULT_LAMBDA_TIMEOUT from chalice.constants import DEFAULT_LAMBDA_MEMORY_SIZE -from chalice.utils import OSUtils, UI, serialize_to_json +from chalice.utils import OSUtils, UI, serialize_to_json, to_cfn_resource_name from chalice.config import Config # noqa from chalice.app import Chalice # noqa from chalice.policy import AppPolicyGenerator @@ -91,31 +89,39 @@ def __init__(self, swagger_generator, policy_generator): def generate_sam_template(self, config, code_uri=''): # type: (Config, str) -> Dict[str, Any] - self._check_for_unsupported_features(config) template = copy.deepcopy(self._BASE_TEMPLATE) resources = { - 'APIHandler': self._generate_serverless_function(config, code_uri), + 'APIHandler': self._generate_serverless_function( + config, code_uri, 'app.app', 'api'), 'RestAPI': self._generate_rest_api( config.chalice_app, config.api_gateway_stage), } + self._add_auth_handlers(resources, config, code_uri) template['Resources'] = resources self._update_endpoint_url_output(template, config) return template - def _check_for_unsupported_features(self, config): - # type: (Config) -> None - if config.chalice_app.builtin_auth_handlers: - # It doesn't look like SAM templates support everything - # we need to fully support built in authorizers. - # See: awslabs/serverless-application-model#49 - # and: https://forums.aws.amazon.com/thread.jspa?messageID=787920 - # - # We might need to switch to low level cfn to fix this. - raise UnsupportedFeatureError( - "SAM templates do not currently support these " - "built-in auth handlers: %s" % ', '.join( - [c.name for c in - config.chalice_app.builtin_auth_handlers])) + def _add_auth_handlers(self, resources, config, code_uri): + # type: (Dict[str, Any], Config, str) -> None + for auth_config in config.chalice_app.builtin_auth_handlers: + auth_resource_name = to_cfn_resource_name(auth_config.name) + new_config = config.scope(chalice_stage=config.chalice_stage, + function_name=auth_config.name) + resources[auth_resource_name] = self._generate_serverless_function( + new_config, code_uri, auth_config.handler_string, 'authorizer') + resources[auth_resource_name + 'InvokePermission'] = \ + self._generate_lambda_permission(auth_resource_name) + + def _generate_lambda_permission(self, lambda_ref): + # type: (str) -> Dict[str, Any] + return { + 'Type': 'AWS::Lambda::Permission', + 'Properties': { + 'FunctionName': {'Fn::GetAtt': [lambda_ref, 'Arn']}, + 'Action': 'lambda:InvokeFunction', + 'Principal': 'apigateway.amazonaws.com' + } + } def _update_endpoint_url_output(self, template, config): # type: (Dict[str, Any], Config) -> None @@ -123,13 +129,15 @@ def _update_endpoint_url_output(self, template, config): template['Outputs']['EndpointURL']['Value']['Fn::Sub'] = ( url % config.api_gateway_stage) - def _generate_serverless_function(self, config, code_uri): - # type: (Config, str) -> Dict[str, Any] + def _generate_serverless_function(self, config, code_uri, handler_string, + function_type): + # type: (Config, str, str, str) -> Dict[str, Any] properties = { 'Runtime': config.lambda_python_version, - 'Handler': 'app.app', + 'Handler': handler_string, 'CodeUri': code_uri, - 'Events': self._generate_function_events(config.chalice_app), + 'Events': self._generate_function_events( + config.chalice_app, function_type), 'Tags': config.tags, 'Timeout': DEFAULT_LAMBDA_TIMEOUT, 'MemorySize': DEFAULT_LAMBDA_MEMORY_SIZE @@ -151,17 +159,18 @@ def _generate_serverless_function(self, config, code_uri): 'Properties': properties, } - def _generate_function_events(self, app): + def _generate_function_events(self, app, function_type): + # type: (Chalice, str) -> Dict[str, Any] + return getattr( + self, '_generate_' + function_type + '_function_events')(app) + + def _generate_api_function_events(self, app): # type: (Chalice) -> Dict[str, Any] events = {} for methods in app.routes.values(): for http_method, view in methods.items(): - mod_view_name = re.sub(r'[^A-Za-z0-9]+', '', view.view_name) - key_name = ''.join([ - mod_view_name, http_method.lower(), - hashlib.md5( - view.view_name.encode('utf-8')).hexdigest()[:4], - ]) + key_name = to_cfn_resource_name( + view.view_name + http_method.lower()) events[key_name] = { 'Type': 'Api', 'Properties': { @@ -172,6 +181,10 @@ def _generate_function_events(self, app): } return events + def _generate_authorizer_function_events(self, app): + # type: (Chalice) -> Dict[str, Any] + return {} + def _generate_rest_api(self, app, api_gateway_stage): # type: (Chalice, str) -> Dict[str, Any] swagger_definition = self._swagger_generator.generate_swagger(app) diff --git a/chalice/utils.py b/chalice/utils.py index e20c47612..43b18d88d 100644 --- a/chalice/utils.py +++ b/chalice/utils.py @@ -3,7 +3,9 @@ import zipfile import json import contextlib +import hashlib import tempfile +import re import shutil import sys import tarfile @@ -20,6 +22,19 @@ class AbortedError(Exception): pass +def to_cfn_resource_name(name): + # type: (str) -> str + """Transform a name to a valid cfn name. + + This transform ensures that only alphanumeric characters are used + and prevent collisions by appending the hash of the original name. + """ + alphanumeric_only_name = re.sub(r'[^A-Za-z0-9]+', '', name) + return ''.join([ + alphanumeric_only_name, hashlib.md5( + name.encode('utf-8')).hexdigest()[:4]]) + + def remove_stage_from_deployed_values(key, filename): # type: (str, str) -> None """Delete a top level key from the deployed JSON file.""" diff --git a/tests/unit/deploy/test_swagger.py b/tests/unit/deploy/test_swagger.py index c675a9f5b..6e51b49b1 100644 --- a/tests/unit/deploy/test_swagger.py +++ b/tests/unit/deploy/test_swagger.py @@ -1,4 +1,4 @@ -from chalice.deploy.swagger import SwaggerGenerator +from chalice.deploy.swagger import SwaggerGenerator, CFNSwaggerGenerator from chalice import CORSConfig from chalice.app import CustomAuthorizer, CognitoUserPoolAuthorizer from chalice.app import IAMAuthorizer, Chalice @@ -473,3 +473,40 @@ def foo(): '/2015-03-31/functions/auth_arn/invocations'), } } + + +def test_will_custom_auth_with_cfn(sample_app): + swagger_gen = CFNSwaggerGenerator( + region='us-west-2', + deployed_resources={} + ) + + # No "name=" kwarg provided should default + # to a name of "auth". + @sample_app.authorizer(ttl_seconds=10, execution_role='arn:role') + def auth(auth_request): + pass + + @sample_app.route('/auth', authorizer=auth) + def foo(): + pass + + doc = swagger_gen.generate_swagger(sample_app) + assert 'securityDefinitions' in doc + assert doc['securityDefinitions']['auth'] == { + 'in': 'header', + 'name': 'Authorization', + 'type': 'apiKey', + 'x-amazon-apigateway-authtype': 'custom', + 'x-amazon-apigateway-authorizer': { + 'type': 'token', + 'authorizerCredentials': 'arn:role', + 'authorizerResultTtlInSeconds': 10, + 'authorizerUri': { + 'Fn::Sub': ( + 'arn:aws:apigateway:${AWS::Region}:lambda:path' + '/2015-03-31/functions/${authfa53.Arn}/invocations' + ) + } + } + } diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 6de843bd1..5561b3a6e 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -260,21 +260,6 @@ def test_role_arn_added_to_function(sample_app, assert 'Policies' not in properties -def test_fails_with_custom_auth(sample_app_with_auth, - mock_swagger_generator, - mock_policy_generator): - p = package.SAMTemplateGenerator( - mock_swagger_generator, mock_policy_generator) - mock_swagger_generator.generate_swagger.return_value = { - 'swagger': 'document' - } - config = Config.create( - chalice_app=sample_app_with_auth, api_gateway_stage='dev', - app_name='myapp', manage_iam_role=False, iam_role_arn='role-arn') - with pytest.raises(package.UnsupportedFeatureError): - p.generate_sam_template(config) - - def test_app_incompatible_with_cf(sample_app, mock_swagger_generator, mock_policy_generator): @@ -294,4 +279,99 @@ def foo_invalid(): template = p.generate_sam_template(config) events = template['Resources']['APIHandler']['Properties']['Events'] # The underscore should be removed from the event name. - assert 'fooinvalidget2cda' in events + assert 'fooinvalidget4cee' in events + + +def test_app_with_auth(sample_app, + mock_swagger_generator, + mock_policy_generator): + + @sample_app.authorizer('myauth') + def myauth(auth_request): + pass + + @sample_app.route('/authorized', authorizer=myauth) + def foo(): + return {} + # The last four digits come from the hash of the auth name + cfn_auth_name = 'myauthdb6d' + + p = package.SAMTemplateGenerator( + mock_swagger_generator, mock_policy_generator) + mock_swagger_generator.generate_swagger.return_value = { + 'swagger': 'document' + } + config = Config.create( + chalice_app=sample_app, + api_gateway_stage='dev', + ) + template = p.generate_sam_template(config) + assert cfn_auth_name in template['Resources'] + auth_function = template['Resources'][cfn_auth_name] + assert auth_function['Type'] == 'AWS::Serverless::Function' + assert auth_function['Properties']['Handler'] == 'app.myauth' + + # Assert that the invoke permsissions were added as well. + assert cfn_auth_name + 'InvokePermission' in template['Resources'] + assert template['Resources'][cfn_auth_name + 'InvokePermission'] == { + 'Type': 'AWS::Lambda::Permission', + 'Properties': { + 'Action': 'lambda:InvokeFunction', + 'FunctionName': { + 'Fn::GetAtt': [ + cfn_auth_name, + 'Arn' + ] + }, + 'Principal': 'apigateway.amazonaws.com' + } + } + + +def test_app_with_auth_but_invalid_cfn_name(sample_app, + mock_swagger_generator, + mock_policy_generator): + + # Underscores are not allowed for CFN resource names + # This instead should be referred to as customauth in CFN templates + # where the underscore is removed. + @sample_app.authorizer('custom_auth') + def custom_auth(auth_request): + pass + + @sample_app.route('/authorized', authorizer=custom_auth) + def foo(): + return {} + + # The last four digits come from the hash of the auth name + cfn_auth_name = 'customauth8767' + p = package.SAMTemplateGenerator( + mock_swagger_generator, mock_policy_generator) + mock_swagger_generator.generate_swagger.return_value = { + 'swagger': 'document' + } + config = Config.create( + chalice_app=sample_app, + api_gateway_stage='dev', + ) + template = p.generate_sam_template(config) + assert cfn_auth_name in template['Resources'] + auth_function = template['Resources'][cfn_auth_name] + assert auth_function['Type'] == 'AWS::Serverless::Function' + assert auth_function['Properties']['Handler'] == 'app.custom_auth' + + # Assert that the invoke permsissions were added as well. + assert cfn_auth_name + 'InvokePermission' in template['Resources'] + assert template['Resources'][cfn_auth_name + 'InvokePermission'] == { + 'Type': 'AWS::Lambda::Permission', + 'Properties': { + 'Action': 'lambda:InvokeFunction', + 'FunctionName': { + 'Fn::GetAtt': [ + cfn_auth_name, + 'Arn' + ] + }, + 'Principal': 'apigateway.amazonaws.com' + } + } From 4bb83f7e2e9fdef652fa6fa24c245fd39ebc7b8e Mon Sep 17 00:00:00 2001 From: kyleknap Date: Tue, 24 Oct 2017 22:00:06 -0700 Subject: [PATCH 2/4] Scope iam role configuration to auth functions --- chalice/package.py | 26 ++++++-------------------- tests/unit/test_package.py | 7 ++----- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/chalice/package.py b/chalice/package.py index e039f23f6..09bc7a98b 100644 --- a/chalice/package.py +++ b/chalice/package.py @@ -27,10 +27,8 @@ def create_app_packager(config): # lambda function is deployed. SAMTemplateGenerator( CFNSwaggerGenerator('{region}', {}), - PreconfiguredPolicyGenerator( - config, - ApplicationPolicyHandler( - osutils, AppPolicyGenerator(osutils)))), + ApplicationPolicyHandler( + osutils, AppPolicyGenerator(osutils))), LambdaDeploymentPackager( osutils=osutils, dependency_builder=DependencyBuilder(osutils), @@ -43,18 +41,6 @@ class UnsupportedFeatureError(Exception): pass -class PreconfiguredPolicyGenerator(object): - def __init__(self, config, policy_gen): - # type: (Config, ApplicationPolicyHandler) -> None - self._config = config - self._policy_gen = policy_gen - - def generate_policy_from_app_source(self): - # type: () -> Dict[str, Any] - return self._policy_gen.generate_policy_from_app_source( - self._config) - - class SAMTemplateGenerator(object): _BASE_TEMPLATE = { 'AWSTemplateFormatVersion': '2010-09-09', @@ -153,7 +139,7 @@ def _generate_serverless_function(self, config, code_uri, handler_string, if not config.manage_iam_role: properties['Role'] = config.iam_role_arn else: - properties['Policies'] = [self._generate_iam_policy()] + properties['Policies'] = [self._generate_iam_policy(config)] return { 'Type': 'AWS::Serverless::Function', 'Properties': properties, @@ -197,9 +183,9 @@ def _generate_rest_api(self, app, api_gateway_stage): 'Properties': properties, } - def _generate_iam_policy(self): - # type: () -> Dict[str, Any] - return self._policy_generator.generate_policy_from_app_source() + def _generate_iam_policy(self, config): + # type: (Config) -> Dict[str, Any] + return self._policy_generator.generate_policy_from_app_source(config) class AppPackager(object): diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 5561b3a6e..c7d880bb9 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -15,7 +15,7 @@ def mock_swagger_generator(): @pytest.fixture def mock_policy_generator(): - return mock.Mock(spec=package.PreconfiguredPolicyGenerator) + return mock.Mock(ApplicationPolicyHandler) def test_can_create_app_packager(): @@ -36,12 +36,9 @@ def test_can_create_app_packager_with_no_autogen(): def test_preconfigured_policy_proxies(): policy_gen = mock.Mock(spec=ApplicationPolicyHandler) config = Config.create(project_dir='project_dir', autogen_policy=False) - generator = package.PreconfiguredPolicyGenerator( - config, policy_gen=policy_gen) policy_gen.generate_policy_from_app_source.return_value = { 'policy': True} - policy = generator.generate_policy_from_app_source() - policy_gen.generate_policy_from_app_source.assert_called_with(config) + policy = policy_gen.generate_policy_from_app_source(config) assert policy == {'policy': True} From 7f21f6159f90c304a9170c682172e892eac54182 Mon Sep 17 00:00:00 2001 From: kyleknap Date: Tue, 24 Oct 2017 23:54:27 -0700 Subject: [PATCH 3/4] Add changelog entry --- CHANGELOG.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 840029af6..a88015838 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,8 @@ Next Release (TBA) * Fix issue deploying some packages in Windows with utf-8 characters (`#560 `__) +* Add support for custom authorizers with ``chalice package`` + (`#580 `__) 1.0.3 From dbbca7bef3c652ddd367fd4d097dbc59e75c7a73 Mon Sep 17 00:00:00 2001 From: kyleknap Date: Tue, 24 Oct 2017 23:56:23 -0700 Subject: [PATCH 4/4] Fix typecheck --- chalice/package.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chalice/package.py b/chalice/package.py index 09bc7a98b..dc7d176d1 100644 --- a/chalice/package.py +++ b/chalice/package.py @@ -69,7 +69,7 @@ class SAMTemplateGenerator(object): } # type: Dict[str, Any] def __init__(self, swagger_generator, policy_generator): - # type: (SwaggerGenerator, PreconfiguredPolicyGenerator) -> None + # type: (SwaggerGenerator, ApplicationPolicyHandler) -> None self._swagger_generator = swagger_generator self._policy_generator = policy_generator