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 local custom authorizers #467

Merged
merged 1 commit into from
Sep 5, 2017

Conversation

stealthycoin
Copy link
Contributor

@stealthycoin stealthycoin commented Aug 10, 2017

BuiltinAuthorizer will now work in local mode. Authorizer types that
cannot be run locally will now simply allow the requests to proceed as
if there were no authorizer to allow for local testing.

closes #404

@codecov-io
Copy link

codecov-io commented Aug 10, 2017

Codecov Report

Merging #467 into master will increase coverage by 0.25%.
The diff coverage is 98.65%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #467      +/-   ##
==========================================
+ Coverage   94.01%   94.26%   +0.25%     
==========================================
  Files          18       18              
  Lines        2939     3103     +164     
  Branches      380      396      +16     
==========================================
+ Hits         2763     2925     +162     
  Misses        129      129              
- Partials       47       49       +2
Impacted Files Coverage Δ
chalice/cli/__init__.py 81.86% <100%> (ø) ⬆️
chalice/cli/factory.py 89.52% <50%> (ø) ⬆️
chalice/local.py 98.26% <99.09%> (+0.66%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 07968a6...7305052. Read the comment docs.

Copy link
Member

@JordonPhillips JordonPhillips left a comment

Choose a reason for hiding this comment

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

Code looks good! Only had a few minor comments.

def create_local_server(self, app_obj, port):
# type: (Chalice, int) -> local.LocalDevServer
return local.create_local_server(app_obj, port)
def create_local_server(self, app_obj, config, port):
Copy link
Member

Choose a reason for hiding this comment

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

You don't appear to be testing this function

chalice/local.py Outdated
self._initialize_lambda_context_attributes(
function_name, memory_size)

def _initialize_lambda_context_attributes(self, function_name,
Copy link
Member

Choose a reason for hiding this comment

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

It's a bit bizzare to define instance attributes outside of init, especially since you're not doing any complicated initialization or logic to set their initial values.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I separated the logic that is specific to the actual lambda context initialization vs the initializations I have to do for my fake object to run.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah I'm surprised pylint didn't yell at you for this. Even if you want it extracted out to a separate function, I think they should also be "declared" in the __init__.

chalice/local.py Outdated
def _prepare_authorizer_event(self, lambda_event):
# type: (EventType) -> EventType
"""Translate event for an authorizer input."""
authorizer_event = lambda_event.copy()
Copy link
Member

Choose a reason for hiding this comment

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

How nested is this structure? if it's more than one level then maybe you should deepcopy.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The event is a dict of entirely [str, Union[str, Dict[str, str]] so I don't think it needs to be deepcopied unless I am mistaken about something.

chalice/local.py Outdated
try:
route_entry = self._app_object.routes[resource_path][http_method]
except KeyError:
# If there a key error is raised when trying to get the route entry
Copy link
Member

Choose a reason for hiding this comment

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

s/If there/If/

chalice/local.py Outdated
# here by returning no authorizer to avoid duplicating the logic.
return None, None
authorizer = route_entry.authorizer
return authorizer, route_entry
Copy link
Member

Choose a reason for hiding this comment

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

If authorizer is an attribute of route_entry why are you not just returning route_entry?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes I think this made more sense before a refactor and I didn't think to update it.

chalice/local.py Outdated
# Technically we only need to check for authorizer being `None` here
# since the route_entry will always match authorizer but mypy needs us
# to explicitly check both before we can use them. In any event, if
# either of these are not defined then there was no authroizer or
Copy link
Member

Choose a reason for hiding this comment

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

You seem to have trailed off here.

return response


class ChaliceRequestHandler(BaseHTTPRequestHandler):
Copy link
Member

Choose a reason for hiding this comment

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

A class doc string for this would be nice

Copy link
Member

@jamesls jamesls left a comment

Choose a reason for hiding this comment

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

Some initial feedback, still going through it.

chalice/local.py Outdated
self._initialize_lambda_context_attributes(
function_name, memory_size)

def _initialize_lambda_context_attributes(self, function_name,
Copy link
Member

Choose a reason for hiding this comment

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

Yeah I'm surprised pylint didn't yell at you for this. Even if you want it extracted out to a separate function, I think they should also be "declared" in the __init__.

chalice/local.py Outdated
# automatically generate our CORS headers.
options_headers = self._autogen_options_headers(lambda_event)
raise NoOptionsRouteDefined(options_headers)
# The authroizer call will be a noop if there is no authroizer method
Copy link
Member

Choose a reason for hiding this comment

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

%s/authroizer/authorizer/g

chalice/local.py Outdated
# 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((
Copy link
Member

Choose a reason for hiding this comment

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

You shouldn't need the extra set of parens here.

chalice/local.py Outdated
# particular view function.
for statement in statements:
if statement.get('Effect') == 'Allow' and \
statement.get('Action') == 'execute-api:Invoke':
Copy link
Member

Choose a reason for hiding this comment

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

These technically support wildcards as well. If you're not going to support them, you should probably make a note about eventually supporting them.

chalice/local.py Outdated
pattern_suffix = '/'
method_pattern = r'(?:\*|%s)' % route_entry.method
route_entry_pattern = r'^.*?/%s%s%s$' % (method_pattern,
route_entry.uri_pattern,
Copy link
Member

Choose a reason for hiding this comment

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

I don't think this is right. The uri pattern will include the placeholder values so something like this won't work:

@app.route('/foo/{name}', authorizer=demo_auth)
def index(name):
    return {'context': app.current_request.context}

If I return AuthResponse(routes=['/foo/bar'], principal_id='user') from my authorizer.

What if we mirrored things more closely to real IAM?

  1. Generate the resource ARN for the specific method/view
  2. Check if any of the resources in the returned policy match the ARN (accounting for wildcards).

I also would suggest extracting this policy matching out to a separate class so we can add a lot more tests for the edge cases (using pytest.paramtrize).

@stealthycoin stealthycoin force-pushed the local-custom-auth branch 2 times, most recently from bd86e3b to a47eb8c Compare August 23, 2017 17:04
Copy link
Member

@JordonPhillips JordonPhillips left a comment

Choose a reason for hiding this comment

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

Implementation looks good! Just one little nitpick before I go over the tests.

chalice/local.py Outdated
escaped_resource = re.escape(resource)
resource_regex = escaped_resource.replace(r'\?', '.').replace(
r'\*', '.*?')
matcher = re.compile(resource_regex)
Copy link
Member

Choose a reason for hiding this comment

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

nitpick, but you don't need to compile if you're just immediately using it anyway

Copy link
Member

@jamesls jamesls left a comment

Choose a reason for hiding this comment

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

Just had some minor feedback.

@@ -1,3 +1,4 @@
from chalice.local import LambdaContext
Copy link
Member

Choose a reason for hiding this comment

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

Import orders, chalice after 3rd party libs.

chalice/local.py Outdated
return LocalDevServer(app_obj, config, port)


class LocalArnBuilder(object):
Copy link
Member

Choose a reason for hiding this comment

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

s/Arn/ARN

chalice/local.py Outdated
)


class ArnMatcher(object):
Copy link
Member

Choose a reason for hiding this comment

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

s/Arn/ARN

chalice/local.py Outdated
% authorizer.__class__.__name__
)
return lambda_event, lambda_context
auth_result = authorizer(auth_event, lambda_context)
Copy link
Member

Choose a reason for hiding this comment

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

I don't think this is quite right. If you don't have the appropriate auth header (Authorization) in the request, you get a 401 and the authorizer isn't called at all.

chalice/local.py Outdated

def _send_not_authorized_response(self, headers):
# type: (HeaderType) -> None
self.send_response(401)
Copy link
Member

Choose a reason for hiding this comment

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

An authorized error where we generate a policy that forbids access to a resource gives a 403, not a 401.

$ http https://rest-api-id.execute-api.us-west-2.amazonaws.com/api/ 'Authorization: deny'
HTTP/1.1 403 Forbidden
Connection: keep-alive
Content-Length: 60
Content-Type: application/json
Date: Thu, 24 Aug 2017 00:27:57 GMT
Via: 1.1 7f3f42df8af148df1f9f1ee7175987ad.cloudfront.net (CloudFront)
X-Amz-Cf-Id: 4ZhC3GokHeXr68EKakpF6xQWxVvTDZtQiGK253psOcWbPW3M980rGw==
X-Cache: Error from cloudfront
x-amzn-ErrorType: AccessDeniedException
x-amzn-RequestId: 13ff2f6d-8863-11e7-af78-e1cc9bcdaa69

{
    "Message": "User is not authorized to access this resource"
}

chalice/local.py Outdated
time_source = Clock()
self._time_source = time_source
self._start_time = self._current_time_millis()
self._max_runtime = runtime_millis
Copy link
Member

Choose a reason for hiding this comment

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

runtime_millis is pretty vague, but max_runtime_ms is a lot clearer. Is there a reason to not map to call the arg something different than it's instance attribute name?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wanted to give the unit in the name is why its not a direct mirror. I agree with max_runtime_ms so I'll do that.

assert dev_server.server.server_port == 23456


class FakeTimeSource(object):
Copy link
Member

Choose a reason for hiding this comment

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

We usually put all the non test classes/functions at the top of the file before the actual tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was keeping it nearby for easier reference. I'll move it.

event, context = authorizer.authorize(event, context)
assert event['requestContext']['authorizer']['principalId'] == 'user'

def test_can_authorize_with_lower_case_headers(self, lambda_context_args,
Copy link
Member

Choose a reason for hiding this comment

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

Isn't this identical to the previous test test_can_authorize_empty_path?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yea...

event, context = authorizer.authorize(event, context)
# Assert that when the authorizer.authorize is called and there is no
# authorizer defined for a particular route that it is a noop.
assert event == event
Copy link
Member

Choose a reason for hiding this comment

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

?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed... still passes.

# Assert that when the authorizer.authorize is called and there is no
# authorizer defined for a particular route that it is a noop.
assert event == event
assert context == context
Copy link
Member

Choose a reason for hiding this comment

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

?

@stealthycoin stealthycoin force-pushed the local-custom-auth branch 4 times, most recently from e006290 to 3712b8b Compare August 28, 2017 20:21
@jamesls
Copy link
Member

jamesls commented Aug 30, 2017

I'm just going through this now, but it looks like there's still some feedback that hasn't been incorporated: #467 (comment)

The ARN generated for a route that has a captured param is still: 'arn:aws:execute-api:mars-west-1:123456789012:ymy8tbxw7b/api/GET/foo/{name}' which isn't right. I tested the example in the linked comment and it still fails in local mode but passes on actual API gateway/lambda.

@stealthycoin stealthycoin force-pushed the local-custom-auth branch 2 times, most recently from 0e58247 to 2030304 Compare August 31, 2017 06:49
@stealthycoin stealthycoin force-pushed the local-custom-auth branch 5 times, most recently from 517261f to 8403c5e Compare September 5, 2017 07:31
BuiltinAuthorizer will now work in local mode. Authorizer types that
cannot be run locally will now simply allow the requests to proceed as
if there were no authorizer to allow for local testing.
@stealthycoin stealthycoin merged commit c09ed07 into aws:master Sep 5, 2017
@stealthycoin stealthycoin deleted the local-custom-auth branch September 5, 2017 07:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Local mode authorizers
4 participants