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:
@@ -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
|
||||
|
||||
@@ -80,7 +80,7 @@ class ApplicationAccessAdmin(ModelAdmin):
|
||||
"""
|
||||
ModelAdmin for ApplicationAccess
|
||||
"""
|
||||
list_display = [u'application', u'scopes']
|
||||
list_display = ['application', 'scopes', 'filters']
|
||||
|
||||
|
||||
class ApplicationOrganizationAdmin(ModelAdmin):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`_:
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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'
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user