From 6fb11c7c06fe1a122b1b22f02239bb7f657deda0 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 19 May 2026 18:11:06 -0500 Subject: [PATCH 1/7] feat: add platform-wide authz scope handling for course keys --- cms/djangoapps/contentstore/views/course.py | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 569871b95bea..a85848ec1d0a 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -29,7 +29,7 @@ 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,18 +823,38 @@ 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_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]): """ Convert a set of Authz scopes into specific course keys. """ 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) + elif isinstance(access, PlatformCourseOverviewGlobData): + return _get_course_keys_from_platform_scope() + if org_keys: course_keys.update( key for key in _get_course_keys_for_org_scope(org_keys) @@ -842,6 +862,7 @@ def _get_course_keys_from_scopes(authz_scopes: list[ScopeData]): ) return course_keys + def _get_authz_accessible_courses_list(request): """ List all courses available to the logged in user by From 273eff3a19f66fe0fa70c45b6805ae50d16fa9c9 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 19 May 2026 18:24:41 -0500 Subject: [PATCH 2/7] feat: enhance content creator access check with platform-wide scope handling --- common/djangoapps/student/auth.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 ) From 33cadac02046698a0fab87c87534ff08b0726a7a Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 19 May 2026 18:29:53 -0500 Subject: [PATCH 3/7] style: sort imports --- cms/djangoapps/contentstore/views/course.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index a85848ec1d0a..5fa252e2104a 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, PlatformCourseOverviewGlobData, 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, From 8c1070b76df7b257aa8ea3c409fa986ebd5a89bf Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 20 May 2026 16:03:48 -0500 Subject: [PATCH 4/7] feat: add tests for platform-wide scope handling in course listing authorization --- .../contentstore/tests/test_course_listing.py | 94 ++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index 9a56b8ec7c76..6c03a973414a 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,94 @@ 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, _ = 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 self._authz_waffle_context(active=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), 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, _, _, _ = self._create_courses() + org2_course_key = CourseLocator("Org2", "Course1", "AuthzRun") + self._create_course(org2_course_key) + enabled_keys = {str(key) for key in authz_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.build_external_key()]) + + self.assertEqual(course_keys, set(authz_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.build_external_key("Org1"), + PlatformCourseOverviewGlobData.build_external_key(), + ] + ) + + self.assertEqual(course_keys, {authz_keys[0]}) # noqa: PT009 From 659939ce7a774a833fbdbfe862bd28a204573b63 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Mon, 25 May 2026 17:15:20 -0500 Subject: [PATCH 5/7] chore: address pr review --- cms/djangoapps/contentstore/views/course.py | 23 +++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 5fa252e2104a..d5530a7792d3 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -844,10 +844,26 @@ def _get_course_keys_from_platform_scope() -> set[CourseKey]: } -def _get_course_keys_from_scopes(authz_scopes: list[ScopeData]): +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() @@ -857,14 +873,13 @@ def _get_course_keys_from_scopes(authz_scopes: list[ScopeData]): course_keys.add(access.course_key) elif isinstance(access, OrgCourseOverviewGlobData) and access.org: org_keys.add(access.org) - elif isinstance(access, PlatformCourseOverviewGlobData): - return _get_course_keys_from_platform_scope() 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 From f62ff29ef0586ca4d24341b40c53d840350a79d4 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Mon, 25 May 2026 18:18:18 -0500 Subject: [PATCH 6/7] fix: update course listing authorization tests --- .../contentstore/tests/test_course_listing.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index 6c03a973414a..061fc895e23c 100644 --- a/cms/djangoapps/contentstore/tests/test_course_listing.py +++ b/cms/djangoapps/contentstore/tests/test_course_listing.py @@ -839,7 +839,7 @@ 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, _ = self._create_courses() + _, _, 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( @@ -850,11 +850,19 @@ def test_course_listing_with_platform_scope(self): request = self._make_request(self.authorized_user) - with self._authz_waffle_context(active=True): + 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), org2_course.id} + 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 @@ -890,19 +898,17 @@ def test_get_course_keys_from_scopes_with_platform_scope(self): """ Platform-wide scopes should resolve to all courses with AuthZ enabled. """ - authz_keys, _, _, _ = self._create_courses() - org2_course_key = CourseLocator("Org2", "Course1", "AuthzRun") - self._create_course(org2_course_key) - enabled_keys = {str(key) for key in authz_keys} + 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.build_external_key()]) + course_keys = _get_course_keys_from_scopes([PlatformCourseOverviewGlobData(external_key="course-v1:*")]) - self.assertEqual(course_keys, set(authz_keys)) # noqa: PT009 + self.assertEqual(course_keys, set(authz_keys) | set(legacy_keys)) # noqa: PT009 def test_get_course_keys_from_scopes_platform_scope_short_circuits(self): """ @@ -918,8 +924,8 @@ def test_get_course_keys_from_scopes_platform_scope_short_circuits(self): ): course_keys = _get_course_keys_from_scopes( [ - OrgCourseOverviewGlobData.build_external_key("Org1"), - PlatformCourseOverviewGlobData.build_external_key(), + OrgCourseOverviewGlobData(external_key="course-v1:Org1+*"), + PlatformCourseOverviewGlobData(external_key="course-v1:*"), ] ) From 4a0ebbb9212cbfa786e9df68fcdacd6bb38fbef7 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Thu, 4 Jun 2026 10:02:27 -0500 Subject: [PATCH 7/7] chore: update openedx-authz version to 1.17.0 --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index abb44195541c..2c26cede65e4 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -834,7 +834,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.16.0 +openedx-authz==1.17.0 # via -r requirements/edx/kernel.in openedx-calc==5.0.0 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 63b12fdaac6d..0a26c34f7811 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.16.0 +openedx-authz==1.17.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 0ff66dbfc655..75ef9868396a 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.16.0 +openedx-authz==1.17.0 # via -r requirements/edx/base.txt openedx-calc==5.0.0 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index f9e487a57971..6e42ab8c0a71 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.16.0 +openedx-authz==1.17.0 # via -r requirements/edx/base.txt openedx-calc==5.0.0 # via