diff --git a/README.md b/README.md index 230d913..e214d30 100755 --- a/README.md +++ b/README.md @@ -243,6 +243,58 @@ result = lambda_handler(event=input_event) assert result == {"body": '{"path": "bar/baz"}', "statusCode": 200, "headers":{}} ``` +### Scopes + +If you're using a Lambda authorizer, you can pass authorization scopes as input into your Lambda function. + +This is useful when using the API Gateway with a Lambda authorizer and have the Lambda authorizer return in a scopes json object the permissions (scopes) the caller has access to. In your Lambda function you can specify what scopes the caller should have to call that function. If the requested scope was not provided by the Lambda authorizer, a 403 error code is given. + +The API gateway has the limitation it can only pass primitive data types from a Lambda authorizer function. The scopes list therefore needs to be json encoded by the authorizer function. + +To use this, add a scopes attribute to the handler with the list of scopes your function requires. They will be verified from the requestContext.authorizer.scopes attribute from the Lambda authorizer. + +```python +from lambdarest import lambda_handler + +@lambda_handler.handle("get", path="/private1", scopes=["myresource.read"]) +def my_own_get(event): + return {"this": "will be json dumped"} + +##### TEST ##### + +input_event = { + "body": '{}', + "httpMethod": "GET", + "resource": "/private1", + "requestContext": { + "authorizer": { + "scopes": '["myresource.read"]' + } + } +} +result = lambda_handler(event=input_event) +assert result == {"body": '{"this": "will be json dumped"}', "statusCode": 200, "headers":{}} +``` +When no scopes are provided by the authorizer but are still requested by your function, a permission denied error is returned. +```python +from lambdarest import lambda_handler + +@lambda_handler.handle("get", path="/private2", scopes=["myresource.read"]) +def my_own_get(event): + return {"this": "will be json dumped"} + +##### TEST ##### + +input_event = { + "body": '{}', + "httpMethod": "GET", + "resource": "/private2" +} +result = lambda_handler(event=input_event) +print(result) +assert result == {"body": "Permission denied", "statusCode": 403, "headers":{}} +``` + ## Use it with AWS Application Load Balancer In order to use it with Application Load Balancer you need to create your own lambda_handler and not use the singleton: diff --git a/lambdarest/__init__.py b/lambdarest/__init__.py index 459f671..e95ddfc 100755 --- a/lambdarest/__init__.py +++ b/lambdarest/__init__.py @@ -64,6 +64,10 @@ def to_json(self, encoder=json.JSONEncoder, application_load_balancer=False): return response +class ScopeMissing(Exception): + pass + + def __float_cast(value): try: return float(value) @@ -261,6 +265,13 @@ def inner_lambda_handler(event, context=None): ) error_tuple = ("Validation Error", 400) + except ScopeMissing as error: + error_description = "Permission denied" + logging.warning( + logging_message.format(status_code=403, message=error_description) + ) + error_tuple = (error_description, 403) + except Exception as error: if error_handler: error_handler(error, method_name) @@ -272,7 +283,7 @@ def inner_lambda_handler(event, context=None): application_load_balancer=application_load_balancer ) - def inner_handler(method_name, path="/", schema=None, load_json=True): + def inner_handler(method_name, path="/", schema=None, load_json=True, scopes=None): if schema and not load_json: raise ValueError("if schema is supplied, load_json needs to be true") @@ -288,6 +299,21 @@ def inner(event, *args, **kwargs): if schema: # jsonschema.validate using given schema validate(json_data, schema, **__validate_kwargs) + + try: + provided_scopes = json.loads( + event["requestContext"]["authorizer"]["scopes"] + ) + except KeyError: + provided_scopes = [] + except json.decoder.JSONDecodeError: + # Ignore passed scopes if it isn't properly json encoded + provided_scopes = [] + + for scope in scopes or []: + if scope not in provided_scopes: + raise ScopeMissing("Scope: '{}' is missing".format(scope)) + return func(event, *args, **kwargs) # if this is a catch all url, make sure that it's setup correctly diff --git a/tests/test_lambdarest.py b/tests/test_lambdarest.py index 2264227..91501d7 100755 --- a/tests/test_lambdarest.py +++ b/tests/test_lambdarest.py @@ -460,3 +460,289 @@ def test_that_json_encoding_body_list(self): self.assertEqual(json.loads(result["body"]), [{"foo": "bar"}]) self.assertEqual(result["headers"], {}) self.assertEqual(result["statusCode"], 200) + + # test matrix for scopes + # Scopes requested + # none | single | multiple + # Scopes provided: + # missing | | A + # none B | | + # single | C | + # multiple D | | E + # invalid F | G | + + def test_scopes_A(self): + post_mock = mock.Mock(return_value=[{"foo": "bar"}]) + self.lambda_handler.handle( + "post", scopes=["resource.method1", "resource3.method2"] + )( + post_mock + ) # decorate mock + result = self.lambda_handler(self.event, self.context) + self.assertEqual( + result, {"body": "Permission denied", "statusCode": 403, "headers": {}} + ) + + def test_scopes_B(self): + event_with_empty_scopes = { + "resource": "/", + "httpMethod": "POST", + "headers": None, + "queryStringParameters": None, + "pathParameters": None, + "stageVariables": None, + "requestContext": { + "accountId": "1234123542134", + "authorizer": {"scopes": "[]"}, + "resourceId": "erd49w", + "stage": "test-invoke-stage", + "requestId": "test-invoke-request", + "identity": { + "cognitoIdentityPoolId": None, + "accountId": "23424534543", + "cognitoIdentityId": None, + "caller": "asdfasdfasfdasfdas", + "apiKey": "asdfasdfasdfas", + "sourceIp": "127.0.0.1", + "accessKey": "asdfasdfasdfasfd", + "cognitoAuthenticationType": None, + "cognitoAuthenticationProvider": None, + "userArn": "arn:aws:iam::123214323", + "userAgent": "Apache-HttpClient/4.5.x (Java/1.8.0_102)", + "user": "asdfsadsfads", + }, + "resourcePath": "/test", + "httpMethod": "POST", + "apiId": "90o718c6bk", + }, + "body": None, + "isBase64Encoded": False, + } + post_mock = mock.Mock(return_value=[{"foo": "bar"}]) + self.lambda_handler.handle( + "post", scopes=["resource.method1", "resource3.method2"] + )( + post_mock + ) # decorate mock + result = self.lambda_handler(event_with_empty_scopes, self.context) + self.assertEqual( + result, {"body": "Permission denied", "statusCode": 403, "headers": {}} + ) + + def test_scopes_C(self): + event_with_single_scope = { + "resource": "/", + "httpMethod": "POST", + "headers": None, + "queryStringParameters": None, + "pathParameters": None, + "stageVariables": None, + "requestContext": { + "accountId": "1234123542134", + "authorizer": {"scopes": '["resource1.method2"]'}, + "resourceId": "erd49w", + "stage": "test-invoke-stage", + "requestId": "test-invoke-request", + "identity": { + "cognitoIdentityPoolId": None, + "accountId": "23424534543", + "cognitoIdentityId": None, + "caller": "asdfasdfasfdasfdas", + "apiKey": "asdfasdfasdfas", + "sourceIp": "127.0.0.1", + "accessKey": "asdfasdfasdfasfd", + "cognitoAuthenticationType": None, + "cognitoAuthenticationProvider": None, + "userArn": "arn:aws:iam::123214323", + "userAgent": "Apache-HttpClient/4.5.x (Java/1.8.0_102)", + "user": "asdfsadsfads", + }, + "resourcePath": "/test", + "httpMethod": "POST", + "apiId": "90o718c6bk", + }, + "body": None, + "isBase64Encoded": False, + } + post_mock = mock.Mock(return_value=[{"foo": "bar"}]) + self.lambda_handler.handle("post", scopes=["resource1.method2"])( + post_mock + ) # decorate mock + result = self.lambda_handler(event_with_single_scope, self.context) + self.assertEqual( + result, {"body": '[{"foo": "bar"}]', "statusCode": 200, "headers": {}} + ) + + def test_scopes_D(self): + event_with_multiple_scopes = { + "resource": "/", + "httpMethod": "POST", + "headers": None, + "queryStringParameters": None, + "pathParameters": None, + "stageVariables": None, + "requestContext": { + "accountId": "1234123542134", + "authorizer": {"scopes": '["resource1.method2", "resouce2.method3"]'}, + "resourceId": "erd49w", + "stage": "test-invoke-stage", + "requestId": "test-invoke-request", + "identity": { + "cognitoIdentityPoolId": None, + "accountId": "23424534543", + "cognitoIdentityId": None, + "caller": "asdfasdfasfdasfdas", + "apiKey": "asdfasdfasdfas", + "sourceIp": "127.0.0.1", + "accessKey": "asdfasdfasdfasfd", + "cognitoAuthenticationType": None, + "cognitoAuthenticationProvider": None, + "userArn": "arn:aws:iam::123214323", + "userAgent": "Apache-HttpClient/4.5.x (Java/1.8.0_102)", + "user": "asdfsadsfads", + }, + "resourcePath": "/test", + "httpMethod": "POST", + "apiId": "90o718c6bk", + }, + "body": None, + "isBase64Encoded": False, + } + post_mock = mock.Mock(return_value=[{"foo": "bar"}]) + self.lambda_handler.handle("post", scopes=[])(post_mock) # decorate mock + result = self.lambda_handler(event_with_multiple_scopes, self.context) + self.assertEqual( + result, {"body": '[{"foo": "bar"}]', "statusCode": 200, "headers": {}} + ) + + def test_scopes_E(self): + event_with_multiple_scopes = { + "resource": "/", + "httpMethod": "POST", + "headers": None, + "queryStringParameters": None, + "pathParameters": None, + "stageVariables": None, + "requestContext": { + "accountId": "1234123542134", + "authorizer": {"scopes": '["resource1.method2", "resouce2.method3"]'}, + "resourceId": "erd49w", + "stage": "test-invoke-stage", + "requestId": "test-invoke-request", + "identity": { + "cognitoIdentityPoolId": None, + "accountId": "23424534543", + "cognitoIdentityId": None, + "caller": "asdfasdfasfdasfdas", + "apiKey": "asdfasdfasdfas", + "sourceIp": "127.0.0.1", + "accessKey": "asdfasdfasdfasfd", + "cognitoAuthenticationType": None, + "cognitoAuthenticationProvider": None, + "userArn": "arn:aws:iam::123214323", + "userAgent": "Apache-HttpClient/4.5.x (Java/1.8.0_102)", + "user": "asdfsadsfads", + }, + "resourcePath": "/test", + "httpMethod": "POST", + "apiId": "90o718c6bk", + }, + "body": None, + "isBase64Encoded": False, + } + post_mock = mock.Mock(return_value=[{"foo": "bar"}]) + self.lambda_handler.handle( + "post", scopes=["resouce2.method3", "resouce3.method1"] + )( + post_mock + ) # decorate mock + result = self.lambda_handler(event_with_multiple_scopes, self.context) + self.assertEqual( + result, {"body": "Permission denied", "statusCode": 403, "headers": {}} + ) + + def test_scopes_F(self): + event_with_invalid_scopes = { + "resource": "/", + "httpMethod": "POST", + "headers": None, + "queryStringParameters": None, + "pathParameters": None, + "stageVariables": None, + "requestContext": { + "accountId": "1234123542134", + "authorizer": {"scopes": "{[invalid json}"}, + "resourceId": "erd49w", + "stage": "test-invoke-stage", + "requestId": "test-invoke-request", + "identity": { + "cognitoIdentityPoolId": None, + "accountId": "23424534543", + "cognitoIdentityId": None, + "caller": "asdfasdfasfdasfdas", + "apiKey": "asdfasdfasdfas", + "sourceIp": "127.0.0.1", + "accessKey": "asdfasdfasdfasfd", + "cognitoAuthenticationType": None, + "cognitoAuthenticationProvider": None, + "userArn": "arn:aws:iam::123214323", + "userAgent": "Apache-HttpClient/4.5.x (Java/1.8.0_102)", + "user": "asdfsadsfads", + }, + "resourcePath": "/test", + "httpMethod": "POST", + "apiId": "90o718c6bk", + }, + "body": None, + "isBase64Encoded": False, + } + post_mock = mock.Mock(return_value=[{"foo": "bar"}]) + self.lambda_handler.handle("post", scopes=[])(post_mock) # decorate mock + result = self.lambda_handler(event_with_invalid_scopes, self.context) + self.assertEqual( + result, {"body": '[{"foo": "bar"}]', "statusCode": 200, "headers": {}} + ) + + def test_scopes_G(self): + event_with_invalid_scopes = { + "resource": "/", + "httpMethod": "POST", + "headers": None, + "queryStringParameters": None, + "pathParameters": None, + "stageVariables": None, + "requestContext": { + "accountId": "1234123542134", + "authorizer": {"scopes": "{[invalid json}"}, + "resourceId": "erd49w", + "stage": "test-invoke-stage", + "requestId": "test-invoke-request", + "identity": { + "cognitoIdentityPoolId": None, + "accountId": "23424534543", + "cognitoIdentityId": None, + "caller": "asdfasdfasfdasfdas", + "apiKey": "asdfasdfasdfas", + "sourceIp": "127.0.0.1", + "accessKey": "asdfasdfasdfasfd", + "cognitoAuthenticationType": None, + "cognitoAuthenticationProvider": None, + "userArn": "arn:aws:iam::123214323", + "userAgent": "Apache-HttpClient/4.5.x (Java/1.8.0_102)", + "user": "asdfsadsfads", + }, + "resourcePath": "/test", + "httpMethod": "POST", + "apiId": "90o718c6bk", + }, + "body": None, + "isBase64Encoded": False, + } + post_mock = mock.Mock(return_value=[{"foo": "bar"}]) + self.lambda_handler.handle("post", scopes=["resource1.method2"])( + post_mock + ) # decorate mock + result = self.lambda_handler(event_with_invalid_scopes, self.context) + self.assertEqual( + result, {"body": "Permission denied", "statusCode": 403, "headers": {}} + )