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

WIP: Restricted OAuth Initial Design & Implementation #18117

Closed
wants to merge 4 commits into from
Closed

WIP: Restricted OAuth Initial Design & Implementation #18117

wants to merge 4 commits into from

Conversation

mettursathish
Copy link

@mettursathish mettursathish commented May 3, 2018

  • Using JWT as OAuth2 Tokens
  • Enforcing Scopes in LMS APIs
    Adding a version number V1 in the OAuth2 token payload
    Restricted Applications receive unexpired JWTs, signed with a new key
    Associate Available Organizations with Applications
    Associate Available Organizations with DOT Applications
  • Organization Information in OAuth Tokens

Separate PR for the JWTPermissionClass against edx-drf-extensions here

Decision Document Reference here

restricted oauth changes & migration files
@openedx-webhooks
Copy link

Thanks for the pull request, @mettursathish! I've created OSPR-2385 to keep track of it in JIRA. JIRA is a place for product owners to prioritize feature reviews by the engineering development teams.

Feel free to add as much of the following information to the ticket:

  • supporting documentation
  • edx-code email threads
  • timeline information ("this must be merged by XX date", and why that is)
  • partner information ("this is a course on edx.org")
  • any other information that can help Product understand the context for the PR

All technical communication about the code itself will still be done via the GitHub pull request interface. As a reminder, our process documentation is here.

We can't start reviewing your pull request until you've submitted a signed contributor agreement or indicated your institutional affiliation. If you like, you can add yourself to the AUTHORS file for this repo, though that isn't required. Please see the CONTRIBUTING file for more information.

@openedx-webhooks openedx-webhooks added needs triage open-source-contribution PR author is not from Axim or 2U waiting on author PR author needs to resolve review requests, answer questions, fix tests, etc. and removed needs triage labels May 3, 2018
Copy link
Contributor

@nasthagiri nasthagiri left a comment

Choose a reason for hiding this comment

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

Thanks for pushing this out. Take a look at my comments and we can discuss synchronously if needed.

@@ -51,14 +61,21 @@ def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None)
"""
now = int(time())
expires_in = expires_in or self.jwt_auth['JWT_EXPIRATION']
filters = {}
Copy link
Contributor

Choose a reason for hiding this comment

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

Please review https://github.com/edx/edx-platform/pull/18139, which includes doc updates for using "filters" for organizations.

SessionAuthentication,
OAuth2AuthenticationAllowInactiveUser,
)
permission_classes = (IsAuthenticated, JWTRestrictedApplicationPermission,)
Copy link
Contributor

Choose a reason for hiding this comment

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

Please see https://github.com/edx/edx-platform/pull/17426
We need to use a common framework to make sure all endpoints enforce scopes. Otherwise, if a developer introduces a new endpoint that supports JwtAuthentication but forgets to add JWTRestrictedApplicationPermission, then malicious users can call that endpoint with a Restricted token without the appropriate scopes.

Once the work in the PR is complete, we should use the view_auth_classes decorator for this view.

# for RestrictedApplications (only). A RestrictedApplication can
# only call this method if it is allowed to receive a 'grades:read'
# scope
restricted_oauth_required = True
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this boolean needed? If OAuth is always needed, then why does this view even add SessionAuthentication in its list?

Copy link
Contributor

Choose a reason for hiding this comment

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

Reminder: this boolean can now be removed, right?

Copy link
Author

Choose a reason for hiding this comment

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

yes, its removed now.

@@ -171,6 +189,17 @@ def get(self, request, course_id):
Return:
A JSON serialized representation of the requesting user's current grade status.
"""
# See if the request has an explicit sattr(request, 'allowed_organizations'))
# which limits which OAuth2 clients can see the courses
# based on the association with a RestrictedApplication
Copy link
Contributor

Choose a reason for hiding this comment

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

Rather than having a comment about this, consider Clean Code's recommendation to have self-describing code by creating a helper method with a self-describing name, such as "verify_content_organization_filter".

Copy link
Author

Choose a reason for hiding this comment

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

