Skip to content

Commit 1b858e5

Browse files
committed
Merge pull request openedx#5749 from rocha/cache-oauth2-handler-course-values
Cache user course privileges during OpenID Connect authorization.
2 parents 9a6acf2 + fac73e8 commit 1b858e5

File tree

3 files changed

+109
-54
lines changed

3 files changed

+109
-54
lines changed

lms/djangoapps/oauth2_handler/handlers.py

Lines changed: 73 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
""" Handlers for OpenID Connect provider. """
22

33
from django.conf import settings
4+
from django.core.cache import cache
45

56
from courseware.access import has_access
67
from student.models import anonymous_id_for_user
@@ -68,48 +69,57 @@ class CourseAccessHandler(object):
6869
valid only if the user is instructor or staff of at least one course.
6970
7071
Each new scope has a corresponding claim: `instructor_courses` and
71-
`staff_courses` that lists the course_ids for which the user as instructor
72+
`staff_courses` that lists the course_ids for which the user has instructor
7273
or staff privileges.
7374
74-
The claims support claim request values. In other words, if no claim is
75-
requested it returns all the courses for the corresponding privileges. If a
76-
claim request is used, then it only returns the from the list of requested
77-
values that have the corresponding privileges.
75+
The claims support claim request values: if there is no claim request, the
76+
value of the claim is the list all the courses for which the user has the
77+
corresponding privileges. If a claim request is used, then the value of the
78+
claim the list of courses from the requested values that have the
79+
corresponding privileges.
7880
7981
For example, if the user is staff of course_a and course_b but not
80-
course_c, the request:
82+
course_c, the claim corresponding to the scope request:
8183
8284
scope = openid course_staff
8385
84-
will return:
86+
has the value:
8587
8688
{staff_courses: [course_a, course_b] }
8789
88-
If the request is:
90+
For the claim request:
8991
90-
claims = {userinfo: {staff_courses=[course_b, course_d]}}
92+
claims = {userinfo: {staff_courses: {values=[course_b, course_d]}}}
9193
92-
the result will be:
94+
the corresponding claim will have the value:
9395
9496
{staff_courses: [course_b] }.
9597
96-
This is useful to quickly determine if a user has the right
97-
privileges for a given course.
98+
This is useful to quickly determine if a user has the right privileges for a
99+
given course.
98100
99101
For a description of the function naming and arguments, see:
100102
101103
`oauth2_provider/oidc/handlers.py`
102104
103105
"""
104106

107+
COURSE_CACHE_TIMEOUT = getattr(settings, 'OIDC_COURSE_HANDLER_CACHE_TIMEOUT', 60) # In seconds.
108+
109+
def __init__(self, *_args, **_kwargs):
110+
self._course_cache = {}
111+
105112
def scope_course_instructor(self, data):
106113
"""
107114
Scope `course_instructor` valid only if the user is an instructor
108115
of at least one course.
109116
110117
"""
111118

112-
course_ids = self._courses_with_access_type(data, 'instructor')
119+
# TODO: unfortunately there is not a faster and still correct way to
120+
# check if a user is instructor of at least one course other than
121+
# checking the access type against all known courses.
122+
course_ids = self.find_courses(data['user'], 'instructor')
113123
return ['instructor_courses'] if course_ids else None
114124

115125
def scope_course_staff(self, data):
@@ -118,8 +128,9 @@ def scope_course_staff(self, data):
118128
least one course.
119129
120130
"""
131+
# TODO: see :method:CourseAccessHandler.scope_course_instructor
132+
course_ids = self.find_courses(data['user'], 'staff')
121133

122-
course_ids = self._courses_with_access_type(data, 'staff')
123134
return ['staff_courses'] if course_ids else None
124135

