Skip to content

Commit

Permalink
Add support for builtin API Gateway authorizers
Browse files Browse the repository at this point in the history
Chalice currently supports IAM, Cognito, and Custom authorizers.
The custom authorizer allows you to define a lambda function which
contains custom authorization logic specific for your use case.
Previously, you'd have to create your lambda authorizer function out
of band from your chalice app.  We just provided an API to associated
your custom authorizer with certain views.

This change adds support for a "built in authorizer", which allows you
to create custom authorizers that are defined within your chalice app.
When you run "chalice deploy" we'll automatically create and associate
the authorizer for you, but we'll also create the lambda function
associated with the authorizer.

This introduces new concepts in chalice including multi lambda function
support.  We now have an API handler (the existing lambda function) as
well as auth handlers.
  • Loading branch information
jamesls committed Jun 22, 2017
1 parent 9f6705a commit 1e60f9a
Show file tree
Hide file tree
Showing 20 changed files with 1,324 additions and 121 deletions.
3 changes: 2 additions & 1 deletion chalice/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
from chalice.app import (
ChaliceViewError, BadRequestError, UnauthorizedError, ForbiddenError,
NotFoundError, ConflictError, TooManyRequestsError, Response, CORSConfig,
CustomAuthorizer, CognitoUserPoolAuthorizer, IAMAuthorizer
CustomAuthorizer, CognitoUserPoolAuthorizer, IAMAuthorizer,
AuthResponse, AuthRoute
)

__version__ = '0.9.0'
139 changes: 139 additions & 0 deletions chalice/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ def __init__(self, app_name, configure_logs=True):
self.configure_logs = configure_logs
self.log = logging.getLogger(self.app_name)
self._authorizers = {}
self.builtin_auth_handlers = []
if self.configure_logs:
self._configure_logging()

Expand Down Expand Up @@ -461,6 +462,27 @@ def define_authorizer(self, name, header, auth_type, provider_arns=None):
'provider_arns': provider_arns,
}

def authorizer(self, name=None, **kwargs):
def _register_authorizer(auth_func):
auth_name = name
if auth_name is None:
auth_name = auth_func.__name__
ttl_seconds = kwargs.pop('ttl_seconds', None)
execution_role = kwargs.pop('execution_role', None)
if kwargs:
raise TypeError(
'TypeError: authorizer() got unexpected keyword '
'arguments: %s' % ', '.join(list(kwargs)))
auth_config = BuiltinAuthConfig(
name=auth_name,
handler_string='app.%s' % auth_func.__name__,
ttl_seconds=ttl_seconds,
execution_role=execution_role,
)
self.builtin_auth_handlers.append(auth_config)
return ChaliceAuthorizer(name, auth_func, auth_config)
return _register_authorizer

def route(self, path, **kwargs):
def _register_view(view_func):
self._add_route(path, view_func, **kwargs)
Expand Down Expand Up @@ -626,3 +648,120 @@ def _add_cors_headers(self, response, cors):
for name, value in cors.get_access_control_headers().items():
if name not in response.headers:
response.headers[name] = value


class BuiltinAuthConfig(object):
def __init__(self, name, handler_string, ttl_seconds=None,
execution_role=None):
# We'd also support all the misc config options you can set.
self.name = name
self.handler_string = handler_string
self.ttl_seconds = ttl_seconds
self.execution_role = execution_role


class ChaliceAuthorizer(object):
def __init__(self, name, func, config):
self.name = name
self.func = func
self.config = config

def __call__(self, event, content):
auth_request = self._transform_event(event)
result = self.func(auth_request)
if isinstance(result, AuthResponse):
return result.to_dict(auth_request)
return result

def _transform_event(self, event):
return AuthRequest(event['type'],
event['authorizationToken'],
event['methodArn'])


class AuthRequest(object):
def __init__(self, auth_type, token, method_arn):
self.auth_type = auth_type
self.token = token
self.method_arn = method_arn


class AuthResponse(object):
ALL_HTTP_METHODS = ['DELETE', 'HEAD', 'OPTIONS',
'PATCH', 'POST', 'PUT', 'GET']

def __init__(self, routes, principal_id, context=None):
self.routes = routes
self.principal_id = principal_id
# The request is used to generate full qualified ARNs
# that we need for the resource portion of the returned
# policy.
if context is None:
context = {}
self.context = context

def to_dict(self, request):
return {
'context': self.context,
'principalId': self.principal_id,
'policyDocument': self._generate_policy(request),
}

def _generate_policy(self, request):
allowed_resources = self._generate_allowed_resources(request)
return {
'Version': '2012-10-17',
'Statement': [
{
'Action': 'execute-api:Invoke',
'Effect': 'Allow',
'Resource': allowed_resources,
}
]
}

def _generate_allowed_resources(self, request):
allowed_resources = []
for route in self.routes:
if isinstance(route, AuthRoute):
methods = route.methods
path = route.path
else:
# If 'route' is just a string, then they've
# opted not to use the AuthRoute(), so we'll
# generate a policy that allows all HTTP methods.
methods = self.ALL_HTTP_METHODS
path = route
for method in methods:
allowed_resources.append(
self._generate_arn(path, request, method))
return allowed_resources

