WIP: Restricted OAuth Initial Design & Implementation - #18117
WIP: Restricted OAuth Initial Design & Implementation#18117mettursathish wants to merge 4 commits into
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. |
nasthagiri
left a comment
There was a problem hiding this comment.
Thanks for pushing this out. Take a look at my comments and we can discuss synchronously if needed.
| """ | ||
| now = int(time()) | ||
| expires_in = expires_in or self.jwt_auth['JWT_EXPIRATION'] | ||
| filters = {} |
There was a problem hiding this comment.
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,) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
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 |
There was a problem hiding this comment.
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.
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'): |
There was a problem hiding this comment.
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.
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.
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.
| # 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.
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.
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 = '') |
There was a problem hiding this comment.
We also need organization type specified in the model. For example, content_org, user_org, etc.
There was a problem hiding this comment.
Should this now be removed since the OauthRestrictOrganization table is now introduced?
| 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.
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 |
nasthagiri
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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'): |
There was a problem hiding this comment.
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.
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']: |
There was a problem hiding this comment.
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.
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']: |
There was a problem hiding this comment.
You also need to check for the user=me filter, right?
There was a problem hiding this comment.
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.
Remainder: In the next PR, we will move the changes to Grades V1
| # 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.
Reminder: remove commented out code.
| from oauth2_provider import models | ||
|
|
||
| from .models import RestrictedApplication | ||
| from .models import RestrictedApplication, OauthRestrictOrganization, OauthRestrictedApplication |
There was a problem hiding this comment.
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(): |
| related_name='restricted_application' | ||
| ) | ||
|
|
||
| # a space separated list of scopes that this application can request |
There was a problem hiding this comment.
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.
| # 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.
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.
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.
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.
This would be good in some kind of scope enforcing decorator possibly.
| # 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.
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.
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.
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?
| return access_token.expires == datetime(1970, 1, 1, tzinfo=utc) | ||
|
|
||
|
|
||
| class OauthRestrictedApplication(AbstractApplication): |
There was a problem hiding this comment.
Rename to ScopedApplication.
| return app_scopes.intersection(all_scopes) | ||
|
|
||
|
|
||
| class OauthRestrictOrganization(models.Model): |
There was a problem hiding this comment.
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