Skip to content

Commit c853095

Browse files
committed
chore: add changes PR openedx#38984
1 parent 26d0908 commit c853095

2 files changed

Lines changed: 109 additions & 39 deletions

File tree

common/djangoapps/student/roles.py

Lines changed: 52 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
from opaque_keys.edx.keys import CourseKey
1616
from opaque_keys.edx.locator import CourseLocator
1717
from openedx_authz.api import users as authz_api
18-
from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData, RoleAssignmentData
18+
from openedx_authz.api.data import (
19+
CourseOverviewData,
20+
OrgCourseOverviewGlobData,
21+
PlatformCourseOverviewGlobData,
22+
RoleAssignmentData,
23+
)
1924
from openedx_authz.constants import roles as authz_roles
2025
from organizations.api import get_organizations
2126

@@ -160,44 +165,55 @@ class AuthzCompatCourseAccessRole:
160165
role: str
161166

162167

163-
def _get_org_and_course_id_from_authz_scope(
164-
scope: CourseOverviewData | OrgCourseOverviewGlobData,
165-
) -> tuple[str, str | None] | None:
168+
def _get_orgs_and_course_ids_from_authz_scope(
169+
scope: CourseOverviewData | OrgCourseOverviewGlobData | PlatformCourseOverviewGlobData,
170+
) -> list[tuple[str, str | None]]:
166171
"""
167-
Extract the org and course key from an AuthZ course assignment scope.
172+
Extract the (org, course_id) pairs an AuthZ course assignment scope maps to.
168173
169-
Course-scoped assignments return ``(org, course_external_key)``.
170-
Org-wide assignments return ``(org, None)``.
174+
Course-scoped assignments map to a single ``(org, course_external_key)`` pair.
175+
Org-wide assignments map to a single ``(org, None)`` pair.
171176
172-
Returns ``None`` when the org cannot be determined. For org-wide scopes,
173-
``OrgGlobData.org`` is typed as ``str | None`` because it is parsed from
174-
``external_key`` and returns ``None`` for malformed glob patterns.
177+
Platform-wide assignments (``course-v1:*``) apply to every org, not just one, so
178+
they map to ``(org, None)`` for *every registered org* — the same shape an org-wide
179+
grant already produces, just repeated per org. This lets a platform-wide grant be
180+
picked up by the existing OrgRole-based legacy checks (e.g. ``has_staff_roles``,
181+
``get_user_permissions``, which already check org-level and course-level access
182+
separately) with no changes to ``RoleCache``/``OrgRole``/``CourseRole``.
183+
184+
Returns an empty list when the org cannot be determined (e.g. a malformed org-glob
185+
external_key, where ``OrgGlobData.org`` is ``None``) or the scope type isn't one of
186+
the above.
175187
"""
176188
if isinstance(scope, CourseOverviewData):
177189
course_id = scope.external_key
178-
return get_org_from_key(course_id), course_id
179-
if isinstance(scope, OrgCourseOverviewGlobData):
180-
return scope.org, None
181-
return None
190+
return [(get_org_from_key(course_id), course_id)]
191+
if isinstance(scope, PlatformCourseOverviewGlobData):
192+
return [(org["short_name"], None) for org in get_organizations()]
193+
if isinstance(scope, OrgCourseOverviewGlobData) and scope.org is not None:
194+
return [(scope.org, None)]
195+
return []
182196

183197

184198
def authz_get_all_course_assignments_for_user(user: User) -> list[RoleAssignmentData]:
185199
"""
186200
Return AuthZ role assignments for a user that apply to courses.
187201
188-
Includes assignments scoped to a specific course (``CourseOverviewData``) and
189-
assignments scoped to all courses in an organization (``OrgCourseOverviewGlobData``).
190-
Assignments for other resource types, such as content libraries, are excluded.
202+
Includes assignments scoped to a specific course (``CourseOverviewData``), to all
203+
courses in an organization (``OrgCourseOverviewGlobData``), and to all courses on
204+
the platform (``PlatformCourseOverviewGlobData``). Assignments for other resource
205+
types, such as content libraries, are excluded.
191206
192207
Args:
193208
user (User): The user whose AuthZ role assignments should be retrieved.
194209
195210
Returns:
196-
list[RoleAssignmentData]: Role assignments whose scope is course-level or org-wide
211+
list[RoleAssignmentData]: Role assignments whose scope is course-level,
212+
org-wide, or platform-wide.
197213
"""
198214
return authz_api.get_user_role_assignments_per_scope_type(
199215
user_external_key=user.username,
200-
scope_types=(CourseOverviewData, OrgCourseOverviewGlobData),
216+
scope_types=(CourseOverviewData, OrgCourseOverviewGlobData, PlatformCourseOverviewGlobData),
201217
)
202218

203219

