Skip to content

Commit d252145

Browse files
committed
feat: enhance role assignment handling for users with org-wide scopes
1 parent 3d0f9c0 commit d252145

4 files changed

Lines changed: 193 additions & 72 deletions

File tree

cms/djangoapps/contentstore/views/course.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from django.core.exceptions import FieldError, ImproperlyConfigured, PermissionDenied
1818
from django.core.exceptions import ValidationError as DjangoValidationError
1919
from django.db.models import QuerySet
20-
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
20+
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
2121
from django.shortcuts import redirect
2222
from django.urls import reverse
2323
from django.utils.translation import gettext as _
@@ -62,6 +62,7 @@
6262
has_studio_write_access,
6363
is_content_creator,
6464
)
65+
from common.djangoapps.student.models.user import CourseAccessRole
6566
from common.djangoapps.student.roles import (
6667
CourseInstructorRole,
6768
CourseStaffRole,
@@ -823,6 +824,7 @@ def _get_course_keys_for_org_scope(org_keys: set[str]):
823824

824825
return CourseOverview.get_all_courses(orgs=org_keys).values_list('id', flat=True)
825826

827+
826828
def _get_course_keys_from_scopes(authz_scopes: list[ScopeData]):
827829
"""
828830
Convert a set of Authz scopes into specific course keys.
@@ -842,6 +844,7 @@ def _get_course_keys_from_scopes(authz_scopes: list[ScopeData]):
842844
)
843845
return course_keys
844846

847+
845848
def _get_authz_accessible_courses_list(request):
846849
"""
847850
List all courses available to the logged in user by
@@ -855,20 +858,39 @@ def _get_authz_accessible_courses_list(request):
855858

856859
return _get_course_keys_from_scopes(authz_scopes)
857860

858-
def _get_legacy_accessible_courses_list(request):
861+
862+
def _get_legacy_accessible_courses_list(request: HttpRequest) -> set[CourseKey]:
859863
"""
860-
List all courses available to the logged in user by
861-
evaluating legacy Django group roles and organization-level access.
864+
Resolve candidate course keys from legacy ``CourseAccessRole`` records.
865+
866+
Only database-backed legacy roles are considered. AuthZ-managed access,
867+
including org-wide scopes, is resolved separately by
868+
``_get_authz_accessible_courses_list``.
869+
870+
Course-level roles (``instructor``, ``staff``) are mapped directly to their
871+
course keys. Org-wide roles expand to every course in that organization via
872+
a single ``CourseOverview.get_all_courses(orgs=...)`` query. The ``staff``
873+
role is matched exactly, so ``limited_staff`` assignments are excluded.
874+
875+
Args:
876+
request: The incoming HTTP request; ``request.user`` determines which
877+
legacy role records are evaluated.
878+
879+
Returns:
880+
set[CourseKey]: Course keys the user may access through legacy roles.
881+
882+
Raises:
883+
AccessListFallback: If a legacy role record has neither a course key nor
884+
an organization
862885
"""
863886
user = request.user
864-
instructor_courses = UserBasedRole(user, CourseInstructorRole.ROLE).courses_with_role()
865-
866-
with strict_role_checking():
867-
staff_courses = UserBasedRole(user, CourseStaffRole.ROLE).courses_with_role()
887+
legacy_accesses = CourseAccessRole.objects.filter(
888+
user=user,
889+
role__in=[CourseInstructorRole.ROLE, CourseStaffRole.ROLE],
890+
)
868891

869892
group_keys = set()
870893
org_accesses = set()
871-
legacy_accesses = instructor_courses | staff_courses
872894

873895
for access in legacy_accesses:
874896
if access.course_id is not None:

common/djangoapps/student/roles.py

Lines changed: 97 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
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, RoleAssignmentData
18+
from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData, RoleAssignmentData
1919
from openedx_authz.constants import roles as authz_roles
2020

2121
from common.djangoapps.student.models import CourseAccessRole
@@ -74,17 +74,6 @@ def authz_add_role(user: User, authz_role: str, course_key: str):
7474
legacy_role = get_legacy_role_from_authz_role(authz_role)
7575
emit_course_access_role_added(user, course_locator, course_locator.org, legacy_role)
7676

77-
def authz_get_all_course_assignments_for_user(user: User) -> list[RoleAssignmentData]:
78-
"""
79-
Get all course assignments for a user.
80-
"""
81-
assignments = authz_api.get_user_role_assignments(user_external_key=user.username)
82-
# filter courses only
83-
filtered_assignments = [
84-
assignment for assignment in assignments
85-
if isinstance(assignment.scope, CourseOverviewData)
86-
]
87-
return filtered_assignments
8877

8978
def get_org_from_key(key: str) -> str:
9079
"""
@@ -93,6 +82,7 @@ def get_org_from_key(key: str) -> str:
9382
parsed_key = CourseKey.from_string(key)
9483
return parsed_key.org
9584

85+
9686
def register_access_role(cls):
9787
"""
9888
Decorator that allows access roles to be registered within the roles module and referenced by their
@@ -141,34 +131,108 @@ class AuthzCompatCourseAccessRole:
141131
"""
142132
Generic data class for storing CourseAccessRole-compatible data
143133
to be used inside BulkRoleCache and RoleCache.
134+
144135
This allows the cache to store both legacy and openedx-authz compatible roles
145136
"""
146137
user_id: int
147138
username: str
148139
org: str
149-
course_id: str # Course key
140+
course_id: str | None
150141
role: str
151142

152143

153-
def get_authz_compat_course_access_roles_for_user(user: User) -> set[AuthzCompatCourseAccessRole]:
144+
def authz_get_all_course_assignments_for_user(user: User) -> list[RoleAssignmentData]:
145+
"""
146+
Return AuthZ role assignments for a user that apply to courses.
147+
148+
Includes assignments scoped to a specific course (``CourseOverviewData``) and
149+
assignments scoped to all courses in an organization (``OrgCourseOverviewGlobData``).
150+
Assignments for other resource types, such as content libraries, are excluded.
151+
152+
Args:
153+
user (User): The user whose AuthZ role assignments should be retrieved.
154+
155+
Returns:
156+
list[RoleAssignmentData]: Role assignments whose scope is course-level or org-wide
154157
"""
155-
Retrieve all CourseAccessRole objects for a given user and convert them to AuthzCompatCourseAccessRole objects.
158+
assignments = authz_api.get_user_role_assignments(user_external_key=user.username)
159+
return [
160+
assignment
161+
for assignment in assignments
162+
if isinstance(assignment.scope, CourseOverviewData | OrgCourseOverviewGlobData)
163+
]
164+
165+
166+
def _compat_roles_from_authz_assignment(
167+
user: User,
168+
assignment: RoleAssignmentData,
169+
) -> set[AuthzCompatCourseAccessRole]:
156170
"""
157-
compat_role_assignments = set()
158-
assignments = authz_get_all_course_assignments_for_user(user)
159-
for assignment in assignments:
160-
for role in assignment.roles:
161-
legacy_role = get_legacy_role_from_authz_role(authz_role=role.external_key)
162-
course_key = assignment.scope.external_key
163-
org = get_org_from_key(course_key)
164-
compat_role = AuthzCompatCourseAccessRole(
171+
Convert an AuthZ role assignment into legacy-compatible course access roles.
172+
173+
Course-scoped assignments produce roles tied to a specific course key.
174+
Org-wide assignments produce org-level roles with no course key (``course_id``
175+
is ``None``), matching legacy ``OrgStaffRole`` / ``OrgInstructorRole`` behavior.
176+
AuthZ roles without a legacy mapping are skipped.
177+
178+
Args:
179+
user (User): The user associated with the assignment.
180+
assignment (RoleAssignmentData): A single AuthZ role assignment, including
181+
its scope and assigned roles.
182+
183+
Returns:
184+
set[AuthzCompatCourseAccessRole]: Legacy-compatible role records for the
185+
assignment. Returns an empty set if the scope is unsupported, the org
186+
is missing for an org-wide assignment, or no roles could be mapped.
187+
"""
188+
scope = assignment.scope
189+
if isinstance(scope, CourseOverviewData):
190+
course_id = scope.external_key
191+
org = get_org_from_key(course_id)
192+
elif isinstance(scope, OrgCourseOverviewGlobData):
193+
org = scope.org
194+
if not org:
195+
return set()
196+
course_id = None
197+
else:
198+
return set()
199+
200+
compat_roles = set()
201+
for role in assignment.roles:
202+
legacy_role = get_legacy_role_from_authz_role(authz_role=role.external_key)
203+
if legacy_role is None:
204+
continue
205+
compat_roles.add(
206+
AuthzCompatCourseAccessRole(
165207
user_id=user.id,
166208
username=user.username,
167209
org=org,
168-
course_id=course_key,
169-
role=legacy_role
210+
course_id=course_id,
211+
role=legacy_role,
170212
)
171-
compat_role_assignments.add(compat_role)
213+
)
214+
return compat_roles
215+
216+
217+
def get_authz_compat_course_access_roles_for_user(user: User) -> set[AuthzCompatCourseAccessRole]:
218+
"""
219+
Retrieve AuthZ course and org role assignments for a user in legacy format.
220+
221+
Fetches all course-level and org-wide AuthZ assignments for the user and
222+
converts each one into ``AuthzCompatCourseAccessRole`` records suitable for
223+
``RoleCache`` and other legacy permission checks.
224+
225+
Args:
226+
user (User): The user whose AuthZ role assignments should be converted.
227+
228+
Returns:
229+
set[AuthzCompatCourseAccessRole]: Legacy-compatible role records derived
230+
from the user's AuthZ assignments. Returns an empty set if the user
231+
has no applicable assignments.
232+
"""
233+
compat_role_assignments = set()
234+
for assignment in authz_get_all_course_assignments_for_user(user):
235+
compat_role_assignments.update(_compat_roles_from_authz_assignment(user, assignment))
172236
return compat_role_assignments
173237

174238

@@ -843,11 +907,9 @@ def courses_with_role(self) -> set[AuthzCompatCourseAccessRole]:
843907
"""
844908
Return a set of AuthzCompatCourseAccessRole for all of the courses with this user x (or derived from x) role.
845909
"""
910+
# Get all assignments for a user to a role
846911
roles = RoleCache.get_roles(self.role)
847912
legacy_assignments = CourseAccessRole.objects.filter(role__in=roles, user=self.user)
848-
849-
# Get all assignments for a user to a role
850-
new_authz_roles = [get_authz_role_from_legacy_role(role) for role in roles]
851913
all_authz_user_assignments = authz_get_all_course_assignments_for_user(self.user)
852914

853915
all_assignments = set()
@@ -863,19 +925,9 @@ def courses_with_role(self) -> set[AuthzCompatCourseAccessRole]:
863925
))
864926

