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

feat: add user_id in the default scopes if no scopes are requested from payload #34300

Merged
merged 10 commits into from
Mar 19, 2024

Conversation

justEhmadSaeed
Copy link
Contributor

@justEhmadSaeed justEhmadSaeed commented Feb 27, 2024

Description

  • This change will impact API users with Application Access and grant type of client_credentials.
  • Defined scopes in the ApplicationAccess were not being utilized if request payload didn't have scope attribute. Only default scopes were being used.
  • Now user_id will be included in the default scopes if no scopes are requested and user_id is an allowed scope.

Supporting information

Ticket: ENT-8426: Let API users get a user_id in their JWTs

Testing instructions

  1. Create an Application for a user with a grant type of client credentials.
  2. Create an ApplicationAccess record for that Application and add its scope attributes, which are delimited by a comma.
  3. Request access token from the API, and it should include all attributes defined in the Application scopes.

Copy link
Contributor

@iloveagent57 iloveagent57 left a comment

Choose a reason for hiding this comment

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

Ah, this is a good idea! It looks good to me, might be worth running by the arch-bom team, too.

client credentials, it should use available scopes as default.
"""
if request.grant_type == 'client_credentials' and not request.scopes:
return get_scopes_backend().get_available_scopes(application=request.client, request=request)
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks. Some high-level thoughts:

  1. I don't know how this will interact with the email/profile defaults implemented in the JWT code? Whatever change we might make, we need to ensure we have good test coverage.
  2. See feat: Add scope user_id to JWT payload #33455 for related background around the user_id claim. I wish we had a cleaner simpler story for where user_id should not be added. It also includes default scope code, which seems to exist in other locations, so I'm not clear on the best implementation.
  3. It feels like adding all scopes as default would make things less-secure. I'm wondering if we should keep this scoped (pun-intended) down to just user_id? I think we are moving to a place where we are trying to make user_id a default if user_id is an allowable scope.

Copy link
Contributor

Choose a reason for hiding this comment

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

Here are some specific requests. Please let me know if you have any questions or concerns. I don't think any of this will take very long.

  1. Instead of adding all allowable scopes, only add user_id if it is in the allowable scopes list. (Note: we can always grow the default list in the future if need be, but it will be difficult to impossible to ever shrink it.)
  2. Update the ADR in feat: Add scope user_id to JWT payload #33455 with this addition. You can add a Change History section, like is used in OEPs.
  3. Add a temporary rollout toggle setting. It is easy to get auth stuff wrong, or have unintended consequences, and this will make it simple to turn on/off. We can remove the toggle after all has proven ok.
  4. Add or update unit tests. This includes tests around the JWTs. Having the rollout toggle should make it simple to test that scopes are working as we expect before and after.

@iloveagent57: You can also let me know if you think any of this seems unreasonable.

Copy link
Contributor

Choose a reason for hiding this comment

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

That all sounds extremely reasonable! Thanks for reviewing this.

For my own clarity on (1) - what are the reasons to not return all scopes to the requesting application when the corresponding ApplicationAccess record says it's ok for that application to have those scopes?

Copy link
Contributor

@robrap robrap Mar 8, 2024

Choose a reason for hiding this comment

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

For my own clarity on (1) - what are the reasons to not return all scopes to the requesting application when the corresponding ApplicationAccess record says it's ok for that application to have those scopes?

a. For (1), it's about the principle of least privilege. One could ask why users have to request a scope at all when we could always return a token that can do anything the user can do? That's basically what this proposal would be. Although this is perfectly valid, it would mean that tokens may have more access than a user needs.
b. Related, we don't have a lot of scopes at this time. Sorry although it seemingly isn't a big deal, because this decision would be difficult to reverse, I'd rather add as little as possible as a default.
c. The user_id claim and our needs around it are unique. I'm not sure, but I'm guessing that the system or person requesting the token doesn't care about user_id, and only we care about it. So, we don't want the user to have to think about requesting this scope. Actually, when the user does request a specific scope, I think other defaults are not included unless requested. This could be an issue if we really want user_id on all of these JWTs. Maybe scope isn't the right way to go around this at all? That said, we can iterate on improvements, as long as you get user_id where you need it for now.

Copy link
Member

@sameenfatima78 sameenfatima78 left a comment

Choose a reason for hiding this comment

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

Would be nice if we could add tests for this newly added logic.

If the request payload does not have `scopes` attribute for a grant_type of
client credentials, it should use available scopes as default.
"""
if request.grant_type == 'client_credentials' and not request.scopes:
Copy link
Contributor

