Repository: aws/chalice

Issue:
Support requiring OAuth scopes for routes
When using the `CognitoUserPoolAuthorizer` authorizer, there doesn't seem to be any way to require specific OAuth scopes for routes, as is possible through the API Gateway console.  This would be a useful feature to add.  One possible API would be to add a `scopes` keyword argument to `Chalice.route()`, which allows specifying a list of OAuth scopes that are required for that endpoint.

```python
authorizer = CognitoUserPoolAuthorizer('MyAuth', ['pool_arn'])

@app.route('/admin_stuff', authorizer=authorizer, scopes=['myapp/admin'])
def admin_stuff():
    do_some_privileged_task()

@app.route('/normal_stuff', authorizer=authorizer, scopes=['myapp/user'])
def normal_stuff():
    do_normal_stuff()
```

The would result in the outputted swagger having security sections like:

```yaml
paths:
  /admin_stuff:
    get:
      ...
      security:
      - MyAuth: ['myapp/admin']
  /normal_stuff:
    get:
      ...
      security:
      - MyAuth: ['myapp/user']
```

Currently, from what I can tell from looking at the source, (I believe this is the relevant code in [swagger.py](https://github.com/aws/chalice/blob/master/chalice/deploy/swagger.py#L152)), it seems to be hardcoded to always emit an empty scopes list for the security section.

Discussion:

Patch:
diff --git a/.changes/next-release/16785208095-bugfix-package-42232.json b/.changes/next-release/16785208095-bugfix-package-42232.json
deleted file mode 100644
index fedb18a..0000000
--- a/.changes/next-release/16785208095-bugfix-package-42232.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-  "type": "bugfix",
-  "category": "package",
-  "description": "Traverse symlinks to directories when packaging the vendor directory (#583)."
-}
diff --git a/chalice/app.py b/chalice/app.py
index c73924f..d7e2731 100644
--- a/chalice/app.py
+++ b/chalice/app.py
@@ -8,6 +8,7 @@ import json
 import traceback
 import decimal
 import base64
+import copy
 from collections import defaultdict
 
 
@@ -221,10 +222,14 @@ class CaseInsensitiveMapping(Mapping):
 
 class Authorizer(object):
     name = ''
+    scopes = []
 
     def to_swagger(self):
         raise NotImplementedError("to_swagger")
 
+    def with_scopes(self, scopes):
+        raise NotImplementedError("with_scopes")
+
 
 class IAMAuthorizer(Authorizer):
 
@@ -232,6 +237,7 @@ class IAMAuthorizer(Authorizer):
 
     def __init__(self):
         self.name = 'sigv4'
+        self.scopes = []
 
     def to_swagger(self):
         return {
@@ -241,12 +247,16 @@ class IAMAuthorizer(Authorizer):
             'x-amazon-apigateway-authtype': 'awsSigv4',
         }
 
+    def with_scopes(self, scopes):
+        raise NotImplementedError("with_scopes")
+
 
 class CognitoUserPoolAuthorizer(Authorizer):
 
     _AUTH_TYPE = 'cognito_user_pools'
 
-    def __init__(self, name, provider_arns, header='Authorization'):
+    def __init__(self, name, provider_arns, header='Authorization',
+                 scopes=None):
         self.name = name
         self._header = header
         if not isinstance(provider_arns, list):
@@ -257,6 +267,7 @@ class CognitoUserPoolAuthorizer(Authorizer):
                 "provider_arns should be a list of ARNs, received: %s"
                 % provider_arns)
         self._provider_arns = provider_arns
+        self.scopes = scopes or []
 
     def to_swagger(self):
         return {
@@ -270,18 +281,24 @@ class CognitoUserPoolAuthorizer(Authorizer):
             }
         }
 
+    def with_scopes(self, scopes):
+        authorizer_with_scopes = copy.deepcopy(self)
+        authorizer_with_scopes.scopes = scopes
+        return authorizer_with_scopes
+
 
 class CustomAuthorizer(Authorizer):
 
     _AUTH_TYPE = 'custom'
 
     def __init__(self, name, authorizer_uri, ttl_seconds=300,
-                 header='Authorization', invoke_role_arn=None):
+                 header='Authorization', invoke_role_arn=None, scopes=None):
         self.name = name
         self._header = header
         self._authorizer_uri = authorizer_uri
         self._ttl_seconds = ttl_seconds
         self._invoke_role_arn = invoke_role_arn
+        self.scopes = scopes or []
 
     def to_swagger(self):
         swagger = {
@@ -300,6 +317,11 @@ class CustomAuthorizer(Authorizer):
                 'authorizerCredentials'] = self._invoke_role_arn
         return swagger
 