@@ -208,9 +224,10 @@ def _compat_roles_from_authz_assignment(
208224
"""
209225
Convert an AuthZ role assignment into legacy-compatible course access roles.
210226
211-
Course-scoped assignments produce roles tied to a specific course key.
212-
Org-wide assignments produce org-level roles with no course key (``course_id``
213-
is ``None``), matching legacy ``OrgStaffRole`` / ``OrgInstructorRole`` behavior.
227+
Course-scoped assignments produce roles tied to a specific course key. Org-wide
228+
and platform-wide assignments produce org-level roles with no course key
229+
(``course_id`` is ``None``), matching legacy ``OrgStaffRole`` / ``OrgInstructorRole``
230+
behavior — a platform-wide assignment produces one such role per registered org.
214231
AuthZ roles without a legacy mapping are skipped.
215232
216233
Args:
@@ -223,25 +240,21 @@ def _compat_roles_from_authz_assignment(
223240
assignment. Returns an empty set if the org cannot be determined from
224241
the scope or no roles could be mapped.
225242
"""
226-
org_and_course_id = _get_org_and_course_id_from_authz_scope(assignment.scope)
227-
if org_and_course_id is None:
228-
return set()
229-
org, course_id = org_and_course_id
230-
231243
compat_roles = set()
232-
for role in assignment.roles:
233-
legacy_role = get_legacy_role_from_authz_role(authz_role=role.external_key)
234-
if legacy_role is None:
235-
continue
236-
compat_roles.add(
237-
AuthzCompatCourseAccessRole(
238-
user_id=user.id,
239-
username=user.username,
240-
org=org,
241-
course_id=course_id,
242-
role=legacy_role,
244+
for org, course_id in _get_orgs_and_course_ids_from_authz_scope(assignment.scope):
245+
for role in assignment.roles:
246+
legacy_role = get_legacy_role_from_authz_role(authz_role=role.external_key)
247+
if legacy_role is None:
248+
continue
249+
compat_roles.add(
250+
AuthzCompatCourseAccessRole(
251+
user_id=user.id,
252+
username=user.username,
253+
org=org,
254+
course_id=course_id,
255+
role=legacy_role,
256+
)
243257
)
244-
)
245258
return compat_roles
246259

247260

common/djangoapps/student/tests/test_roles.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,63 @@ def test_org_scope_authz_role_grants_instructor_dashboard_permissions(self):
409409
self.assertTrue(self.student.has_perm(instructor_permissions.VIEW_DASHBOARD, course_key)) # noqa: PT009
410410
self.assertTrue(self.student.has_perm(instructor_permissions.SHOW_TASKS, course_key)) # noqa: PT009
411411

412+
def test_get_authz_compat_course_access_roles_for_user_platform_glob(self):
413+
"""
414+
A platform-wide (course-v1:*) AuthZ assignment should map to one legacy
415+
org-level course access role per registered org, since it applies to all of them.
416+
"""
417+
for org in self.orgs:
418+
add_organization({"name": org, "short_name": org, "description": ""})
419+
420+
assignment = RoleAssignmentData(
421+
subject=UserData(external_key=self.student.username),
422+
roles=[RoleData(external_key=COURSE_ADMIN.external_key)],
423+
scope=PlatformCourseOverviewGlobData(external_key="course-v1:*"),
424+
)
425+
with patch("openedx_authz.api.users.get_user_role_assignments", return_value=[assignment]):
426+
result = get_authz_compat_course_access_roles_for_user(self.student)
427+
428+
self.assertCountEqual( # noqa: PT009
429+
result,
430+
{
431+
AuthzCompatCourseAccessRole(
432+
user_id=self.student.id,
433+
username=self.student.username,
434+
org=org,
435+
course_id=None,
436+
role="instructor",
437+
)
438+
for org in self.orgs
439+
},
440+
)
441+
442+
def test_platform_glob_authz_role_grants_instructor_dashboard_permissions(self):
443+
"""
444+
A platform-wide (course-v1:*) AuthZ course_admin should grant legacy instructor
445+
access for courses in *any* org, the same way an org-wide grant does for its org.
446+
"""
447+
# pylint: disable=protected-access
448+
for org in self.orgs:
449+
add_organization({"name": org, "short_name": org, "description": ""})
450+
marvel_course_key = CourseKey.from_string(f"course-v1:{self.orgs[0]}+DemoX+DemoCourse")
451+
dc_course_key = CourseKey.from_string(f"course-v1:{self.orgs[1]}+DemoX+DemoCourse")
452+
453+
assignment = RoleAssignmentData(
454+
subject=UserData(external_key=self.student.username),
455+
roles=[RoleData(external_key=COURSE_ADMIN.external_key)],
456+
scope=PlatformCourseOverviewGlobData(external_key="course-v1:*"),
457+
)
458+
with patch("openedx_authz.api.users.get_user_role_assignments", return_value=[assignment]):
459+
if hasattr(self.student, "_roles"):
460+
del self.student._roles
461+
self.student._roles = RoleCache(self.student)
462+
463+
for org in self.orgs:
464+
self.assertTrue(self.student._roles.has_role("instructor", None, org)) # noqa: PT009
465+
self.assertTrue(OrgInstructorRole(org).has_user(self.student)) # noqa: PT009
466+
self.assertTrue(self.student.has_perm(instructor_permissions.VIEW_DASHBOARD, marvel_course_key)) # noqa: PT009
467+
self.assertTrue(self.student.has_perm(instructor_permissions.VIEW_DASHBOARD, dc_course_key)) # noqa: PT009
468+
412469

413470
@ddt.ddt
414471
class RoleCacheTestCase(TestCase): # pylint: disable=missing-class-docstring

0 commit comments

Comments
 (0)