125136
def claim_instructor_courses(self, data):
@@ -128,60 +139,75 @@ def claim_instructor_courses(self, data):
128139
user has instructor privileges.
129140
130141
"""
131-
return self._courses_with_access_type(data, 'instructor')
142+
143+
return self.find_courses(data['user'], 'instructor', data.get('values'))
132144

133145
def claim_staff_courses(self, data):
134146
"""
135147
Claim `staff_courses` with list of course_ids for which the user
136148
has staff privileges.
137149
138150
"""
139-
return self._courses_with_access_type(data, 'staff')
140151

141-
def _courses_with_access_type(self, data, access_type):
152+
return self.find_courses(data['user'], 'staff', data.get('values'))
153+
154+
def find_courses(self, user, access_type, values=None):
142155
"""
143-
Utility function to list all courses for a user according to the
144-
access type.
156+
Find all courses for which the user has the specified access type. If
157+
`values` is specified, check only the courses from `values`.
145158
146-
The field `data` follows the handler specification in:
159+
"""
147160

148-
`oauth2_provider/oidc/handlers.py`
161+
# Check the instance cache and update if not present. The instance
162+
# cache is useful since there are multiple scope and claims calls in the
163+
# same request.
149164

150-
"""
165+
key = (user.id, access_type)
166+
if key in self._course_cache:
167+
course_ids = self._course_cache[key]
168+
else:
169+
course_ids = self._get_courses_with_access_type(user, access_type)
170+
self._course_cache[key] = course_ids
151171

152-
user = data['user']
153-
values = set(data.get('values', []))
172+
# If values was specified, filter out other courses.
173+
if values is not None:
174+
course_ids = list(set(course_ids) & set(values))
154175

155-
courses = _get_all_courses()
156-
courses = (c for c in courses if has_access(user, access_type, c))
157-
course_ids = (unicode(c.id) for c in courses)
176+
return course_ids
158177

159-
# If values was provided, return only the requested authorized courses
160-
if values:
161-
return [c for c in course_ids if c in values]
162-
else:
163-
return [c for c in course_ids]
178+
# pylint: disable=missing-docstring
179+
def _get_courses_with_access_type(self, user, access_type):
164180

181+
# Check the application cache and update if not present. The application
182+
# cache is useful since there are calls to different endpoints in close
183+
# succession, for example the id_token and user_info endpoins.
165184

166-
class IDTokenHandler(OpenIDHandler, ProfileHandler, CourseAccessHandler):
167-
"""
168-
Configure the ID Token handler for the LMS.
185+
key = '-'.join([str(self.__class__), str(user.id), access_type])
186+
course_ids = cache.get(key)
169187

170-
Note that the values of the claims `instructor_courses` and
171-
`staff_courses` are not included in the ID Token. The rationale is
172-
that for global staff, the list of courses returned could be very
173-
large. Instead they could check for specific courses using the
174-
UserInfo endpoint.
188+
if course_ids is None:
189+
course_ids = [unicode(course.id) for course in _get_all_courses()
190+
if has_access(user, access_type, course)]
191+
cache.set(key, course_ids, self.COURSE_CACHE_TIMEOUT)
175192

176-
"""
193+
return course_ids
177194

195+
196+
class IDTokenHandler(OpenIDHandler, ProfileHandler, CourseAccessHandler):
197+
""" Configure the ID Token handler for the LMS. """
178198
def claim_instructor_courses(self, data):
179-
# Don't return list of courses in ID Tokens
180-
return None
199+
# Don't return list of courses unless they are requested as essential.
200+
if data.get('essential'):
201+
return super(IDTokenHandler, self).claim_instructor_courses(data)
202+
else:
203+
return None
181204

182205
def claim_staff_courses(self, data):
183-
# Don't return list of courses in ID Tokens
184-
return None
206+
# Don't return list of courses unless they are requested as essential.
207+
if data.get('essential'):
208+
return super(IDTokenHandler, self).claim_staff_courses(data)
209+
else:
210+
return None
185211

186212

