diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index e209be33ff30..4c7e3a8d947a 100644 --- a/cms/djangoapps/contentstore/tests/test_course_listing.py +++ b/cms/djangoapps/contentstore/tests/test_course_listing.py @@ -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 @@ -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 @@ -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()) ]) @@ -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()) ]) @@ -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()), diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 300bed93ef5a..9cb5fbfaf351 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -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, @@ -76,7 +70,6 @@ GlobalStaff, OrgStaffRole, UserBasedRole, - enable_authz_course_authoring, strict_role_checking, ) from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json @@ -84,6 +77,7 @@ 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 @@ -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 @@ -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]: diff --git a/openedx/core/djangoapps/authz/scopes.py b/openedx/core/djangoapps/authz/scopes.py new file mode 100644 index 000000000000..57835bb8d357 --- /dev/null +++ b/openedx/core/djangoapps/authz/scopes.py @@ -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 diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py index a51bf6c99812..3bfc7e166536 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py @@ -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() diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py index 81cbb440b032..91494a696b7e 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py @@ -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 @@ -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): @@ -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( @@ -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): diff --git a/openedx/core/djangoapps/content_tagging/rules.py b/openedx/core/djangoapps/content_tagging/rules.py index fe1064f22b5e..8b0e11ba549f 100644 --- a/openedx/core/djangoapps/content_tagging/rules.py +++ b/openedx/core/djangoapps/content_tagging/rules.py @@ -7,6 +7,8 @@ import django.contrib.auth.models import openedx_tagging.rules as oel_tagging import rules +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_authz import api as authz_api from openedx_authz.constants import permissions as authz_permissions @@ -21,6 +23,7 @@ OrgInstructorRole, OrgLibraryUserRole, OrgStaffRole, + enable_authz_course_authoring, ) from .auth import should_use_course_authz_for_object @@ -79,16 +82,29 @@ def _get_course_user_orgs(user: UserType, orgs: list[Organization]) -> list[Orga if not orgs: return [] + def course_uses_authz(course_id) -> bool: + """ + True if the course has switched to openedx-authz, where legacy course roles are no longer + authoritative: tagging there requires courses.manage_tags (see can_change_object_tag_objectid), + so those users are picked up by _get_authz_manage_tags_orgs instead. + """ + try: + return enable_authz_course_authoring(CourseKey.from_string(str(course_id))) + except InvalidKeyError: + return False + def user_has_role_ignore_course_id(user, role_name, org_name) -> bool: """ - Returns True if the given user has the given role for the given org, OR for any courses in this org. + Returns True if the given user has the given role for the given org, OR for any courses in this org + that have not switched to openedx-authz. """ # We use the user's RoleCache here to avoid re-querying. roles_cache = get_role_cache(user) course_roles = get_course_roles(user) return any( access_role.role in roles_cache.get_roles(role_name) and - access_role.org == org_name + access_role.org == org_name and + not (access_role.course_id and course_uses_authz(access_role.course_id)) for access_role in course_roles ) @@ -117,17 +133,38 @@ def get_user_orgs(user: UserType, orgs: list[Organization] | None = None) -> lis """ Return a list of orgs that the given user is a member of (instructor or content creator), from the given list of orgs. + + Membership comes from legacy roles and, additively, from openedx-authz roles that grant + courses.manage_tags (see _get_authz_manage_tags_orgs). """ org_list = rules_cache.get_orgs() if orgs is None else orgs content_creator_orgs = _get_content_creator_orgs(user, org_list) course_user_orgs = _get_course_user_orgs(user, org_list) library_user_orgs = _get_library_user_orgs(user, org_list) - user_orgs = list(set(content_creator_orgs) | set(course_user_orgs) | set(library_user_orgs)) + authz_orgs = _get_authz_manage_tags_orgs(user, org_list) + return list(set(content_creator_orgs) | set(course_user_orgs) | set(library_user_orgs) | set(authz_orgs)) - return user_orgs + +def _get_authz_manage_tags_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]: + """ + Return the orgs, from the given list, where the user holds courses.manage_tags through openedx-authz. + + This is the authz equivalent of _get_course_user_orgs: roles like course_editor, + course_staff, and course_admin only exist in openedx-authz, with no legacy + CourseAccessRole for the legacy helpers to find. + + The user's scopes (course, org-wide and platform-wide) are expanded into course keys, keeping only + the courses that have switched to openedx-authz: a policy assignment can exist ahead of the course's + own toggle, and until it flips, legacy access stays authoritative for it (see + should_use_course_authz_for_object). + """ + if not orgs: + return [] + + org_names = rules_cache.get_authz_manage_tags_org_names(user) + return [org for org in orgs if org.short_name in org_names] -@rules.predicate def can_create_taxonomy(user: UserType) -> bool: """ Returns True if the given user can create a taxonomy. diff --git a/openedx/core/djangoapps/content_tagging/tests/test_rules.py b/openedx/core/djangoapps/content_tagging/tests/test_rules.py index 7fb2274c8238..1567648dcfec 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_rules.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_rules.py @@ -7,16 +7,19 @@ from django.test import TestCase from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, LibraryLocatorV2 +from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData, PlatformCourseOverviewGlobData from openedx_authz.constants import permissions as authz_permissions from openedx_tagging.models import Tag from openedx_tagging.rules import ObjectTagPermissionItem from common.djangoapps.student.auth import add_users, update_org_role from common.djangoapps.student.roles import CourseStaffRole, OrgStaffRole +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.toggles import AUTHZ_COURSE_AUTHORING_FLAG from .. import api -from ..rules import can_change_object_tag_objectid, can_remove_object_tag_objectid +from ..rules import can_change_object_tag_objectid, can_remove_object_tag_objectid, can_view_taxonomy, get_user_orgs +from ..utils import rules_cache from .test_api import TestTaxonomyMixin User = get_user_model() @@ -850,3 +853,103 @@ def test_library_object_unchanged(self, mock_is_user_allowed): authz_permissions.MANAGE_LIBRARY_TAGS.identifier, str(library_key), ) + + +@patch("openedx_authz.api.get_scopes_for_user_and_permission") +class TestRulesAuthzManageTagsOrgs(TestTaxonomyMixin, TestCase): + """ + Tests for how get_user_orgs and can_view_taxonomy resolve orgs granted through openedx-authz + courses.manage_tags, which have no legacy CourseAccessRole equivalent. + """ + + def setUp(self): + super().setUp() + rules_cache.clear() # authz scopes are cached per request + self.superuser = User.objects.create(username="superuser", email="superuser@example.com", is_superuser=True) + self.authz_user = User.objects.create(username="authz_user", email="authz_user@example.com") + self.legacy_user = User.objects.create(username="legacy_user", email="legacy_user@example.com") + # org1's short_name ("OeX") matches this course's org. + self.course_key = CourseLocator.from_string("course-v1:OeX+DemoX+Demo_Course") + self.org2_only_taxonomy = api.create_taxonomy(name="Axim only") + api.set_taxonomy_orgs(self.org2_only_taxonomy, orgs=[self.org2]) + + @staticmethod + def _course_scope(course_key): + """Build the authz course scope for the given course key.""" + return CourseOverviewData(external_key=str(course_key)) + + @override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=True) + def test_course_scope_grants_its_org(self, mock_get_scopes): + """A course-level manage_tags grant resolves to that course's org once the course has switched to authz.""" + mock_get_scopes.return_value = [self._course_scope(self.course_key)] + assert get_user_orgs(self.authz_user) == [self.org1] + + @override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=False) + def test_course_scope_ignored_when_course_not_switched(self, mock_get_scopes): + """A course-level grant is ignored while the course still uses legacy access.""" + mock_get_scopes.return_value = [self._course_scope(self.course_key)] + assert not get_user_orgs(self.authz_user) + + @override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=True) + def test_org_glob_scope_grants_its_org(self, mock_get_scopes): + """An org-wide manage_tags grant resolves to that org when authz is enabled.""" + CourseOverviewFactory(id=self.course_key, org=self.course_key.org) + CourseOverviewFactory( + id=CourseLocator(self.org2.short_name, "DemoX", "Demo_Course"), org=self.org2.short_name + ) + mock_get_scopes.return_value = [OrgCourseOverviewGlobData(external_key="course-v1:OeX+*")] + assert get_user_orgs(self.authz_user) == [self.org1] + + @override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=False) + def test_org_glob_scope_ignored_when_flag_off(self, mock_get_scopes): + """An org-wide grant is ignored when authz is not enabled.""" + CourseOverviewFactory(id=self.course_key, org=self.course_key.org) + mock_get_scopes.return_value = [OrgCourseOverviewGlobData(external_key="course-v1:OeX+*")] + assert not get_user_orgs(self.authz_user) + + @override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=True) + def test_platform_glob_scope_grants_all_orgs(self, mock_get_scopes): + """A platform-wide manage_tags grant resolves to the orgs of all courses when authz is enabled.""" + CourseOverviewFactory(id=self.course_key, org=self.course_key.org) + CourseOverviewFactory( + id=CourseLocator(self.org2.short_name, "DemoX", "Demo_Course"), org=self.org2.short_name + ) + mock_get_scopes.return_value = [PlatformCourseOverviewGlobData(external_key="course-v1:*")] + assert set(get_user_orgs(self.authz_user)) == {self.org1, self.org2} + + @override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=True) + def test_can_view_taxonomy_for_authz_only_user(self, mock_get_scopes): + """The per-taxonomy check agrees with the list filter: authz-only users can open their org's taxonomies.""" + mock_get_scopes.return_value = [self._course_scope(self.course_key)] + assert can_view_taxonomy(self.authz_user, self.taxonomy_one_org) + assert can_view_taxonomy(self.authz_user, self.taxonomy_both_orgs) + assert not can_view_taxonomy(self.authz_user, self.org2_only_taxonomy) + + @override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=False) + def test_legacy_course_role_counts_before_course_switches(self, mock_get_scopes): + """Legacy course staff still resolve the org while the course has not switched to authz.""" + mock_get_scopes.return_value = [] + add_users(self.superuser, CourseStaffRole(self.course_key), self.legacy_user) + assert get_user_orgs(self.legacy_user) == [self.org1] + + def test_legacy_course_role_ignored_once_course_switches(self, mock_get_scopes): + """ + Legacy course staff with no authz role in a switched course don't see the org's taxonomies, matching + can_change_object_tag_objectid, which requires courses.manage_tags there. + """ + mock_get_scopes.return_value = [] + with override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=False): + add_users(self.superuser, CourseStaffRole(self.course_key), self.legacy_user) + with override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=True): + legacy_user = User.objects.get(pk=self.legacy_user.pk) # fresh instance, no cached role cache + assert not get_user_orgs(legacy_user) + assert not can_view_taxonomy(legacy_user, self.taxonomy_one_org) + + def test_legacy_course_role_kept_with_authz_manage_tags(self, mock_get_scopes): + """A switched-course staff member who also holds manage_tags through authz still resolves the org.""" + mock_get_scopes.return_value = [self._course_scope(self.course_key)] + with override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=False): + add_users(self.superuser, CourseStaffRole(self.course_key), self.legacy_user) + with override_waffle_flag(AUTHZ_COURSE_AUTHORING_FLAG, active=True): + legacy_user = User.objects.get(pk=self.legacy_user.pk) + assert get_user_orgs(legacy_user) == [self.org1] diff --git a/openedx/core/djangoapps/content_tagging/utils.py b/openedx/core/djangoapps/content_tagging/utils.py index 658f23d16b5d..8de70c71c990 100644 --- a/openedx/core/djangoapps/content_tagging/utils.py +++ b/openedx/core/djangoapps/content_tagging/utils.py @@ -131,5 +131,32 @@ def get_library_orgs(self, user, org_names: list[str]) -> list[Organization]: library_orgs[org_name] for org_name in org_names if org_name in library_orgs ] + def get_authz_manage_tags_org_names(self, user) -> set[str]: + """ + Returns the short names of the orgs of the courses where the given user holds courses.manage_tags + through openedx-authz. + + Scopes are expanded with get_course_keys_from_scopes, the same way other authz-aware course + listings do, so only courses that have switched to openedx-authz are taken into account. + + Cached for the duration of the request: org membership is evaluated once per taxonomy, + and each lookup would otherwise hit the authz policy store and the course overviews again. + """ + # Import here to keep openedx-authz out of this module's import-time dependencies. + from openedx_authz import api as authz_api + from openedx_authz.constants import permissions as authz_permissions + + from openedx.core.djangoapps.authz.scopes import get_course_keys_from_scopes + + cache_key = f'authz_manage_tags_org_names:{user.username}' + org_names = self.request_cache.data.get(cache_key) + if org_names is None: + scopes = authz_api.get_scopes_for_user_and_permission( + user.username, authz_permissions.COURSES_MANAGE_TAGS.identifier + ) + org_names = {course_key.org for course_key in get_course_keys_from_scopes(list(scopes))} + self.request_cache.set(cache_key, org_names) + return org_names + rules_cache = TaggingRulesCache()