Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 158 additions & 12 deletions cms/djangoapps/contentstore/tests/test_course_listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from ccx_keys.locator import CCXLocator
from django.test import RequestFactory
from opaque_keys.edx.locations import CourseLocator
from openedx_authz.api.data import OrgCourseOverviewGlobData
from openedx_authz.api.data import OrgCourseOverviewGlobData, PlatformCourseOverviewGlobData
from openedx_authz.api.users import assign_role_to_user_in_scope
from openedx_authz.constants.roles import COURSE_DATA_RESEARCHER, COURSE_EDITOR, COURSE_STAFF

Expand All @@ -21,6 +21,7 @@
_accessible_courses_iter_for_tests,
_accessible_courses_list_from_groups,
_accessible_courses_summary_iter,
_get_course_keys_from_scopes,
get_courses_accessible_to_user,
)
from common.djangoapps.course_action_state.models import CourseRerunState
Expand Down Expand Up @@ -434,8 +435,11 @@ def _create_course(self, course_key):

return CourseOverviewFactory.create(id=course.id, org=course_key.org)

def _mock_authz_toggle(self, enabled_keys):
def _mock_authz_toggle(self, enabled_keys, global_enabled=False):
"""Return a mock is_enabled side effect for AUTHZ_COURSE_AUTHORING_FLAG."""
def _is_enabled(course_key=None, **_):
if course_key is None:
return global_enabled
return str(course_key) in enabled_keys
return _is_enabled

Expand Down Expand Up @@ -724,11 +728,10 @@ def test_course_listing_with_org_scope(self):
the AuthZ course authoring toggle is enabled.
"""
_, _, authz_courses, legacy_courses = self._create_courses()
org_scope = OrgCourseOverviewGlobData(external_key='course-v1:Org1+*')
assign_role_to_user_in_scope(
self.authorized_user.username,
COURSE_STAFF.external_key,
org_scope.external_key,
OrgCourseOverviewGlobData.build_external_key("Org1"),
)

request = self._make_request(self.authorized_user)
Expand Down Expand Up @@ -757,11 +760,10 @@ def test_course_listing_with_org_scope_with_toggle(self):
authz_keys, _, _, _ = self._create_courses()
# enable only the first and third course keys
enabled_keys = {str(authz_keys[0]), str(authz_keys[2])}
org_scope = OrgCourseOverviewGlobData(external_key='course-v1:Org1+*')
assign_role_to_user_in_scope(
self.authorized_user.username,
COURSE_STAFF.external_key,
org_scope.external_key,
OrgCourseOverviewGlobData.build_external_key("Org1"),
)

request = self._make_request(self.authorized_user)
Expand All @@ -784,11 +786,10 @@ def test_course_listing_with_org_scope_without_courses(self):
courses, `get_courses_accessible_to_user` should return an empty
list.
"""
org_scope = OrgCourseOverviewGlobData(external_key='course-v1:Org2+*')
assign_role_to_user_in_scope(
self.authorized_user.username,
COURSE_STAFF.external_key,
org_scope.external_key,
OrgCourseOverviewGlobData.build_external_key("Org2"),
)

request = self._make_request(self.authorized_user)
Expand All @@ -806,17 +807,15 @@ def test_course_listing_with_org_scope_fetched_once(self):
"""
Verify that course overviews are fetched once with all authorized orgs.
"""
org_scope1 = OrgCourseOverviewGlobData(external_key='course-v1:Org1+*')
org_scope2 = OrgCourseOverviewGlobData(external_key='course-v1:Org2+*')
assign_role_to_user_in_scope(
self.authorized_user.username,
COURSE_STAFF.external_key,
org_scope1.external_key,
OrgCourseOverviewGlobData.build_external_key("Org1"),
)
assign_role_to_user_in_scope(
self.authorized_user.username,
COURSE_STAFF.external_key,
org_scope2.external_key,
OrgCourseOverviewGlobData.build_external_key("Org2"),
)

request = self._make_request(self.authorized_user)
Expand All @@ -832,3 +831,150 @@ def test_course_listing_with_org_scope_fetched_once(self):
courses, _ = get_courses_accessible_to_user(request)

mock_get_all_courses.assert_called_once_with(orgs={"Org1", "Org2"})

def test_course_listing_with_platform_scope(self):
"""
Verify that a platform-wide scope (`course-v1:*`) grants access to all
courses across orgs when the AuthZ course authoring toggle is enabled.
"""
_, _, authz_courses, legacy_courses = self._create_courses()
org2_course_key = CourseLocator("Org2", "Course1", "AuthzRun")
org2_course = self._create_course(org2_course_key)
assign_role_to_user_in_scope(
self.authorized_user.username,
COURSE_STAFF.external_key,
PlatformCourseOverviewGlobData.build_external_key(),
)

request = self._make_request(self.authorized_user)

with patch.object(
core_toggles.AUTHZ_COURSE_AUTHORING_FLAG,
"is_enabled",
return_value=True,
):
courses, _ = get_courses_accessible_to_user(request)