Will remove the comments in the next commit

# See if the request has an explicit sattr(request, 'allowed_organizations'))
# which limits which OAuth2 clients can see the courses
# based on the association with a RestrictedApplication
if hasattr(request, 'auth') and hasattr(request, 'allowed_organization'):
Copy link
Contributor

Choose a reason for hiding this comment

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

Need to check that the organization type is "content_org" and not something else like "user_org".

is_application_restricted = RestrictedApplication.objects.filter(application=instance.application).exists()
if is_application_restricted:
RestrictedApplication.set_access_token_as_expired(instance)
if settings.FEATURES.get('AUTO_EXPIRE_RESTRICTED_ACCESS_TOKENS', False):
Copy link
Contributor

Choose a reason for hiding this comment

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

Please use a WaffleSwitch instead since it will allow us to enable/disable dynamically at runtime in case we run into any issues in production.

is_application_restricted = RestrictedApplication.objects.filter(application=instance.application).exists()
if is_application_restricted:
RestrictedApplication.set_access_token_as_expired(instance)
if settings.FEATURES.get('AUTO_EXPIRE_RESTRICTED_ACCESS_TOKENS', False):
Copy link
Contributor

Choose a reason for hiding this comment

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

Also, we do not want to enable this as soon as the PR merges. We will want to pro-actively enable this in production - decoupled from deployment of the code. So, the default path should continue to send expired tokens. The waffle switch should then be something like "ENABLE_OAUTH_SCOPE_ENFORCEMENT" or the like.


# define default separator used to store lists
# IMPORTANT: Do not change this after data has been populated in database
_DEFAULT_SEPARATOR = ' '

class RestrictedApplication(models.Model):
Copy link
Contributor

Choose a reason for hiding this comment

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

As mentioned in associate available scopes with applications, we want to make these associations with the DOT Application. This RestrictedApplication model was intended to be only a temporary model (a short-term hack) that would be eliminated once we fully supported and rolled out scopes enforcement.

Given this, you will want to create a new table for linking DOT applications to their scopes and filters.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, let's discuss the following to see whether it much further simplifies the design:

Given that these changes should be applied to DOT Applications, per the design docs, is there a reason why RestrictedApplications should even come into the picture? We can eliminate all the checks related to RestrictedApplications and all the existing complexity around checking for it. We can then keep the RestrictedApplication code as is.

For the Microsoft application's configuration on edx.org, we would simply eliminate the RestrictedApplication binding once we enable enforcing of scopes.

# this field will be used to implement appropriate data filtering
# so that clients of a specific OAuth2 Application will only be
# able retrieve datasets that the OAuth2 Application is allowed to retrieve.
_org_associations = models.ForeignKey(Organization, default = '')
Copy link
Contributor

Choose a reason for hiding this comment

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

We also need organization type specified in the model. For example, content_org, user_org, etc.

Copy link
Contributor

Choose a reason for hiding this comment

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

Should this now be removed since the OauthRestrictOrganization table is now introduced?

@@ -132,3 +131,20 @@ def save_bearer_token(self, token, request, *args, **kwargs):
# Restore the original request attributes
request.grant_type = grant_type
request.user = user

def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
Copy link
Contributor

Choose a reason for hiding this comment

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

As described in associate available scopes with applications, the more DOT standard way of doing this is to extend the SettingsScope backend and to set the new class as the value for the SCOPES_BACKEND_CLASS setting.

@nasthagiri
Copy link
Contributor

Hey @mettursathish Do you have an ETA on when you'll be able to push forward this PR? Thanks.

Associate Available Scopes with Applications
Associate Available Organizations with Applications
Waffle Switch for oauth2.unexpired_restricted_applications
Also, addressed code review feedback's
@nasthagiri nasthagiri requested a review from douglashall May 23, 2018 13:39
@nasthagiri
Copy link
Contributor

jenkins ok to test

1 similar comment
@estute
Copy link
Contributor

estute commented May 24, 2018

jenkins ok to test

Copy link
Contributor

@nasthagiri nasthagiri left a comment

