BOM-1264: add third-party-auth scope and usage (#23135)

* WIP: add third-party-auth scope and usage

BOM-1264


* Fix tests now that we do permissions in a more standard way.

Rather than manually setting the permission class we previously
explicitly raised a PermissionDenied exception.  The way DRF
permissoning logic works, if we use the WWW-Authenticate header in the
highest priority auth class, it will return a 401 instead of a 403.


* Added test to make sure having permissions gives access to user mapping api

* Test new filters logic.

Ensure that the filters we add to the application access model make it
into the JWT correctly.

* quality fix

* quality fix

* disable pylint warning

* quality fix

* fix indent prob

Co-authored-by: Feanil Patel <feanil@edx.org>
Co-authored-by: Manjinder Singh <49171515+jinder1s@users.noreply.github.com>
This commit is contained in:
Robert Raposa
2020-02-21 11:25:28 -05:00
committed by GitHub
parent dca3dbc5ed
commit 372d2e927c
15 changed files with 451 additions and 47 deletions

View File

@@ -2,7 +2,7 @@
Adapter to isolate django-oauth-toolkit dependencies
"""
from edx_django_utils.monitoring import set_custom_metric
from oauth2_provider import models
from openedx.core.djangoapps.oauth_dispatch.models import RestrictedApplication
@@ -97,13 +97,34 @@ class DOTAdapter(object):
Get the authorization filters for the given client application.
"""
application = client
filters = [org_relation.to_jwt_filter_claim() for org_relation in application.organizations.all()]
filter_set = set()
if hasattr(application, 'access') and application.access.filters:
filter_set.update(application.access.filters)
filter_set = self._add_org_relation_filters_to_set(application, filter_set)
# Allow applications configured with the client credentials grant type to access
# data for all users. This will enable these applications to fetch data in bulk.
# Applications configured with all other grant types should only have access
# to data for the request user.
if application.authorization_grant_type != application.GRANT_CLIENT_CREDENTIALS:
filters.append(self.FILTER_USER_ME)
filter_set.add(self.FILTER_USER_ME)
return filters
return list(filter_set)
def _add_org_relation_filters_to_set(self, application, filter_set):
"""
Adds Organization related filters to the filter_set.
TODO: BOM-1292: Retire Application Organizations once all filters have been migrated
to Application Access. When retiring, this entire function can be deleted.
"""
filter_set_before_orgs = filter_set.copy()
filter_set.update([org_relation.to_jwt_filter_claim() for org_relation in application.organizations.all()])
set_custom_metric('filter_set_before_orgs', list(filter_set_before_orgs))
set_custom_metric('filter_set_after_orgs', list(filter_set))
set_custom_metric('filter_set_difference', list(filter_set.difference(filter_set_before_orgs)))
return filter_set

View File

@@ -80,7 +80,7 @@ class ApplicationAccessAdmin(ModelAdmin):
"""
ModelAdmin for ApplicationAccess
"""
list_display = [u'application', u'scopes']
list_display = ['application', 'scopes', 'filters']
class ApplicationOrganizationAdmin(ModelAdmin):

View File

@@ -0,0 +1,53 @@
11. More General Scope Filter Support
-------------------------------------
Status
------
Accepted
Context
-------
For background, please see:
* `Include Organizations in Tokens`_, where we decided to include a `content_org` filter in JWT tokens.
The implementation of the `content_org` filter included a new model for relating OAuth Applications and Organizations. This design made it difficult to add new types of filters, especially if they weren't tied to organizations.
Decisions
---------
#. **Add ApplicationAccess filters** Add a ``filters`` field to the ApplicationAccess model to more quickly allow for new filter types.
#. **Remove ApplicationOrganization** Deprecate and remove the ApplicationOrganization model which could only handle a very small subset of filters.
Consequences
------------
* Adding the `filters` field to the ApplicationAccess model allows for a simpler design with the following benefits:
* This enables filters, which typically have some relationship to scopes, to be defined in the same admin screen. This should make it simpler to define oAuth Applications with proper security.
* This enables the removal of the separate ApplicationOrganization model, which was more complex to configure and less clear regarding its impact on the JWT.
*. The new `filters` field must be added to the EdxOAuth2AuthorizationView_ to handle user authorization for OAuth Applications with grant type 'Authorization code'. This work will be done in a future PR detailed in BOM-1291_.
Using the example from `Include Organizations in Tokens`_, we would now simply use the Application Access admin screen to set::
Scopes: grades:read,enrollments:read
Filters: content_org:Microsoft
This would result in a JWT that contains the following, assuming these two scopes were requested::
{
"scopes": ["grades:read", "enrollments:read"],
"filters": ["content_org:Microsoft", "user:me"],
...
}
Note: Every JWT access token created using a given OAuth Application will include **all filters** defined for that application. This was also true as of the initial introduction of filters.
.. _EdxOAuth2AuthorizationView: https://github.com/edx/edx-platform/blob/9cf2f9f298e5e8be3b3abcaadaf0b7a96d0de0df/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py#L16
.. _BOM-1291: https://openedx.atlassian.net/browse/BOM-1291
.. _Transport JWT in HTTP Cookies: 0007-include-organizations-in-tokens.rst

View File

@@ -0,0 +1,46 @@
12. Scope and filter for Third-Party Auth
-----------------------------------------
Status
------
Accepted
Context
-------
The permission class ``ThirdPartyAuthProviderApiPermission`` exists to protect a single view, ``UserMappingView``. The permission ensures that the OAuth Client Application used during authentication has a related mapping in the ``ProviderApiPermissions`` model for the ``provider_id`` passed to the view.
An example call to this view looks like::
GET /api/third_party_auth/v0/providers/{provider_id}/users
The problem is that ``ProviderApiPermissions`` has a foreign-key reference to a django-oauth-provider (DOP) table which is no longer supported as of the decision to `Migrate to Django OAuth Toolkit (DOT)`_.
.. _Migrate to Django OAuth Toolkit (DOT): 0002-migrate-to-dot.rst
Decisions
---------
A new scope and filter will be introduced to provide this same Third-Party Auth authorization, and taking advantage of the `More General Scope Filter Support`_ decision.
The new scope and filter are::
Scope: tpa:read
Filter: tpa_provider:<provider_id> (e.g. tpa_provider:saml-ubc)
The scope can be protected using the already existing `JwtHasScope`_ DRF permission class in edx-drf-extensions.
The new filter permission class, ``JwtHasTpaProviderFilterForRequestedProvider``, will be implemented in edx-platform to start because it is only used by an edx-platform view, ``UserMappingView``. Additionally, the permission class is used in conjunction with other legacy permissions and it is simpler to keep all the tests together.
.. _More General Scope Filter Support: 0011-scope-filter-support.rst
.. _JwtHasScope: https://github.com/edx/edx-drf-extensions/blob/64f831d715d14dc2db5a1046201ff14e92fa7c9f/edx_rest_framework_extensions/permissions.py#L70
Consequences
------------
* The django-oauth-provider related model ``ProviderApiPermissions`` can be retired without adding a new model, simplifying our OAuth story.
* The complicated method of handling compound permissions, like `JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS`_ from edx-drf-extensions, needs to be duplicated in edx-platform to properly handle Restricted Applications and ``JwtHasTpaProviderFilterForRequestedProvider``. Simplifying this design is being left to a later decision.
.. _JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS: https://github.com/edx/edx-drf-extensions/blob/64f831d715d14dc2db5a1046201ff14e92fa7c9f/edx_rest_framework_extensions/permissions.py#L171

View File

@@ -38,6 +38,10 @@ to test other grant types if they are substituted in the appropriate places.
iii. Click Save.
iv. If the temporary waffle switch `oauth2.enforce_jwt_scopes`_ is still defined in your codebase, you will need to enable this switch in the LMS under http://localhost:18000/admin/waffle/switch/add/
.. _oauth2.enforce_jwt_scopes: https://github.com/edx/edx-drf-extensions/blob/609e1dbaa98f476b36e50143de97732f2f6a9b4f/edx_rest_framework_extensions/config.py#L5-L18
3. Create a publicly accessible URL to the LMS if you are testing on devstack. This step is needed to support the redirecting handshake in the Authorization Code protocol from Google's server back to localhost.
i. Install `localtunnel`_:

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.28 on 2020-02-14 21:30
from __future__ import unicode_literals
from django.db import migrations, models
import django_mysql.models
class Migration(migrations.Migration):
dependencies = [
('oauth_dispatch', '0007_restore_application_id_constraints'),
]
operations = [
migrations.AddField(
model_name='applicationaccess',
name='filters',
field=django_mysql.models.ListCharField(models.CharField(max_length=32), blank=True, help_text='Comma-separated list of filters that this application will be allowed to request.', max_length=825, null=True, size=25),
),
]

View File

@@ -65,6 +65,9 @@ class ApplicationAccess(models.Model):
"""
Specifies access control information for the associated Application.
For usage details, see:
- openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst
.. no_pii:
"""
@@ -77,6 +80,15 @@ class ApplicationAccess(models.Model):
help_text=_('Comma-separated list of scopes that this application will be allowed to request.'),
)
filters = ListCharField(
base_field=models.CharField(max_length=32),
size=25,
max_length=(25 * 33), # 25 * 32 character filters, plus commas
help_text=_('Comma-separated list of filters that this application will be allowed to request.'),
null=True,
blank=True,
)
class Meta:
app_label = 'oauth_dispatch'
@@ -84,13 +96,18 @@ class ApplicationAccess(models.Model):
def get_scopes(cls, application):
return cls.objects.get(application=application).scopes
@classmethod
def get_filters(cls, application):
return cls.objects.get(application=application).filters
def __str__(self):
"""
Return a unicode representation of this object.
"""
return u"{application_name}:{scopes}".format(
return u"{application_name}:{scopes}:{filters}".format(
application_name=self.application.name,
scopes=self.scopes,
filters=self.filters,
)
@@ -102,6 +119,8 @@ class ApplicationOrganization(models.Model):
See openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst
for the intended use of this model.
Deprecated: Use filters in ApplicationAccess instead.
.. no_pii:
"""
RELATION_TYPE_CONTENT_ORG = u'content_org'

View File

@@ -348,6 +348,7 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
dot_app_access = models.ApplicationAccess.objects.create(
application=dot_app,
scopes=['grades:read'],
filters=['test:filter'],
)
models.ApplicationOrganization.objects.create(
application=dot_app,
@@ -355,6 +356,8 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
)
scopes = dot_app_access.scopes
filters = self.dot_adapter.get_authorization_filters(dot_app)
assert 'test:filter' in filters
response = self._post_request(self.user, dot_app, token_type='jwt', scope=scopes)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode('utf-8'))