865927
for assignment in all_authz_user_assignments:
866-
for role in assignment.roles:
867-
if role.external_key not in new_authz_roles:
868-
continue
869-
legacy_role = get_legacy_role_from_authz_role(authz_role=role.external_key)
870-
course_key = assignment.scope.external_key
871-
org = get_org_from_key(course_key)
872-
all_assignments.add(AuthzCompatCourseAccessRole(
873-
user_id=self.user.id,
874-
username=self.user.username,
875-
org=org,
876-
course_id=course_key,
877-
role=legacy_role
878-
))
928+
for compat_role in _compat_roles_from_authz_assignment(self.user, assignment):
929+
if compat_role.role in roles:
930+
all_assignments.add(compat_role)
879931

880932
return all_assignments
881933

@@ -899,18 +951,12 @@ def has_courses_with_role(self, org: str | None = None) -> bool:
899951
return True
900952

901953
# Then check for authz assignments
902-
new_authz_roles = [get_authz_role_from_legacy_role(role) for role in roles]
903954
all_authz_user_assignments = authz_get_all_course_assignments_for_user(self.user)
904955

905956
for assignment in all_authz_user_assignments:
906-
for role in assignment.roles:
907-
if role.external_key not in new_authz_roles:
957+
for compat_role in _compat_roles_from_authz_assignment(self.user, assignment):
958+
if compat_role.role not in roles:
908959
continue
909-
if org is None:
910-
# There is at least one assignment, short circuit
911-
return True
912-
course_key = assignment.scope.external_key
913-
parsed_org = get_org_from_key(course_key)
914-
if org == parsed_org:
960+
if org is None or org == compat_role.org:
915961
return True
916962
return False