def _generate_arn(self, route, request, method='*'):
incoming_arn = request.method_arn
parts = incoming_arn.rsplit(':', 1)
# "arn:aws:execute-api:us-west-2:123:rest-api-id/dev/GET/needs/auth"
# Then we pull out the rest-api-id and stage, such that:
# base = ['rest-api-id', 'stage']
base = parts[-1].split('/')[:2]
# Now we add in the path components and rejoin everything
# back together to make a full arn.
# We're also assuming all HTTP methods (via '*') for now.
# To support per HTTP method routes the API will need to be updated.
# We also need to strip off the leading ``/`` so it can be
# '/'.join(...)'d properly.
base.extend([method, route[1:]])
last_arn_segment = '/'.join(base)
if route == '/':
# We have to special case the '/' case. For whatever
# reason, API gateway adds an extra '/' to the method_arn
# of the auth request, so we need to do the same thing.
last_arn_segment += '/'
final_arn = '%s:%s' % (parts[0], last_arn_segment)
return final_arn


class AuthRoute(object):
def __init__(self, path, methods):
self.path = path
self.methods = methods
32 changes: 32 additions & 0 deletions chalice/app.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class TooManyRequestsError(ChaliceViewError): ...


ALL_ERRORS = ... # type: List[ChaliceViewError]
_BUILTIN_AUTH_FUNC = Callable[
[AuthRequest], Union[AuthResponse, Dict[str, Any]]]


class Authorizer:
Expand Down Expand Up @@ -107,6 +109,7 @@ class Chalice(object):
current_request = ... # type: Request
debug = ... # type: bool
authorizers = ... # type: Dict[str, Dict[str, Any]]
builtin_auth_handlers = ... # type: List[BuiltinAuthConfig]

def __init__(self, app_name: str) -> None: ...

Expand All @@ -116,3 +119,32 @@ class Chalice(object):
def _get_view_function_response(self,
view_function: Callable[..., Any],
function_args: List[Any]) -> Response: ...


class ChaliceAuthorizer(object):
name = ... # type: str
func = ... # type: _BUILTIN_AUTH_FUNC
config = ... # type: BuiltinAuthConfig


class BuiltinAuthConfig(object):
name = ... # type: str
handler_string = ... # type: str


class AuthRequest(object):
auth_type = ... # type: str
token = ... # type: str
method_arn = ... # type: str


class AuthRoute(object):
path = ... # type: str
methods = ... # type: List[str]


class AuthResponse(object):
ALL_HTTP_METHODS = ... # type: List[str]
routes = ... # type: Union[str, AuthRoute]
principal_id = ... # type: str
context = ... # type: Optional[Dict[str, str]]
46 changes: 43 additions & 3 deletions chalice/awsclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import shutil
import json
import re
import uuid

import botocore.session # noqa
from botocore.exceptions import ClientError
Expand Down Expand Up @@ -275,7 +276,7 @@ def get_role_arn_for_name(self, name):
try:
role = client.get_role(RoleName=name)
except client.exceptions.NoSuchEntityException:
raise ValueError("No role ARN found for: %s" % name)
raise ResourceDoesNotExistError("No role ARN found for: %s" % name)
return role['Role']['Arn']

def delete_role_policy(self, role_name, policy_name):
Expand Down Expand Up @@ -507,12 +508,14 @@ def get_sdk_download_stream(self, rest_api_id,
return response['body']

def add_permission_for_apigateway(self, function_name, region_name,
account_id, rest_api_id, random_id):
# type: (str, str, str, str, str) -> None
account_id, rest_api_id, random_id=None):
# type: (str, str, str, str, Optional[str]) -> None
"""Authorize API gateway to invoke a lambda function."""
client = self._client('lambda')
source_arn = self._build_source_arn_str(region_name, account_id,
rest_api_id)
if random_id is None:
random_id = self._random_id()
client.add_permission(
Action='lambda:InvokeFunction',
FunctionName=function_name,
Expand Down Expand Up @@ -564,3 +567,40 @@ def _client(self, service_name):
self._client_cache[service_name] = self._session.create_client(
service_name)
return self._client_cache[service_name]

def add_permission_for_authorizer(self, rest_api_id, function_arn,
random_id=None):
# type: (str, str, Optional[str]) -> None
client = self._client('apigateway')
# This is actually a paginated operation, but botocore does not
# support this style of pagination right now. The max authorizers
# for an API is 10, so we're ok for now. We will need to circle
# back on this eventually.
authorizers = client.get_authorizers(restApiId=rest_api_id)
for authorizer in authorizers['items']:
if function_arn in authorizer['authorizerUri']:
authorizer_id = authorizer['id']
break
else:
raise ResourceDoesNotExistError(
"Unable to find authorizer associated "
"with function ARN: %s" % function_arn)
parts = function_arn.split(':')
region_name = parts[3]
account_id = parts[4]
function_name = parts[-1]
source_arn = ("arn:aws:execute-api:%s:%s:%s/authorizers/%s" %
(region_name, account_id, rest_api_id, authorizer_id))
if random_id is None:
random_id = self._random_id()
self._client('lambda').add_permission(
Action='lambda:InvokeFunction',
FunctionName=function_name,
StatementId=random_id,
Principal='apigateway.amazonaws.com',
SourceArn=source_arn,
)

def _random_id(self):
# type: () -> str
return str(uuid.uuid4())
6 changes: 4 additions & 2 deletions chalice/cli/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,10 @@ def create_config_obj(self, chalice_stage_name=DEFAULT_STAGE_NAME,
user_provided_params['profile'] = self.profile
if api_gateway_stage is not None:
user_provided_params['api_gateway_stage'] = api_gateway_stage
config = Config(chalice_stage_name, user_provided_params,
config_from_disk, default_params)
config = Config(chalice_stage=chalice_stage_name,
user_provided_params=user_provided_params,
config_from_disk=config_from_disk,
default_params=default_params)
return config

def _validate_config_from_disk(self, config):
Expand Down
Loading

0 comments on commit 1e60f9a

Please sign in to comment.