Choose a reason for hiding this comment

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

@moeez96: Once this lands, would it be possible to consolidate the password grant code in this same location, or are there reasons why the user_id default code needs to be all over the place? If it can't be consolidated, I wonder where we could best comment about this to help our future selves? In the ADR? In _get_updated_scopes? Other?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@robrap please have a look at the updated logic and let me know if there's anything out of logic.

@robrap robrap added the waiting on author PR author needs to resolve review requests, answer questions, fix tests, etc. label Mar 9, 2024
@justEhmadSaeed justEhmadSaeed changed the title feat: use application scopes as default if no scopes are requested from payload feat: add user_id in the default scopes if no scopes are requested from payload Mar 12, 2024
@justEhmadSaeed justEhmadSaeed force-pushed the asaeed/add-application-scopes-to-default-scopes branch from 147c5cd to f832f91 Compare March 12, 2024 17:38
@justEhmadSaeed justEhmadSaeed force-pushed the asaeed/add-application-scopes-to-default-scopes branch from f832f91 to 51de2c6 Compare March 12, 2024 17:58
Copy link
Contributor

@robrap robrap left a comment

Choose a reason for hiding this comment

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

Generally looks good. Thank you. Any "nit" comments you can ignore or follow as you wish.

Additionally, there are these remaining comments from the past:

  1. Update the ADR in feat: Add scope user_id to JWT payload #33455 with this addition. You can add a Change History section, like is used in OEPs.
  2. Add a temporary rollout toggle setting. It is easy to get auth stuff wrong, or have unintended consequences, and this will make it simple to turn on/off. We can remove the toggle after all has proven ok.

Comment on lines 96 to 97
If the request payload does not have `scopes` attribute for a grant_type of
client credentials, it should add `user_id` in the default scopes.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
If the request payload does not have `scopes` attribute for a grant_type of
client credentials, it should add `user_id` in the default scopes.
Returns the default scopes.
If the request payload does not have `scopes` attribute for a grant_type of
client credentials, add `user_id` as a default scope if it is an allowed scope.

'openedx.core.djangoapps.oauth_dispatch.scopes.ApplicationModelScopes.has_user_id_in_application_scopes'
)
@mock.patch('oauth2_provider.oauth2_validators.OAuth2Validator.get_default_scopes')
def test_get_updated_default_scopes(self, mock_get_default_scopes, mock_has_user_id_in_application_scopes):
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit:

Suggested change
def test_get_updated_default_scopes(self, mock_get_default_scopes, mock_has_user_id_in_application_scopes):
def test_get_default_scopes_with_user_id(self, mock_get_default_scopes, mock_has_user_id_in_application_scopes):

Comment on lines 89 to 91
default_scopes = ['profile', 'email']
mock_get_default_scopes.return_value = default_scopes.copy()
mock_has_user_id_in_application_scopes.return_value = True
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit:

Suggested change
default_scopes = ['profile', 'email']
mock_get_default_scopes.return_value = default_scopes.copy()
mock_has_user_id_in_application_scopes.return_value = True
expected_default_scopes = ['profile', 'email', 'user_id']
mock_get_default_scopes.return_value = ['profile', 'email']
mock_has_user_id_in_application_scopes.return_value = True

Comment on lines 89 to 91
default_scopes = ['profile', 'email']
mock_get_default_scopes.return_value = default_scopes.copy()
mock_has_user_id_in_application_scopes.return_value = True
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a simple way to do this with a real application, without mocking?