+    def with_scopes(self, scopes):
+        authorizer_with_scopes = copy.deepcopy(self)
+        authorizer_with_scopes.scopes = scopes
+        return authorizer_with_scopes
+
 
 class CORSConfig(object):
     """A cors configuration to attach to a route."""
@@ -1185,9 +1207,10 @@ class BuiltinAuthConfig(object):
 # we would need more research to know for sure.  For now, this is a
 # special cased runtime class that knows about its config.
 class ChaliceAuthorizer(object):
-    def __init__(self, name, func):
+    def __init__(self, name, func, scopes=None):
         self.name = name
         self.func = func
+        self.scopes = scopes or []
         # This is filled in during the @app.authorizer()
         # processing.
         self.config = None
@@ -1204,6 +1227,11 @@ class ChaliceAuthorizer(object):
                            event['authorizationToken'],
                            event['methodArn'])
 
+    def with_scopes(self, scopes):
+        authorizer_with_scopes = copy.deepcopy(self)
+        authorizer_with_scopes.scopes = scopes
+        return authorizer_with_scopes
+
 
 class AuthRequest(object):
     def __init__(self, auth_type, token, method_arn):
diff --git a/chalice/app.pyi b/chalice/app.pyi
index cecd875..d8ede5e 100644
--- a/chalice/app.pyi
+++ b/chalice/app.pyi
@@ -25,7 +25,9 @@ _BUILTIN_AUTH_FUNC = Callable[
 
 class Authorizer:
     name = ... # type: str
+    scopes = ... # type: List[str]
     def to_swagger(self) -> Dict[str, Any]: ...
+    def with_scopes(self, scopes: List[str]) -> Authorizer: ...
 
 
 class CognitoUserPoolAuthorizer(Authorizer): ...
@@ -193,7 +195,9 @@ class Chalice(DecoratorAPI):
 class ChaliceAuthorizer(object):
     name = ... # type: str
     func = ... # type: _BUILTIN_AUTH_FUNC
+    scopes = ... # type: List[str]
     config = ... # type: BuiltinAuthConfig
+    def with_scopes(self, scopes: List[str]) -> ChaliceAuthorizer: ...
 
 
 class BuiltinAuthConfig(object):
diff --git a/chalice/deploy/packager.py b/chalice/deploy/packager.py
index ead6fc3..e6c1362 100644
--- a/chalice/deploy/packager.py
+++ b/chalice/deploy/packager.py
@@ -115,8 +115,7 @@ class LambdaDeploymentPackager(object):
         if not self._osutils.directory_exists(dirname):
             return
         prefix_len = len(dirname) + 1
-        for root, _, filenames in self._osutils.walk(dirname,
-                                                     followlinks=True):
+        for root, _, filenames in self._osutils.walk(dirname):
             for filename in filenames:
                 full_path = self._osutils.joinpath(root, filename)
                 zip_path = full_path[prefix_len:]
@@ -184,8 +183,7 @@ class LambdaDeploymentPackager(object):
 
     def _hash_vendor_dir(self, vendor_dir, md5):
         # type: (str, Any) -> None
-        for rootdir, _, filenames in self._osutils.walk(vendor_dir,
-                                                        followlinks=True):
+        for rootdir, _, filenames in self._osutils.walk(vendor_dir):
             for filename in filenames:
                 fullpath = self._osutils.joinpath(rootdir, filename)
                 with self._osutils.open(fullpath, 'rb') as f:
diff --git a/chalice/deploy/swagger.py b/chalice/deploy/swagger.py
index d89d459..f7161f4 100644
--- a/chalice/deploy/swagger.py
+++ b/chalice/deploy/swagger.py
@@ -151,7 +151,7 @@ class SwaggerGenerator(object):
             current.setdefault('security', []).append({'api_key': []})
         if view.authorizer:
             current.setdefault('security', []).append(
-                {view.authorizer.name: []})
+                {view.authorizer.name: view.authorizer.scopes})
         if view.view_args:
             self._add_view_args(current, view.view_args)
         return current
diff --git a/chalice/utils.py b/chalice/utils.py
index 43c446f..42a193a 100644
--- a/chalice/utils.py
+++ b/chalice/utils.py
@@ -256,9 +256,9 @@ class OSUtils(object):
         # type: (str) -> str
         return os.path.join(*args)
 
