-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
Conversation
restricted oauth changes & migration files
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:
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. |
There was a problem hiding this 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.
openedx/core/lib/token_utils.py
Outdated
@@ -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 = {} |
There was a problem hiding this comment.
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.
lms/djangoapps/grades/api/views.py
Outdated
SessionAuthentication, | ||
OAuth2AuthenticationAllowInactiveUser, | ||
) | ||
permission_classes = (IsAuthenticated, JWTRestrictedApplicationPermission,) |
There was a problem hiding this comment.
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.
lms/djangoapps/grades/api/views.py
Outdated
# for RestrictedApplications (only). A RestrictedApplication can | ||
# only call this method if it is allowed to receive a 'grades:read' | ||
# scope | ||
restricted_oauth_required = True |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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
lms/djangoapps/grades/api/views.py
Outdated
# 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'): |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 RestrictedApplication
s 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 = '') |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
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
jenkins ok to test |
1 similar comment
jenkins ok to test |
There was a problem hiding this 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.
lms/djangoapps/grades/api/views.py
Outdated
# for RestrictedApplications (only). A RestrictedApplication can | ||
# only call this method if it is allowed to receive a 'grades:read' | ||
# scope | ||
restricted_oauth_required = True |
There was a problem hiding this comment.
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?
lms/djangoapps/grades/api/views.py
Outdated
# 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'): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
lms/djangoapps/grades/api/views.py
Outdated
# 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']: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
lms/djangoapps/grades/api/views.py
Outdated
# 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']: |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
lms/envs/common.py
Outdated
}, | ||
'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' |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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(): |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 = '') |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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' | ||
) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rename to ScopedOrganization
.
jenkins run all |
Where do we all stand on this work? |
@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. |
@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
Your PR has finished running tests. The following contexts failed:
|
@mettursathish The team here is trying to push these changes through this week. |
This ticket contains all the subtasks: https://openedx.atlassian.net/browse/ARCH-99 |
Thanks! Appreciate the cooperation!! |
@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. |
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
Separate PR for the JWTPermissionClass against edx-drf-extensions here
Decision Document Reference here