common/djangoapps/student/tests/test_roles.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010
from edx_toggles.toggles.testutils import override_waffle_flag
1111
from opaque_keys.edx.keys import CourseKey
1212
from opaque_keys.edx.locator import LibraryLocator
13-
from openedx_authz.api.data import ContentLibraryData, CourseOverviewData, RoleAssignmentData, RoleData, UserData
13+
from openedx_authz.api.data import (
14+
ContentLibraryData,
15+
CourseOverviewData,
16+
OrgCourseOverviewGlobData,
17+
RoleAssignmentData,
18+
RoleData,
19+
UserData,
20+
)
1421
from openedx_authz.constants.roles import COURSE_ADMIN, COURSE_STAFF
1522
from openedx_authz.engine.enforcer import AuthzEnforcer
1623

@@ -39,6 +46,7 @@
3946
get_role_cache_key_for_course,
4047
)
4148
from common.djangoapps.student.tests.factories import AnonymousUserFactory, InstructorFactory, StaffFactory, UserFactory
49+
from lms.djangoapps.instructor import permissions as instructor_permissions
4250
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
4351
from openedx.core.toggles import AUTHZ_COURSE_AUTHORING_FLAG
4452

@@ -298,6 +306,50 @@ def test_get_authz_compat_course_access_roles_for_user(self):
298306
result = get_authz_compat_course_access_roles_for_user(self.student)
299307
assert result == set()
300308