-    def walk(self, path, followlinks=False):
-        # type: (str, bool) -> Iterator[Tuple[str, List[str], List[str]]]
-        return os.walk(path, followlinks=followlinks)
+    def walk(self, path):
+        # type: (str) -> Iterator[Tuple[str, List[str], List[str]]]
+        return os.walk(path)
 
     def copytree(self, source, destination):
         # type: (str, str) -> None
diff --git a/docs/source/topics/authorizers.rst b/docs/source/topics/authorizers.rst
index b69bf88..1d2d55d 100644
--- a/docs/source/topics/authorizers.rst
+++ b/docs/source/topics/authorizers.rst
@@ -223,6 +223,54 @@ For more information on custom authorizers, see the
 page in the API Gateway user guide.
 
 
+Scopes
+-------------------------
+
+OAuth 2.0 and OpenID Connect (OIDC) scopes can be used to implement access
+controls in your Chalice app. Scopes are supported when using the Cognito
+Authorizer, Custom Authorizers, and Built-In Authorizers.
+
+To integrate Scopes with a Cognito Authorizer in Chalice, you'll need to have
+an existing `Cognito user pools`_ and `Cognito resource server`_ configured.
+Scopes for Cognito Authorizers need to include the full identifier
+which is ``resourceServerIdentifier/scopeName``.
+
+Scopes can be configured per-authorizer using the ``scopes`` attribute.
+
+.. code-block:: python
+
+    from chalice import CognitoUserPoolAuthorizer
+
+    authorizer = CognitoUserPoolAuthorizer(
+        'MyPool', provider_arns=['arn:aws:cognito:...:userpool/name'],
+        scopes=["https://mychaliceapp.example.com/todos.read"])
+
+    @app.route('/user-pools', methods=['GET'], authorizer=authorizer)
+    def authenticated():
+        return {"success": True}
+
+Scopes can be configured per-route for an Authorizer using ``with_scopes``.
+
+.. code-block:: python
+
+    from chalice import CognitoUserPoolAuthorizer
+
+    authorizer = CognitoUserPoolAuthorizer(
+        'MyPool', provider_arns=['arn:aws:cognito:...:userpool/name'])
+
+    @app.route(
+        '/user-pools',
+        methods=['GET'],
+        authorizer=authorizer.with_scopes(["https://mychaliceapp.example.com/todos.read"]))
+    def authenticated():
+        return {"success": True}
+
+Scopes can also be used with custom authorizers and built-in authorizers.
+These authorizers will need to inspect the access token to determine if access
+should be granted based on the scopes configured for the authorizer and route.
+
+
 .. _IAM permissions: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_controlling.html
 .. _Cognito User Pools: https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html
+.. _Cognito Resource Server: https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html
 .. _API Gateway documentation: https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html


Test patch:
diff --git a/tests/functional/test_deployer.py b/tests/functional/test_deployer.py
index 09cfc73..bd0993b 100644
--- a/tests/functional/test_deployer.py
+++ b/tests/functional/test_deployer.py
@@ -223,38 +223,6 @@ def test_subsequent_deploy_replaces_vendor_dir(tmpdir, chalice_deployer):
         _assert_in_zip('mypackage/__init__.py', b'# v2', f)
 
 
-@slow
-def test_vendor_symlink_included(tmpdir, chalice_deployer):
-    appdir = _create_app_structure(tmpdir)
-    extra_package = tmpdir.mkdir('mypackage')
-    extra_package.join('__init__.py').write('# Test package')
-    vendor = appdir.mkdir('vendor')
-    os.symlink(str(extra_package), str(vendor.join('otherpackage')))
-    name = chalice_deployer.create_deployment_package(
-        str(appdir), 'python2.7')
-    with zipfile.ZipFile(name) as f:
-        _assert_in_zip('otherpackage/__init__.py', b'# Test package', f)
-
-
-@slow
-def test_subsequent_deploy_replaces_vendor_symlink(tmpdir, chalice_deployer):
-    appdir = _create_app_structure(tmpdir)
-    extra_package = tmpdir.mkdir('mypackage')
-    extra_package.join('__init__.py').write('# v1')
-    vendor = appdir.mkdir('vendor')
-    os.symlink(str(extra_package), str(vendor.join('otherpackage')))
-    name = chalice_deployer.create_deployment_package(
-        str(appdir), 'python2.7')
-    with zipfile.ZipFile(name) as f:
-        _assert_in_zip('otherpackage/__init__.py', b'# v1', f)
-    # Now we update a package in vendor/ with a new version.
-    extra_package.join('__init__.py').write('# v2')
-    name = chalice_deployer.create_deployment_package(
-        str(appdir), 'python2.7')
-    with zipfile.ZipFile(name) as f:
-        _assert_in_zip('otherpackage/__init__.py', b'# v2', f)
-
-
 def test_zip_filename_changes_on_vendor_update(tmpdir, chalice_deployer):
     appdir = _create_app_structure(tmpdir)
     vendor = appdir.mkdir('vendor')