Choose a reason for hiding this comment

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

I'm still going through this, but here are some initial comments.

# for RestrictedApplications (only). A RestrictedApplication can
# only call this method if it is allowed to receive a 'grades:read'
# scope
restricted_oauth_required = True
Copy link
Contributor

Choose a reason for hiding this comment

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

Reminder: this boolean can now be removed, right?

# See if the request has an explicit sattr(request, 'allowed_organizations'))
# which limits which OAuth2 clients can see the courses
# based on the association with a RestrictedApplication
if hasattr(request, 'auth') and hasattr(request, 'filters'):
Copy link
Contributor

Choose a reason for hiding this comment

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

To avoid namespace collisions by other apps storing values on the request object, we should add a namespace prefix to new fields we store on the shared request object. So filters should be prefixed by the app name that stores it.

Copy link
Author

Choose a reason for hiding this comment

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

addressed in the latest commit.

# based on the association with a RestrictedApplication
if hasattr(request, 'auth') and hasattr(request, 'filters'):
course_key = CourseKey.from_string(course_id)
if course_key.org not in request.filters['content_org']:
Copy link
Contributor

Choose a reason for hiding this comment

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

Since we are supporting different types of filters, we cannot assume that there's always a content_org filter. So we should check if a content_org filter exists, and if so, we should honor it. If it doesn't exist, we don't worry about filtering by content organization.

Copy link
Author

Choose a reason for hiding this comment

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

addressed in the latest commit, good catch.

# based on the association with a RestrictedApplication
if hasattr(request, 'auth') and hasattr(request, 'filters'):
course_key = CourseKey.from_string(course_id)
if course_key.org not in request.filters['content_org']:
Copy link
Contributor

Choose a reason for hiding this comment

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

You also need to check for the user=me filter, right?

Copy link
Author

Choose a reason for hiding this comment

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

This code is for grades v0 and not for bulk grades; code validate current user earlier and throws valid exception. So, we don't need to validate user=me, we can discuss further if needed.

Copy link
Author

Choose a reason for hiding this comment

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

Remainder: In the next PR, we will move the changes to Grades V1

},
'REQUEST_APPROVAL_PROMPT': 'auto_even_if_expired',
}
# This is required for the migrations in oauth_dispatch.models
# otherwise it fails saying this attribute is not present in Settings
OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application'

##OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application'
Copy link
Contributor

Choose a reason for hiding this comment

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

Reminder: remove commented out code.

Copy link
Author

Choose a reason for hiding this comment

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

done

@@ -5,7 +5,7 @@
from django.contrib.admin import ModelAdmin, site
from oauth2_provider import models

from .models import RestrictedApplication
from .models import RestrictedApplication, OauthRestrictOrganization, OauthRestrictedApplication
Copy link
Contributor

Choose a reason for hiding this comment

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

How does OauthRestrictedApplication differ from RestrictedApplication? We need a better name.

if is_application_restricted:
RestrictedApplication.set_access_token_as_expired(instance)

if not is_oauth_scope_enforcement_enabled():
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice name!

related_name='restricted_application'
)

# a space separated list of scopes that this application can request
Copy link
Contributor

Choose a reason for hiding this comment

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

To discuss: Don't we want the new data model and fields to go in a new table? It doesn't need to be part of this to-be-deprecated RestrictedApplication concept.

Copy link
Author

Choose a reason for hiding this comment

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

cleaned up the old code.

# this field will be used to implement appropriate data filtering
# so that clients of a specific OAuth2 Application will only be
# able retrieve datasets that the OAuth2 Application is allowed to retrieve.
_org_associations = models.ForeignKey(Organization, default = '')
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this now be removed since the OauthRestrictOrganization table is now introduced?