309+
def test_get_authz_compat_course_access_roles_for_user_org_scope(self):
310+
"""
311+
Org-wide AuthZ assignments should map to legacy org-level course access roles.
312+
"""
313+
org_scope = OrgCourseOverviewGlobData(external_key="course-v1:OpenedX+*")
314+
assignment = RoleAssignmentData(
315+
subject=UserData(external_key=self.student.username),
316+
roles=[RoleData(external_key=COURSE_ADMIN.external_key)],
317+
scope=org_scope,
318+
)
319+
with patch("openedx_authz.api.users.get_user_role_assignments", return_value=[assignment]):
320+
result = get_authz_compat_course_access_roles_for_user(self.student)
321+
322+
assert result == {
323+
AuthzCompatCourseAccessRole(
324+
user_id=self.student.id,
325+
username=self.student.username,
326+
org="OpenedX",
327+
course_id=None,
328+
role="instructor",
329+
)
330+
}
331+
332+
def test_org_scope_authz_role_grants_instructor_dashboard_permissions(self):
333+
"""
334+
Org-wide AuthZ course_admin should grant legacy org instructor access used by the instructor dashboard.
335+
"""
336+
course_key = CourseKey.from_string("course-v1:OpenedX+DemoX+DemoCourse")
337+
org_scope = OrgCourseOverviewGlobData(external_key="course-v1:OpenedX+*")
338+
assignment = RoleAssignmentData(
339+
subject=UserData(external_key=self.student.username),
340+
roles=[RoleData(external_key=COURSE_ADMIN.external_key)],
341+
scope=org_scope,
342+
)
343+
with patch("openedx_authz.api.users.get_user_role_assignments", return_value=[assignment]):
344+
if hasattr(self.student, "_roles"):
345+
del self.student._roles
346+
cache = RoleCache(self.student)
347+
348+
assert cache.has_role("instructor", None, "OpenedX")
349+
assert OrgInstructorRole("OpenedX").has_user(self.student)
350+
assert self.student.has_perm(instructor_permissions.VIEW_DASHBOARD, course_key)
351+
assert self.student.has_perm(instructor_permissions.SHOW_TASKS, course_key)
352+
301353

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

0 commit comments

Comments
 (0)