@@ -268,20 +236,6 @@ def test_zip_filename_changes_on_vendor_update(tmpdir, chalice_deployer):
     assert first != second
 
 
-def test_zip_filename_changes_on_vendor_symlink(tmpdir, chalice_deployer):
-    appdir = _create_app_structure(tmpdir)
-    vendor = appdir.mkdir('vendor')
-    extra_package = tmpdir.mkdir('mypackage')
-    extra_package.join('__init__.py').write('# v1')
-    os.symlink(str(extra_package), str(vendor.join('otherpackage')))
-    first = chalice_deployer.deployment_package_filename(
-        str(appdir), 'python3.6')
-    extra_package.join('__init__.py').write('# v2')
-    second = chalice_deployer.deployment_package_filename(
-        str(appdir), 'python3.6')
-    assert first != second
-
-
 @slow
 def test_chalice_runtime_injected_on_change(tmpdir, chalice_deployer):
     appdir = _create_app_structure(tmpdir)
diff --git a/tests/unit/deploy/test_swagger.py b/tests/unit/deploy/test_swagger.py
index 6e84e4b..f18cbe1 100644
--- a/tests/unit/deploy/test_swagger.py
+++ b/tests/unit/deploy/test_swagger.py
@@ -430,6 +430,69 @@ def test_can_use_authorizer_object_with_role_arn(sample_app, swagger_gen):
     }
 
 
+def test_can_use_authorizer_object_scopes(sample_app, swagger_gen):
+    authorizer = CustomAuthorizer(
+        'MyAuth', authorizer_uri='auth-uri', header='Authorization',
+        invoke_role_arn='role-arn', scopes=["write:test", "read:test"])
+
+    @sample_app.route('/auth', authorizer=authorizer)
+    def auth():
+        return {'foo': 'bar'}
+
+    doc = swagger_gen.generate_swagger(sample_app)
+    single_method = doc['paths']['/auth']['get']
+    assert single_method.get('security') == [{
+        'MyAuth': ["write:test", "read:test"]
+    }]
+    security_definitions = doc['securityDefinitions']
+    assert 'MyAuth' in security_definitions
+    assert security_definitions['MyAuth'] == {
+        'type': 'apiKey',
+        'name': 'Authorization',
+        'in': 'header',
+        'x-amazon-apigateway-authtype': 'custom',
+        'x-amazon-apigateway-authorizer': {
+            'authorizerUri': 'auth-uri',
+            'type': 'token',
+            'authorizerResultTtlInSeconds': 300,
+            'authorizerCredentials': 'role-arn'
+        }
+    }
+
+
+def test_can_use_authorizer_object_with_scopes(sample_app, swagger_gen):
+    authorizer = CustomAuthorizer(
+        'MyAuth', authorizer_uri='auth-uri', header='Authorization',
+        invoke_role_arn='role-arn')
+
+    @sample_app.route(
+        '/auth',
+        authorizer=authorizer.with_scopes(["write:test", "read:test"])
+    )
+    def auth():
+        return {'foo': 'bar'}
+
+    doc = swagger_gen.generate_swagger(sample_app)
+    single_method = doc['paths']['/auth']['get']
+    assert single_method.get('security') == [{
+        'MyAuth': ["write:test", "read:test"]
+    }]
+    security_definitions = doc['securityDefinitions']
+    assert 'MyAuth' in security_definitions
+    assert security_definitions['MyAuth'] == {
+        'type': 'apiKey',
+        'name': 'Authorization',
+        'in': 'header',
+        'x-amazon-apigateway-authtype': 'custom',
+        'x-amazon-apigateway-authorizer': {
+            'authorizerUri': 'auth-uri',
+            'type': 'token',
+            'authorizerResultTtlInSeconds': 300,
+            'authorizerCredentials': 'role-arn'
+        }
+    }
+
+
 def test_can_use_api_key_and_authorizers(sample_app, swagger_gen):
     authorizer = CustomAuthorizer(
         'MyAuth', authorizer_uri='auth-uri', header='Authorization')
@@ -446,6 +509,26 @@ def test_can_use_api_key_and_authorizers(sample_app, swagger_gen):
     ]
 
 
