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
94 changes: 93 additions & 1 deletion 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 @@ -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
28 changes: 27 additions & 1 deletion cms/djangoapps/contentstore/views/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 @@ -823,25 +828,46 @@ 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)
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 Down
18 changes: 11 additions & 7 deletions common/djangoapps/student/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)


Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -834,7 +834,7 @@ openedx-atlas==0.7.0
# enterprise-integrated-channels
# openedx-authz
# openedx-forum
openedx-authz==1.15.0
openedx-authz==1.16.0
# via -r requirements/edx/kernel.in
openedx-calc==5.0.0
# via
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1374,7 +1374,7 @@ openedx-atlas==0.7.0
# enterprise-integrated-channels
# openedx-authz
# openedx-forum
openedx-authz==1.15.0
openedx-authz==1.16.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +1012,7 @@ openedx-atlas==0.7.0
# enterprise-integrated-channels
# openedx-authz
# openedx-forum
openedx-authz==1.15.0
openedx-authz==1.16.0
# via -r requirements/edx/base.txt
openedx-calc==5.0.0
# via
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1052,7 +1052,7 @@ openedx-atlas==0.7.0
# enterprise-integrated-channels
# openedx-authz
# openedx-forum
openedx-authz==1.15.0
openedx-authz==1.16.0
# via -r requirements/edx/base.txt
openedx-calc==5.0.0
# via
Expand Down
Loading