which is Jan. 1, 1970
"""

instance.expires = datetime(1970, 1, 1, tzinfo=utc)
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you explain what this new model is meant to be responsible for? Don't we already have the RestrictedApplication class for the expired tokens?

Copy link
Author

Choose a reason for hiding this comment

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

Based on previous discussion, I thought we are going to remove RestrictedApplication. Hence added expired tokens method in the new model. Now, its reverted back.

status_code=status.HTTP_403_FORBIDDEN,
developer_message='The OAuth2 RestrictedApplication is not associated with org.',
error_code='course_org_not_associated_with_calling_application'
)
Copy link
Contributor

Choose a reason for hiding this comment

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

This would be good in some kind of scope enforcing decorator possibly.

Copy link
Contributor

Choose a reason for hiding this comment

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

Possibly a DRF filter.

},
'REQUEST_APPROVAL_PROMPT': 'auto_even_if_expired',
}
# This is required for the migrations in oauth_dispatch.models
# otherwise it fails saying this attribute is not present in Settings
OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application'

OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth_dispatch.OauthRestrictedApplication'
Copy link
Contributor

Choose a reason for hiding this comment

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

This will introduce backward compatibility concerns. We will need to provide some way to migrate existing Application models to the new ScopedApplication model.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we definitely want to go this route (overriding the application model). We just want to make sure we have a rollout plan for this change in existing environments. It may be as simple as adding a data migration to this PR that copies the existing Application models to new ScopedApplication models. I just want to verify this is the "right" approach with our DevOps team.

Copy link
Contributor

Choose a reason for hiding this comment

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

I checked with our DevOps team. We are good with adding a data migration that copies existing data from the old table to the new. This data migration would be adding after the existing migrations which add the new tables. Could you add that to this PR?

@@ -43,3 +52,73 @@ def verify_access_token_as_expired(cls, access_token):
is set at the beginning of the epoch which is Jan. 1, 1970
"""
return access_token.expires == datetime(1970, 1, 1, tzinfo=utc)


class OauthRestrictedApplication(AbstractApplication):
Copy link
Contributor

Choose a reason for hiding this comment

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

Rename to ScopedApplication.

return app_scopes.intersection(all_scopes)


class OauthRestrictOrganization(models.Model):
Copy link
Contributor

Choose a reason for hiding this comment

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

Rename to ScopedOrganization.

@nasthagiri
Copy link
Contributor

Here's the diagram we drew on the whiteboard today.

model diagram

@nedbat
Copy link
Contributor

nedbat commented Jun 5, 2018

jenkins run all

@nedbat
Copy link
Contributor

nedbat commented Jun 5, 2018

Where do we all stand on this work?

@douglashall
Copy link
Contributor

@mettursathish Hi Sathish! I just wanted to give you an update on this. I'm currently working on a PR against your branch here that outlines some of the changes I think we want to make. For me, this was an easier approach than writing things up in comments. I'm hoping to have something for you to look at in the next couple of days. Let me know if you have any objections to this approach.

@mettursathish
Copy link
Author

@douglashall Yes, it works. Feel free to update the comments. I see already you commented on few things, which will be addressed soon.

@nedbat Two things, (1) We are waiting for the edx-drf-extension library changes to be checked-in (2) There are few comments, which will be addressed as well as migration script that we are working.

Adding org in authorize html file
@edx-status-bot
Copy link

Your PR has finished running tests. The following contexts failed:

  • jenkins/a11y
  • jenkins/lettuce
  • jenkins/bokchoy
  • jenkins/python
  • jenkins/quality

@nasthagiri
Copy link
Contributor

@mettursathish The team here is trying to push these changes through this week.
We have broken it up into the following 2 PRs:

@nasthagiri
Copy link
Contributor

This ticket contains all the subtasks: https://openedx.atlassian.net/browse/ARCH-99

@mettursathish
Copy link
Author

Thanks! Appreciate the cooperation!!

@nasthagiri nasthagiri closed this Jul 10, 2018
@openedx-webhooks
Copy link

@mettursathish Even though your pull request wasn’t merged, please take a moment to answer a two question survey so we can improve your experience in the future.

@openedx-webhooks openedx-webhooks added rejected and removed waiting on author PR author needs to resolve review requests, answer questions, fix tests, etc. labels Jan 22, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
open-source-contribution PR author is not from Axim or 2U rejected
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants