Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for builtin API Gateway authorizers #381

Closed
wants to merge 11 commits into from
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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was the reason for calling it Builtin as opposed to Custom? Custom falls more in-line with what API Gateway uses in its documentation. Or maybe just calling them AuthConfig's?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There'a already a custom authorizer in the chalice code. The nomenclature that made the most sense to be (given what's already there):

  • Custom authorizers - using the custom authorizer functionality in API gateway with a lambda function that you manage outside of chalice. This maps to the pre-existing CustomAuthorizer class.
  • Builtin authorizers - authorizers that are defined in a chalice app that we manage/configure for you. Implementation wise it uses the custom authorizer feature of API gateway but is largely abstracted in chalice.

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