result_ids = {c.id for c in courses}
expected_ids = {
*(c.id for c in authz_courses),
*(c.id for c in legacy_courses),
org2_course.id
}

assert result_ids == expected_ids

def test_course_listing_with_platform_scope_with_toggle(self):
"""
If the global authz toggle is disabled and only a subset of courses have
the per-course toggle enabled, only those course keys should appear when
resolving a platform-wide scope.
"""
authz_keys, _, _, _ = self._create_courses()
org2_course_key = CourseLocator("Org2", "Course1", "AuthzRun")
self._create_course(org2_course_key)
enabled_keys = {str(authz_keys[0]), str(authz_keys[2])}
assign_role_to_user_in_scope(
self.authorized_user.username,
COURSE_STAFF.external_key,
PlatformCourseOverviewGlobData.build_external_key(),
)

request = self._make_request(self.authorized_user)

with patch.object(
core_toggles.AUTHZ_COURSE_AUTHORING_FLAG,
"is_enabled",
side_effect=self._mock_authz_toggle(enabled_keys),
):
courses, _ = get_courses_accessible_to_user(request)

result_ids = {c.id for c in courses}
expected = {authz_keys[0], authz_keys[2]}

assert result_ids == expected

def test_course_listing_with_platform_scope_global_flag_enabled(self):
"""
When the global AuthZ toggle is enabled, platform scope should return all
courses without validating the per-course toggle.
"""
authz_keys, legacy_keys, authz_courses, legacy_courses = self._create_courses()
org2_course_key = CourseLocator("Org2", "Course1", "AuthzRun")
org2_course = self._create_course(org2_course_key)
enabled_keys = {str(authz_keys[0])}
assign_role_to_user_in_scope(
self.authorized_user.username,
COURSE_STAFF.external_key,
PlatformCourseOverviewGlobData.build_external_key(),
)

request = self._make_request(self.authorized_user)

with patch.object(
core_toggles.AUTHZ_COURSE_AUTHORING_FLAG,
"is_enabled",
side_effect=self._mock_authz_toggle(enabled_keys, global_enabled=True),
):
courses, _ = get_courses_accessible_to_user(request)

result_ids = {c.id for c in courses}
expected_ids = {*(c.id for c in authz_courses), *(c.id for c in legacy_courses), org2_course.id}

assert result_ids == expected_ids

def test_get_course_keys_from_scopes_with_platform_scope(self):
"""
Platform-wide scopes should resolve to all courses with AuthZ enabled
when the global toggle is disabled.
"""
authz_keys, legacy_keys, _, _ = self._create_courses()
enabled_keys = {str(key) for key in authz_keys + legacy_keys}

with patch.object(
core_toggles.AUTHZ_COURSE_AUTHORING_FLAG,
"is_enabled",
side_effect=self._mock_authz_toggle(enabled_keys),
):
course_keys = _get_course_keys_from_scopes([PlatformCourseOverviewGlobData(external_key="course-v1:*")])

assert course_keys == set(authz_keys) | set(legacy_keys)

def test_get_course_keys_from_scopes_with_platform_scope_global_flag_enabled(self):
"""
Platform-wide scopes should return all courses when the global AuthZ toggle
is enabled, regardless of per-course toggle state.
"""
authz_keys, legacy_keys, _, _ = self._create_courses()
enabled_keys = {str(authz_keys[0])}

with patch.object(
core_toggles.AUTHZ_COURSE_AUTHORING_FLAG,
"is_enabled",
side_effect=self._mock_authz_toggle(enabled_keys, global_enabled=True),
):
course_keys = _get_course_keys_from_scopes([PlatformCourseOverviewGlobData(external_key="course-v1:*")])

assert course_keys == set(CourseOverview.get_all_courses().values_list("id", flat=True))

def test_get_course_keys_from_scopes_platform_scope_short_circuits(self):
"""
When a platform-wide scope is present, org and course scopes should be
ignored and only the platform scope resolution should apply.
"""
authz_keys, _, _, _ = self._create_courses()
enabled_keys = {str(authz_keys[0])}

with patch.object(
core_toggles.AUTHZ_COURSE_AUTHORING_FLAG,
"is_enabled",
side_effect=self._mock_authz_toggle(enabled_keys),
):
course_keys = _get_course_keys_from_scopes(
[
OrgCourseOverviewGlobData(external_key="course-v1:Org1+*"),
PlatformCourseOverviewGlobData(external_key="course-v1:*"),
]
)