"""
default_scopes = ['profile', 'email']
mock_get_default_scopes.return_value = default_scopes.copy()
mock_has_user_id_in_application_scopes.return_value = False
Copy link
Contributor

Choose a reason for hiding this comment

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

Wouldn't this case just work without mocking?

Copy link
Contributor

@robrap robrap left a comment

Choose a reason for hiding this comment

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

Thanks. It's looking good.


This ADR builds upon ADR 0015: Add Scope user_id for JWT Token. It inherits the context from that ADR, including the initial addition of the user_id claim for analytics and the rationale for using a scope to control its inclusion.

However, this ADR focuses specifically on the enterprise context highlighted in the consequences of ADR 0015. External organizations often utilize the LMS API with the grant_type: client_credentials to request tokens on behalf of their users. These tokens are crucial for enterprise integrations and require access to the user's user_id for proper functionality.
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe something like the following addition?: The current implementation requires clients to remember to request using the scope, which is not a trustworthy solution.

This ADR builds upon ADR 0015: Add Scope user_id for JWT Token. It inherits the context from that ADR, including the initial addition of the user_id claim for analytics and the rationale for using a scope to control its inclusion.

However, this ADR focuses specifically on the enterprise context highlighted in the consequences of ADR 0015. External organizations often utilize the LMS API with the grant_type: client_credentials to request tokens on behalf of their users. These tokens are crucial for enterprise integrations and require access to the user's user_id for proper functionality.

Copy link
Contributor

Choose a reason for hiding this comment

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

We could add something like the following:

Based on ADR 5 and ADR 6, it may be that the original purpose of the user_id scope was to protect against leakage of the user_id in the case of some combination of Authorization Grant and Restricted Applications. More investigation would be required to refactor based on this context, but it may be useful for future iterations.

client credentials, add `user_id` as a default scope if it is an allowed scope.
"""
default_scopes = super().get_default_scopes(client_id, request, *args, **kwargs)
if settings.FEATURES.get('ENABLE_USER_ID_SCOPE', True):
Copy link
Contributor

Choose a reason for hiding this comment

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

  1. This doesn't need to live in FEATURES, and could be top-level, but not a huge deal for a temporary toggle.
  2. I would rename to something more specific like ENABLE_DEFAULT_ALLOWABLE_USER_ID_SCOPE.
  3. You could use a SettingToggle. Again, your choice on updating, but that is the best practice.
  4. The toggle should be documented via annotations. See other SettingToggles in edx-platform and https://edx.readthedocs.io/projects/edx-toggles/en/latest/how_to/documenting_new_feature_toggles.html.

@@ -77,6 +80,28 @@ def test_inactive_user_validates(self):
request = self.request_factory.get('/')
assert self.validator.validate_user('darkhelmet', self.TEST_PASSWORD, client=None, request=request)

@mock.patch.dict(settings.FEATURES, ENABLE_USER_ID_SCOPE=True)
def test_get_default_scopes_with_user_id(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

Add tests for the False version as well, to make this really clear.

@justEhmadSaeed justEhmadSaeed force-pushed the asaeed/add-application-scopes-to-default-scopes branch from 2ff78b5 to 5be6ed6 Compare March 15, 2024 19:56
@justEhmadSaeed justEhmadSaeed force-pushed the asaeed/add-application-scopes-to-default-scopes branch from 6909468 to 070582a Compare March 15, 2024 20:39
@justEhmadSaeed justEhmadSaeed merged commit 57970bd into master Mar 19, 2024
67 checks passed
@justEhmadSaeed justEhmadSaeed deleted the asaeed/add-application-scopes-to-default-scopes branch March 19, 2024 09:46
@edx-pipeline-bot
Copy link
Contributor

2U Release Notice: This PR has been deployed to the edX staging environment in preparation for a release to production.

@edx-pipeline-bot
Copy link
Contributor

2U Release Notice: This PR has been deployed to the edX production environment.

1 similar comment
@edx-pipeline-bot
Copy link
Contributor

2U Release Notice: This PR has been deployed to the edX production environment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
waiting on author PR author needs to resolve review requests, answer questions, fix tests, etc.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants