Skip to content

Commit

Permalink
Add a bunch of tests and fixed the matcher
Browse files Browse the repository at this point in the history
  • Loading branch information
stealthycoin committed Aug 23, 2017
1 parent 67435b9 commit bd86e3b
Show file tree
Hide file tree
Showing 2 changed files with 246 additions and 48 deletions.
124 changes: 83 additions & 41 deletions chalice/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
from chalice.app import CORSConfig # noqa
from chalice.app import ChaliceAuthorizer # noqa
from chalice.app import RouteEntry # noqa
from chalice.app import Request # noqa
from chalice.app import AuthResponse # noqa
from chalice.app import BuiltinAuthConfig # noqa
from chalice.config import Config # noqa

from chalice.compat import urlparse, parse_qs


Expand All @@ -46,6 +48,57 @@ def create_local_server(app_obj, config, port):
return LocalDevServer(app_obj, config, port)


class LocalArnBuilder(object):
ARN_FORMAT = ('arn:aws:execute-api:{region}:{account_id}'
':{api_id}/{stage}/{method}/{resource_path}')
LOCAL_REGION = 'mars-west-1'
LOCAL_ACCOUNT_ID = '123456789012'
LOCAL_API_ID = 'ymy8tbxw7b'
LOCAL_STAGE = 'api'

def build_arn(self, method, path):
# type: (str, str) -> str
# In API Gateway the method and URI are separated by a / so typically
# the uri portion omits the leading /. In the case where the entire
# url is just '/' API Gateway adds a / to the end so that the arn end
# with a '//'.
if path != '/':
path = path[1:]
return self.ARN_FORMAT.format(
region=self.LOCAL_REGION,
account_id=self.LOCAL_ACCOUNT_ID,
api_id=self.LOCAL_API_ID,
stage=self.LOCAL_STAGE,
method=method,
resource_path=path
)


class ArnMatcher(object):
def __init__(self, target_arn):
# type: (str) -> None
self._arn = target_arn

def _resource_match(self, resource):
# type: (str) -> bool
# Arn matching supports two special case characetrs that are not
# escapable. * represents a glob which translates to a non-greedy
# match of any number of characters. ? which is any single character.
# These are easy to translate to a regex using .*? and . respectivly.
escaped_resource = re.escape(resource)
resource_regex = escaped_resource.replace(r'\?', '.').replace(
r'\*', '.*?')
matcher = re.compile(resource_regex)
return matcher.match(self._arn) is not None

def does_any_resource_match(self, resources):
# type: (List[str]) -> bool
for resource in resources:
if self._resource_match(resource):
return True
return False


class RouteMatcher(object):
def __init__(self, route_urls):
# type: (List[str]) -> None
Expand Down Expand Up @@ -149,12 +202,10 @@ def __init__(self, function_name, memory_size,
self._time_source = time_source
self._start_time = self._current_time_millis()
self._max_runtime = runtime_millis
self._initialize_lambda_context_attributes(
function_name, memory_size)

def _initialize_lambda_context_attributes(self, function_name,
memory_size):
# type: (str, int) -> None
# Below are properties that are found on the real LambdaContext passed
# by lambda and their associated documentation.

# Name of the Lambda function that is executing.
self.function_name = function_name

Expand Down Expand Up @@ -216,10 +267,14 @@ class LocalGatewayAuthorizer(object):
def __init__(self, app_object):
# type: (Chalice) -> None
self._app_object = app_object
self._arn_builder = LocalArnBuilder()

def authorize(self, lambda_event, lambda_context):
# type: (EventType, LambdaContext) -> Tuple[EventType, LambdaContext]
auth_event = self._prepare_authorizer_event(lambda_event)
method = lambda_event['requestContext']['httpMethod']
path = lambda_event['requestContext']['resourcePath']
arn = self._arn_builder.build_arn(method, path)
auth_event = self._prepare_authorizer_event(arn, lambda_event)
route_entry = self._route_for_event(lambda_event)
if not route_entry:
return lambda_event, lambda_context
Expand All @@ -230,16 +285,15 @@ def authorize(self, lambda_event, lambda_context):
# Currently the only supported local authorizer is the
# BuiltinAuthConfig type. Anything else we will err on the side of
# allowing local testing by simply admiting the request. Otherwise
# there is no way for users to test their code in local mode
warnings.warn((
# there is no way for users to test their code in local mode.
warnings.warn(
'%s is not a supported in local mode. All requests made '
'against a route will be authorized to allow local testing.')
'against a route will be authorized to allow local testing.'
% authorizer.__class__.__name__
)
return lambda_event, lambda_context
auth_result = authorizer(auth_event, lambda_context)
authed = self._check_can_invoke_view_function(
route_entry, auth_result)
authed = self._check_can_invoke_view_function(arn, auth_result)
if authed:
lambda_event = self._update_lambda_event(lambda_event, auth_result)
else:
Expand All @@ -248,37 +302,19 @@ def authorize(self, lambda_event, lambda_context):
'x-amzn-ErrorType': 'UnauthorizedException'})
return lambda_event, lambda_context

