diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index 9a56b8ec7c76..061fc895e23c 100644 --- a/cms/djangoapps/contentstore/tests/test_course_listing.py +++ b/cms/djangoapps/contentstore/tests/test_course_listing.py @@ -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 @@ -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 @@ -832,3 +833,100 @@ 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 + } + + self.assertEqual(result_ids, expected_ids) # noqa: PT009 + + def test_course_listing_with_platform_scope_with_toggle(self): + """ + If the authz toggle is enabled only for a subset of courses, 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]} + self.assertEqual(result_ids, expected) # noqa: PT009 + + def test_get_course_keys_from_scopes_with_platform_scope(self): + """ + Platform-wide scopes should resolve to all courses with AuthZ enabled. + """ + 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:*")]) + + self.assertEqual(course_keys, set(authz_keys) | set(legacy_keys)) # noqa: PT009 + + 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. + """ + 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:*"), + ] + ) + + self.assertEqual(course_keys, {authz_keys[0]}) # noqa: PT009 diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 569871b95bea..d5530a7792d3 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -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, @@ -823,25 +828,61 @@ 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. + + Returns: + set[CourseKey]: Course keys on the platform where the AuthZ course + authoring feature flag is enabled. + """ + return { + course_key + for course_key in CourseOverview.get_all_courses().values_list("id", flat=True) + 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 + 2. Course-specific access (CourseOverviewData): Returns individual course keys + 3. Organization-wide access (OrgCourseOverviewGlobData): Returns all courses in specified orgs + + 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 diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py index d7fdb0bcae3c..f7c3a20e0753 100644 --- a/common/djangoapps/student/auth.py +++ b/common/djangoapps/student/auth.py @@ -253,16 +253,20 @@ def is_content_creator(user, org): def _has_content_creator_access(user, org): """ Check if the user has content creator access based on AuthZ permissions. + + Returns: + bool: True if the user has platform-wide or org-scoped course creation permission. """ - if settings.FEATURES.get('DISABLE_COURSE_CREATION', False): + if settings.FEATURES.get("DISABLE_COURSE_CREATION", False): return False - # Using Org scope. e.g. "course-v1:{org}+*" - org_scope_key = authz_api.OrgCourseOverviewGlobData.build_external_key(org) - return authz_api.is_user_allowed( - user.username, - COURSES_CREATE_COURSE.identifier, - org_scope_key + scope_keys = ( + authz_api.PlatformCourseOverviewGlobData.build_external_key(), + authz_api.OrgCourseOverviewGlobData.build_external_key(org), + ) + return any( + authz_api.is_user_allowed(user.username, COURSES_CREATE_COURSE.identifier, scope_key) + for scope_key in scope_keys ) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 7bd90612d22b..489b3fa67a80 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -834,8 +834,8 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.15.0 - # via -r requirements/edx/kernel.in +openedx-authz @ git+https://github.com/openedx/openedx-authz.git@4aeb6d694a67e9cfac63784e1a7dd94c32573ea4 + # via -r requirements/edx/github.in openedx-calc==5.0.0 # via # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 24305b48d8b5..2c3d2fb2fb63 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1374,7 +1374,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.15.0 +openedx-authz @ git+https://github.com/openedx/openedx-authz.git@4aeb6d694a67e9cfac63784e1a7dd94c32573ea4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index f09d2947f481..b6b730814e13 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1012,7 +1012,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.15.0 +openedx-authz @ git+https://github.com/openedx/openedx-authz.git@4aeb6d694a67e9cfac63784e1a7dd94c32573ea4 # via -r requirements/edx/base.txt openedx-calc==5.0.0 # via diff --git a/requirements/edx/github.in b/requirements/edx/github.in index 7fdb2c051ce8..0eba91b7df3f 100644 --- a/requirements/edx/github.in +++ b/requirements/edx/github.in @@ -82,6 +82,9 @@ # ... add dependencies here +# https://github.com/openedx/openedx-authz/pull/289 +git+https://github.com/openedx/openedx-authz.git@4aeb6d694a67e9cfac63784e1a7dd94c32573ea4 + ############################################################################## # Critical fixes for packages that are not yet available in a PyPI release. ############################################################################## diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index 8abecd6ea77a..e29f1032ba80 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -159,4 +159,4 @@ XBlock[django] # Courseware component architecture xss-utils # https://github.com/openedx/edx-platform/pull/20633 Fix XSS via Translations unicodeit # Converts mathjax equation to plain text by using unicode symbols psycopg2-binary -openedx-authz # Authorization Framework for the Open edX Ecosystem +# openedx-authz # Authorization Framework for the Open edX Ecosystem diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index b1e08b4f4fad..08e18c83767b 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1052,7 +1052,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.15.0 +openedx-authz @ git+https://github.com/openedx/openedx-authz.git@4aeb6d694a67e9cfac63784e1a7dd94c32573ea4 # via -r requirements/edx/base.txt openedx-calc==5.0.0 # via