+def test_can_use_api_key_and_authorizers_with_scopes(sample_app, swagger_gen):
+    authorizer = CustomAuthorizer(
+        'MyAuth', authorizer_uri='auth-uri', header='Authorization')
+
+    @sample_app.route(
+        '/auth',
+        authorizer=authorizer.with_scopes(["write:test", "read:test"]),
+        api_key_required=True
+    )
+    def auth():
+        return {'foo': 'bar'}
+
+    doc = swagger_gen.generate_swagger(sample_app)
+    single_method = doc['paths']['/auth']['get']
+    assert single_method.get('security') == [
+        {'api_key': []},
+        {'MyAuth': ["write:test", "read:test"]},
+    ]
+
+
 def test_can_use_iam_authorizer_object(sample_app, swagger_gen):
     authorizer = IAMAuthorizer()
 
@@ -491,6 +574,36 @@ def test_can_use_cognito_auth_object(sample_app, swagger_gen):
     }
 
 
+def test_can_use_cognito_auth_object_with_scopes(sample_app, swagger_gen):
+    authorizer = CognitoUserPoolAuthorizer('MyUserPool',
+                                           header='Authorization',
+                                           provider_arns=['myarn'])
+
+    @sample_app.route(
+        '/api-key-required',
+        authorizer=authorizer.with_scopes(["write:test", "read:test"])
+    )
+    def foo():
+        return {}
+
+    doc = swagger_gen.generate_swagger(sample_app)
+    single_method = doc['paths']['/api-key-required']['get']
+    assert single_method.get('security') == [{
+        'MyUserPool': ["write:test", "read:test"]
+    }]
+    assert 'securityDefinitions' in doc
+    assert doc['securityDefinitions'].get('MyUserPool') == {
+        'in': 'header',
+        'type': 'apiKey',
+        'name': 'Authorization',
+        'x-amazon-apigateway-authtype': 'cognito_user_pools',
+        'x-amazon-apigateway-authorizer': {
+            'type': 'cognito_user_pools',
+            'providerARNs': ['myarn']
+        }
+    }
+
+
 def test_auth_defined_for_multiple_methods(sample_app, swagger_gen):
     authorizer = CognitoUserPoolAuthorizer('MyUserPool',
                                            header='Authorization',
@@ -535,6 +648,57 @@ def test_builtin_auth(sample_app):
         pass
 
     doc = swagger_gen.generate_swagger(sample_app)
+    single_method = doc['paths']['/auth']['get']
+    assert single_method.get('security') == [{'myauth': []}]
+    assert 'securityDefinitions' in doc
+    assert doc['securityDefinitions']['myauth'] == {
+        'in': 'header',
+        'name': 'Authorization',
+        'type': 'apiKey',
+        'x-amazon-apigateway-authtype': 'custom',
+        'x-amazon-apigateway-authorizer': {
+            'type': 'token',
+            'authorizerCredentials': 'arn:role',
+            'authorizerResultTtlInSeconds': 10,
+            'authorizerUri': ('arn:aws:apigateway:us-west-2:lambda:path'
+                              '/2015-03-31/functions/auth_arn/invocations'),
+        }
+    }
+
+
+def test_builtin_auth_with_scopes(sample_app):
+    swagger_gen = SwaggerGenerator(
+        region='us-west-2',
+        deployed_resources={
+            'api_handler_arn': 'lambda_arn',
+            'api_handler_name': 'api-dev',
+            'lambda_functions': {
+                'api-dev-myauth': {
+                    'arn': 'auth_arn',
+                    'type': 'authorizer',
+                }
+            }
+        }
+    )
+
+    @sample_app.authorizer(name='myauth',
+                           ttl_seconds=10,
+                           execution_role='arn:role')
+    def auth(auth_request):
+        pass
+
+    @sample_app.route(
+        '/auth',
+        authorizer=auth.with_scopes(["write:test", "read:test"])
+    )
+    def foo():
+        pass
+
+    doc = swagger_gen.generate_swagger(sample_app)
+    single_method = doc['paths']['/auth']['get']
+    assert single_method.get('security') == [{
+        'myauth': ["write:test", "read:test"]
+    }]
     assert 'securityDefinitions' in doc
     assert doc['securityDefinitions']['myauth'] == {
         'in': 'header',
@@ -577,6 +741,8 @@ def test_will_default_to_function_name_for_auth(sample_app):
         pass
 
     doc = swagger_gen.generate_swagger(sample_app)
+    single_method = doc['paths']['/auth']['get']
+    assert single_method.get('security') == [{'auth': []}]
     assert 'securityDefinitions' in doc
     assert doc['securityDefinitions']['auth'] == {
         'in': 'header',