def _check_can_invoke_view_function(self, route_entry, auth_result):
# type: (RouteEntry, ResponseType) -> bool
def _check_can_invoke_view_function(self, arn, auth_result):
# type: (str, ResponseType) -> bool
policy = auth_result.get('policyDocument', {})
statements = policy.get('Statement', [])
# Look for a statement that allows us to invoke the route_entry
# it will be in the form local:local/METHOD/uri the URI is always
# prefixed with / so we do not need to explicitly include it in our
# pattern.
# The method can be either an explicit method such as POST, GET, etc...
# or it can be a *. We also need to match for any prefix since the
# user can return any policy at all from the custom authorizer.
pattern_suffix = ''
if route_entry.uri_pattern == '/':
# There is a special case when the / is used since the API Gateway
# arn will have the / duplicated. On all other routes it will
# omit the leading /.
pattern_suffix = '/'
method_pattern = r'(?:\*|%s)' % route_entry.method
route_entry_pattern = r'^.*?/%s%s%s$' % (method_pattern,
route_entry.uri_pattern,
pattern_suffix)
route_entry_re = re.compile(route_entry_pattern)
# Look through all statements for one that allows us to invoke this
# particular view function.
allow_resource_statements = []
for statement in statements:
if statement.get('Effect') == 'Allow' and \
statement.get('Action') == 'execute-api:Invoke':
for resource in statement.get('Resource'):
if route_entry_re.match(resource):
return True
return False
allow_resource_statements.append(resource)

arn_matcher = ArnMatcher(arn)
return arn_matcher.does_any_resource_match(allow_resource_statements)

def _route_for_event(self, lambda_event):
# type: (EventType) -> Optional[RouteEntry]
Expand Down Expand Up @@ -306,14 +342,14 @@ def _update_lambda_event(self, lambda_event, auth_result):
lambda_event['requestContext']['authorizer'] = auth_context
return lambda_event

def _prepare_authorizer_event(self, lambda_event):
# type: (EventType) -> EventType
def _prepare_authorizer_event(self, arn, lambda_event):
# type: (str, EventType) -> EventType
"""Translate event for an authorizer input."""
authorizer_event = lambda_event.copy()
authorizer_event['type'] = 'TOKEN'
authorizer_event['authorizationToken'] = authorizer_event.get(
'headers', {}).get('authorization', '')
authorizer_event['methodArn'] = 'local'
authorizer_event['methodArn'] = arn
return authorizer_event


Expand Down Expand Up @@ -352,7 +388,14 @@ def _has_user_defined_options_method(self, lambda_event):
def handle_request(self, method, path, headers, body):
# type: (str, str, HeaderType, str) -> ResponseType
lambda_context = self._generate_lambda_context()
lambda_event = self._generate_lambda_event(method, path, headers, body)
try:
lambda_event = self._generate_lambda_event(
method, path, headers, body)
except ValueError:
raise NotAuthorizedError(
{'x-amzn-RequestId': lambda_context.aws_request_id,
'x-amzn-ErrorType': 'UnauthorizedException'})

# This can either be because the user's provided an OPTIONS method
# *or* this is a preflight request, which chalice automatically
# responds to without invoking a user defined route.
Expand Down Expand Up @@ -389,7 +432,6 @@ def _autogen_options_headers(self, lambda_event):
# So our local version needs to add this manually to our set of allowed
# headers.
route_methods.append('OPTIONS')
route_methods = route_methods

# The Access-Control-Allow-Methods header is not added by the
# CORSConfig object it is added to the API Gateway route during
Expand Down
Loading

0 comments on commit bd86e3b

Please sign in to comment.