187213
class UserInfoHandler(OpenIDHandler, ProfileHandler, CourseAccessHandler):
@@ -194,6 +220,8 @@ def _get_all_courses():
194220
Utitilty function to list all available courses.
195221
196222
"""
223+
197224
ms_courses = modulestore().get_courses()
198225
courses = [c for c in ms_courses if isinstance(c, CourseDescriptor)]
226+
199227
return courses

lms/djangoapps/oauth2_handler/tests.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# pylint: disable=missing-docstring
2+
from django.core.cache import cache
23
from django.test.utils import override_settings
34

45
from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE
@@ -35,8 +36,14 @@ def set_user(self, user):
3536

3637

3738
class IDTokenTest(BaseTestMixin, IDTokenTestCase):
39+
def setUp(self):
40+
super(IDTokenTest, self).setUp()
41+
42+
# CourseAccessHandler uses the application cache.
43+
cache.clear()
44+
3845
def test_sub_claim(self):
39-
scopes, claims = self.get_new_id_token_values('openid')
46+
scopes, claims = self.get_id_token_values('openid')
4047
self.assertIn('openid', scopes)
4148

4249
sub = claims['sub']
@@ -45,7 +52,7 @@ def test_sub_claim(self):
4552
self.assertEqual(sub, expected_sub)
4653

4754
def test_user_name_claim(self):
48-
_scopes, claims = self.get_new_id_token_values('openid profile')
55+
_scopes, claims = self.get_id_token_values('openid profile')
4956
claim_name = claims['name']
5057

5158
user_profile = UserProfile.objects.get(user=self.user)
@@ -55,23 +62,22 @@ def test_user_name_claim(self):
5562

5663
@override_settings(LANGUAGE_CODE='en')
5764
def test_user_without_locale_claim(self):
58-
scopes, claims = self.get_new_id_token_values('openid profile')
65+
scopes, claims = self.get_id_token_values('openid profile')
5966
self.assertIn('profile', scopes)
6067
self.assertEqual(claims['locale'], 'en')
6168

6269
def test_user_with_locale_claim(self):
6370
language = 'en'
6471
UserPreference.set_preference(self.user, LANGUAGE_KEY, language)
65-
scopes, claims = self.get_new_id_token_values('openid profile')
72+
scopes, claims = self.get_id_token_values('openid profile')
6673

6774
self.assertIn('profile', scopes)
6875

6976
locale = claims['locale']
7077
self.assertEqual(language, locale)
7178

7279
def test_no_special_course_access(self):
73-
scopes, claims = self.get_new_id_token_values('openid course_instructor course_staff')
74-
80+
scopes, claims = self.get_id_token_values('openid course_instructor course_staff')
7581
self.assertNotIn('course_staff', scopes)
7682
self.assertNotIn('staff_courses', claims)
7783

@@ -81,19 +87,40 @@ def test_no_special_course_access(self):
8187
def test_course_staff_courses(self):
8288
CourseStaffRole(self.course_key).add_users(self.user)
8389

84-
scopes, claims = self.get_new_id_token_values('openid course_staff')
90+
scopes, claims = self.get_id_token_values('openid course_staff')
8591

8692
self.assertIn('course_staff', scopes)
8793
self.assertNotIn('staff_courses', claims) # should not return courses in id_token
8894

8995
def test_course_instructor_courses(self):
9096
CourseInstructorRole(self.course_key).add_users(self.user)
9197

92-
scopes, claims = self.get_new_id_token_values('openid course_instructor')
98+
scopes, claims = self.get_id_token_values('openid course_instructor')
9399

94100
self.assertIn('course_instructor', scopes)
95101
self.assertNotIn('instructor_courses', claims) # should not return courses in id_token
96102

103+
def test_course_staff_courses_with_claims(self):
104+
CourseStaffRole(self.course_key).add_users(self.user)
105+
106+
course_id = unicode(self.course_key)
107+
nonexistent_course_id = 'some/other/course'
108+
109+
claims = {
110+
'staff_courses': {
111+
'values': [course_id, nonexistent_course_id],
112+
'essential': True,
113+
}
114+
}
115+
116+
scopes, claims = self.get_id_token_values(scope='openid course_staff', claims=claims)
117+
118+
self.assertIn('course_staff', scopes)
119+
self.assertIn('staff_courses', claims)
120+
self.assertEqual(len(claims['staff_courses']), 1)
121+
self.assertIn(course_id, claims['staff_courses'])
122+
self.assertNotIn(nonexistent_course_id, claims['staff_courses'])
123+
97124

98125
class UserInfoTest(BaseTestMixin, UserInfoTestCase):
99126
def token_for_scope(self, scope):

requirements/edx/github.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,5 @@
3232
-e git+https://github.com/edx/[email protected]#egg=opaque-keys
3333
-e git+https://github.com/edx/ease.git@97de68448e5495385ba043d3091f570a699d5b5f#egg=ease
3434
-e git+https://github.com/edx/i18n-tools.git@56f048af9b6868613c14aeae760548834c495011#egg=i18n-tools
35-
-e git+https://github.com/edx/edx-oauth2-provider.git@0.2.2#egg=oauth2-provider
35+
-e git+https://github.com/edx/edx-oauth2-provider.git@0.3.1#egg=oauth2-provider
3636
-e git+https://github.com/edx/edx-val.git@a3c54afe30375f7a5755ba6f6412a91de23c3b86#egg=edx-val

0 commit comments

Comments
 (0)