assert course_keys == {authz_keys[0]}
96 changes: 84 additions & 12 deletions cms/djangoapps/contentstore/views/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from django.core.exceptions import FieldError, ImproperlyConfigured, PermissionDenied
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db.models import QuerySet
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.translation import gettext as _
Expand All @@ -29,7 +29,12 @@
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import BlockUsageLocator
from openedx_authz.api import get_scopes_for_user_and_permission
from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData, ScopeData
from openedx_authz.api.data import (
CourseOverviewData,
OrgCourseOverviewGlobData,
PlatformCourseOverviewGlobData,
ScopeData,
)
from openedx_authz.constants.permissions import (
COURSES_MANAGE_COURSE_UPDATES,
COURSES_MANAGE_GROUP_CONFIGURATIONS,
Expand Down Expand Up @@ -62,6 +67,7 @@
has_studio_write_access,
is_content_creator,
)
from common.djangoapps.student.models.user import CourseAccessRole
from common.djangoapps.student.roles import (
CourseInstructorRole,
CourseStaffRole,
Expand Down Expand Up @@ -832,25 +838,68 @@ def _get_course_keys_for_org_scope(org_keys: set[str]):

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

def _get_course_keys_from_scopes(authz_scopes: list[ScopeData]):

def _get_course_keys_from_platform_scope() -> set[CourseKey]:
"""
Resolve course keys for a platform-wide Authz scope.

When the AuthZ course authoring feature flag is globally enabled, all courses
are returned without per-course validation. Otherwise, only courses with the
per-course toggle enabled are included.

Returns:
set[CourseKey]: Course keys accessible on the platform.
"""
course_keys = CourseOverview.get_all_courses().values_list("id", flat=True)

if core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled():
return set(course_keys)

return {course_key for course_key in course_keys if core_toggles.enable_authz_course_authoring(course_key)}


def _get_course_keys_from_scopes(authz_scopes: list[ScopeData]) -> set[CourseKey]:
"""
Convert a set of Authz scopes into specific course keys.
Convert authorization scopes into a set of accessible course keys.

This function processes authorization scopes with the following precedence:
1. Platform-wide access (PlatformCourseOverviewGlobData): Returns all courses
when the AuthZ course authoring toggle is globally enabled; otherwise only
courses with the per-course toggle enabled
2. Course-specific access (CourseOverviewData): Returns individual course keys
3. Organization-wide access (OrgCourseOverviewGlobData): Returns all courses in specified orgs

For non-platform scopes, only courses with the authz course authoring toggle
enabled are included.

Args:
authz_scopes: List of authorization scope data objects from the authz system.

Returns:
set[CourseKey]: Set of course keys the user has access to based on their scopes.
"""
if any(isinstance(access, PlatformCourseOverviewGlobData) for access in authz_scopes):
return _get_course_keys_from_platform_scope()

course_keys = set()
org_keys = set()

for access in authz_scopes:
if isinstance(access, CourseOverviewData) and access.course_key:
if core_toggles.enable_authz_course_authoring(access.course_key):
course_keys.add(access.course_key)
elif isinstance(access, OrgCourseOverviewGlobData) and access.org:
org_keys.add(access.org)

if org_keys:
course_keys.update(
key for key in _get_course_keys_for_org_scope(org_keys)
if core_toggles.enable_authz_course_authoring(key)
)

return course_keys


def _get_authz_accessible_courses_list(request):
"""
List all courses available to the logged in user by
Expand All @@ -864,20 +913,43 @@ def _get_authz_accessible_courses_list(request):

return _get_course_keys_from_scopes(authz_scopes)

def _get_legacy_accessible_courses_list(request):

def _get_legacy_accessible_courses_list(request: HttpRequest) -> set[CourseKey]:
"""
List all courses available to the logged in user by
evaluating legacy Django group roles and organization-level access.
Resolve candidate course keys from legacy ``CourseAccessRole`` records.

Only database-backed legacy roles are considered. AuthZ-managed access,
including org-wide scopes, is resolved separately by
``_get_authz_accessible_courses_list``.

Course-level roles (``instructor``, ``staff``) are mapped directly to their
course keys. Org-wide roles expand to every course in that organization via
a single ``CourseOverview.get_all_courses(orgs=...)`` query. The ``staff``
role is matched exactly, so ``limited_staff`` assignments are excluded.

Args:
request: The incoming HTTP request; ``request.user`` determines which
legacy role records are evaluated.

Returns:
set[CourseKey]: Course keys the user may access through legacy roles.

Raises:
AccessListFallback: If a legacy role record has neither a course key nor
an organization
"""
user = request.user
instructor_courses = UserBasedRole(user, CourseInstructorRole.ROLE).courses_with_role()

with strict_role_checking():
staff_courses = UserBasedRole(user, CourseStaffRole.ROLE).courses_with_role()
# Query CourseAccessRole directly instead of UserBasedRole.courses_with_role(),
# which merges legacy DB records with AuthZ assignments. AuthZ access is resolved
# separately in _get_authz_accessible_courses_list(). Exact role names (not
# RoleCache inheritance) exclude limited_staff, matching strict_role_checking().
legacy_accesses = CourseAccessRole.objects.filter(
user=user,
role__in=[CourseInstructorRole.ROLE, CourseStaffRole.ROLE],
)

group_keys = set()
org_accesses = set()
legacy_accesses = instructor_courses | staff_courses

for access in legacy_accesses:
if access.course_id is not None:
Expand Down
Loading
Loading