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
8 changes: 4 additions & 4 deletions cms/djangoapps/contentstore/tests/test_course_listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
_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 All @@ -39,6 +38,7 @@
)
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core import toggles as core_toggles
from openedx.core.djangoapps.authz.scopes import get_course_keys_from_scopes
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
Expand Down Expand Up @@ -976,7 +976,7 @@ def test_get_course_keys_from_scopes_with_platform_scope(self):
"is_enabled",
side_effect=self._mock_authz_toggle(enabled_keys),
):
course_keys = _get_course_keys_from_scopes([
course_keys = get_course_keys_from_scopes([
PlatformCourseOverviewGlobData(external_key=PlatformCourseOverviewGlobData.build_external_key())
])

Expand All @@ -995,7 +995,7 @@ def test_get_course_keys_from_scopes_with_platform_scope_global_flag_enabled(sel
"is_enabled",
side_effect=self._mock_authz_toggle(enabled_keys, global_enabled=True),
):
course_keys = _get_course_keys_from_scopes([
course_keys = get_course_keys_from_scopes([
PlatformCourseOverviewGlobData(external_key=PlatformCourseOverviewGlobData.build_external_key())
])

Expand All @@ -1014,7 +1014,7 @@ def test_get_course_keys_from_scopes_platform_scope_short_circuits(self):
"is_enabled",
side_effect=self._mock_authz_toggle(enabled_keys),
):
course_keys = _get_course_keys_from_scopes(
course_keys = get_course_keys_from_scopes(
[
OrgCourseOverviewGlobData(external_key=OrgCourseOverviewGlobData.build_external_key("Org1")),
PlatformCourseOverviewGlobData(external_key=PlatformCourseOverviewGlobData.build_external_key()),
Expand Down
79 changes: 2 additions & 77 deletions cms/djangoapps/contentstore/views/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,6 @@
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.constants.permissions import (
COURSES_MANAGE_COURSE_UPDATES,
COURSES_MANAGE_GROUP_CONFIGURATIONS,
Expand Down Expand Up @@ -76,14 +70,14 @@
GlobalStaff,
OrgStaffRole,
UserBasedRole,
enable_authz_course_authoring,
strict_role_checking,
)
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json
from common.djangoapps.util.string_utils import _has_non_ascii_characters
from openedx.core import toggles as core_toggles
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
from openedx.core.djangoapps.authz.scopes import get_course_keys_from_scopes
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
from openedx.core.djangoapps.models.course_details import CourseDetails
Expand Down Expand Up @@ -847,75 +841,6 @@ def filter_course(course):
return filter(filter_course, filtered_courses)


def _get_course_keys_for_org_scope(org_keys: set[str]):
"""
Convert a set of organization keys into specific course keys.
"""

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.

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 enable_authz_course_authoring(course_key)}


def _get_course_keys_from_scopes(authz_scopes: list[ScopeData]) -> set[CourseKey]:
"""
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 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 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 @@ -927,7 +852,7 @@ def _get_authz_accessible_courses_list(request):
COURSES_VIEW_COURSE.identifier
)

return _get_course_keys_from_scopes(authz_scopes)
return get_course_keys_from_scopes(authz_scopes)


def _get_legacy_accessible_courses_list(request: HttpRequest) -> set[CourseKey]:
Expand Down
81 changes: 81 additions & 0 deletions openedx/core/djangoapps/authz/scopes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Helpers for resolving openedx-authz scopes into concrete course keys."""
from opaque_keys.edx.keys import CourseKey
from openedx_authz.api.data import (
CourseOverviewData,
OrgCourseOverviewGlobData,
PlatformCourseOverviewGlobData,
ScopeData,
)

from common.djangoapps.student.roles import enable_authz_course_authoring
from openedx.core import toggles as core_toggles
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview


def _get_course_keys_for_org_scope(org_keys: set[str]):
"""
Convert a set of organization keys into specific course keys.
"""

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.

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 enable_authz_course_authoring(course_key)}


def get_course_keys_from_scopes(authz_scopes: list[ScopeData]) -> set[CourseKey]:
"""
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 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 enable_authz_course_authoring(key)
)

return course_keys
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def filter_queryset(self, request, queryset, _):
return queryset

user_admin_orgs = get_admin_orgs(request.user)
user_orgs = get_user_orgs(request.user) # Orgs that the user is a content creator or instructor
user_orgs = get_user_orgs(request.user)

if len(user_orgs) == 0 and len(user_admin_orgs) == 0:
return queryset.none()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
OrgStaffRole,
)
from common.djangoapps.student.tests.factories import StaffFactory, UserFactory
from openedx.core import toggles as core_toggles
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthzTestMixin
from openedx.core.djangoapps.content_libraries.api import AccessLevel, create_library, set_library_user_permissions
from openedx.core.djangoapps.content_tagging import api as tagging_api
Expand Down Expand Up @@ -586,12 +587,14 @@ def test_create_taxonomy_rejects_invalid_type(self) -> None:

@ddt.data(
('staff', 10),
("content_creatorA", 22),
("library_staffA", 22),
("library_userA", 22),
("instructorA", 22),
("course_instructorA", 22),
("course_staffA", 22),
# Non-admin users now also cost one extra query to check for orgs granted through
# openedx-authz's courses.manage_tags (_get_authz_manage_tags_orgs, via get_user_orgs).
("content_creatorA", 23),
("library_staffA", 23),
("library_userA", 23),
("instructorA", 23),
("course_instructorA", 25),
("course_staffA", 25),
)
@ddt.unpack
def test_list_taxonomy_query_count(self, user_attr: str, expected_queries: int):
Expand Down Expand Up @@ -2066,16 +2069,16 @@ def test_get_copied_tags(self):
('staff', 'courseA', 10),
('staff', 'libraryA', 13),
('staff', 'collection_key', 13),
("content_creatorA", 'courseA', 14, False),
("content_creatorA", 'libraryA', 17, False),
("content_creatorA", 'collection_key', 17, False),
("library_staffA", 'libraryA', 17, False), # Library users can only view objecttags, not change them?
("library_staffA", 'collection_key', 17, False),
("library_userA", 'libraryA', 17, False),
("library_userA", 'collection_key', 17, False),
("instructorA", 'courseA', 14),
("course_instructorA", 'courseA', 14),
("course_staffA", 'courseA', 14),
("content_creatorA", 'courseA', 15, False),
("content_creatorA", 'libraryA', 18, False),
("content_creatorA", 'collection_key', 18, False),
("library_staffA", 'libraryA', 18, False), # Library users can only view objecttags, not change them?
("library_staffA", 'collection_key', 18, False),
("library_userA", 'libraryA', 18, False),
("library_userA", 'collection_key', 18, False),
("instructorA", 'courseA', 15),
("course_instructorA", 'courseA', 15),
("course_staffA", 'courseA', 15),
)
@ddt.unpack
def test_object_tags_query_count(
Expand Down Expand Up @@ -2447,6 +2450,78 @@ def test_library_fallthrough_to_legacy(self):
assert response.status_code == status.HTTP_200_OK


class TestTaxonomyOrgViewWithAuthz(CourseAuthzTestMixin, SharedModuleStoreTestCase, APITestCase):
"""
Test TaxonomyOrgView (GET /taxonomies/?org=X) with authz permissions.

Regression tests for openedx-authz#448: a user who only holds an openedx-authz role
(e.g. course_editor) with no legacy org role used to get an empty list, even for
global taxonomies, because UserOrgFilterBackend only knew about legacy roles.
"""

authz_roles_to_assign = [COURSE_EDITOR.external_key]

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
cls.course_key = cls.course.id

def setUp(self):
super().setUp()
self.course_org, _ = Organization.objects.get_or_create(short_name=self.course_key.org)

self.org_taxonomy = tagging_api.create_taxonomy(
name="Org Taxonomy", description="Taxonomy scoped to the course org", orgs=[self.course_org],
)
self.disabled_org_taxonomy = tagging_api.create_taxonomy(
name="Disabled Org Taxonomy", description="Disabled taxonomy scoped to the course org",
enabled=False, orgs=[self.course_org],
)

def _list_url(self, org=None):
url = TAXONOMY_ORG_LIST_URL
return f"{url}?org={org}" if org else url

def test_course_editor_sees_org_taxonomies(self):
"""A course editor (authz-only, no legacy org role) sees enabled taxonomies for their course's org."""
response = self.authorized_client.get(self._list_url(self.course_org.short_name))
assert response.status_code == status.HTTP_200_OK
taxonomy_ids = {t["id"] for t in response.data["results"]}
assert self.org_taxonomy.pk in taxonomy_ids
assert self.disabled_org_taxonomy.pk not in taxonomy_ids

def test_course_editor_doesnt_see_other_org_taxonomies(self):
"""A course editor doesn't get access to a different org's taxonomies through this permission."""
other_org_taxonomy = tagging_api.create_taxonomy(
name="Other Org Taxonomy", description="Taxonomy scoped to an unrelated org",
)
other_org, _ = Organization.objects.get_or_create(short_name="OtherOrgForTaxonomy")
set_taxonomy_orgs(other_org_taxonomy, orgs=[other_org])

response = self.authorized_client.get(self._list_url(other_org.short_name))
assert response.status_code == status.HTTP_200_OK
taxonomy_ids = {t["id"] for t in response.data["results"]}
assert other_org_taxonomy.pk not in taxonomy_ids

def test_no_role_user_still_denied(self):
"""A user with no role at all, legacy or authz, still gets nothing for the org."""
response = self.unauthorized_client.get(self._list_url(self.course_org.short_name))
assert response.status_code == status.HTTP_200_OK
assert response.data["results"] == []

def test_authz_role_ignored_when_course_toggle_disabled(self):
"""
A course_editor assignment doesn't grant access ahead of that course's own authz
toggle being enabled: legacy access stays authoritative until it flips.
"""
with patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, "is_enabled", return_value=False):
response = self.authorized_client.get(self._list_url(self.course_org.short_name))
assert response.status_code == status.HTTP_200_OK
taxonomy_ids = {t["id"] for t in response.data["results"]}
assert self.org_taxonomy.pk not in taxonomy_ids


@skip_unless_cms
@ddt.ddt
class TestDownloadTemplateView(APITestCase):
Expand Down
Loading
Loading