diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py index e12c3f6e7770..e7a2c88d463b 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py @@ -5,15 +5,17 @@ from django.test import RequestFactory from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag -from openedx_authz.constants.roles import COURSE_EDITOR +from openedx_authz.constants.roles import COURSE_EDITOR, COURSE_STAFF from rest_framework import status from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES from cms.djangoapps.contentstore.rest_api.v1.mixins import PermissionAccessMixin -from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase from cms.djangoapps.contentstore.utils import get_lms_link_for_item, get_pages_and_resources_url from cms.djangoapps.contentstore.views.course import _course_outline_json +from common.djangoapps.student.roles import CourseStaffRole from common.djangoapps.student.tests.factories import UserFactory +from openedx.core import toggles as core_toggles from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES from xmodule.modulestore.tests.factories import BlockFactory, check_mongo_calls @@ -224,3 +226,72 @@ def test_superuser_can_access_without_authz_role(self): assert response.status_code == status.HTTP_200_OK assert "course_structure" in response.data + + +class CourseIndexViewReindexLinkAuthzTest(CourseAuthoringAuthzTestMixin, CourseTestCase): + """ + Regression tests: with AuthZ course authoring enabled, `reindex_link` (and therefore the + Reindex button in the course-authoring MFE) must follow the same access rules as the actual + reindex action, not be restricted to Global Staff only. + """ + + def setUp(self): + super().setUp() + self.url = reverse("cms.djangoapps.contentstore:v1:course_index", kwargs={"course_id": self.course.id}) + + def test_reindex_link_present_for_non_global_staff_with_authz_permission(self): + """A non-global-staff user granted the AuthZ course-staff role sees the reindex link.""" + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + + response = self.authorized_client.get(self.url) + + assert response.status_code == status.HTTP_200_OK + assert not self.authorized_user.is_staff + assert response.data["reindex_link"] == f"/course/{self.course.id}/search_reindex" + + def test_reindex_link_absent_without_reindex_permission(self): + """ + A user with enough AuthZ permission to view the course (COURSE_EDITOR) but without the + publish-content permission required to reindex does not see the reindex link. + """ + self.add_user_to_role_in_course(self.authorized_user, COURSE_EDITOR.external_key, self.course.id) + + response = self.authorized_client.get(self.url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["reindex_link"] is None + + +class CourseIndexViewReindexLinkLegacyTest(CourseTestCase): + """ + Regression tests: with AuthZ course authoring disabled, `reindex_link` must preserve the + legacy behavior of being restricted to Global Staff, even for users with legacy course-staff + write access. + """ + + def setUp(self): + super().setUp() + self.url = reverse("cms.djangoapps.contentstore:v1:course_index", kwargs={"course_id": self.course.id}) + + @override_waffle_flag(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, active=False) + def test_reindex_link_absent_for_course_staff_without_global_staff(self): + """A course-staff member who isn't Global Staff does not see the reindex link.""" + course_staff_user, password = self.create_non_staff_user() + CourseStaffRole(self.course.id).add_users(course_staff_user) + + client = AjaxEnabledTestClient() + client.login(username=course_staff_user.username, password=password) + + response = client.get(self.url) + + assert response.status_code == status.HTTP_200_OK + assert not course_staff_user.is_staff + assert response.data["reindex_link"] is None + + @override_waffle_flag(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, active=False) + def test_reindex_link_present_for_global_staff(self): + """Global Staff still sees the reindex link (legacy behavior preserved).""" + response = self.client.get(self.url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["reindex_link"] == f"/course/{self.course.id}/search_reindex" diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 35818125efab..2c061fcab54f 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -1898,14 +1898,14 @@ def _get_course_index_context(request, course_key, course_block): _course_outline_json, _deprecated_blocks_info, course_outline_initial_state, + user_can_reindex_course, ) from openedx.core.djangoapps.content_staging import api as content_staging_api lms_link = get_lms_link_for_item(course_block.location) reindex_link = None - if settings.FEATURES.get('ENABLE_COURSEWARE_INDEX', False): - if GlobalStaff().has_user(request.user): - reindex_link = f"/course/{str(course_key)}/search_reindex" + if settings.FEATURES.get('ENABLE_COURSEWARE_INDEX', False) and user_can_reindex_course(course_key, request.user): + reindex_link = f"/course/{str(course_key)}/search_reindex" sections = course_block.get_children() course_structure = _course_outline_json(request, course_block) locator_to_show = request.GET.get('show', None) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 2ee588195210..73f015348466 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -194,16 +194,33 @@ def get_course_and_check_manage_group_configurations_access(course_key, user, de return _get_course_block(course_key, depth) -def reindex_course_and_check_access(course_key, user): +def user_can_reindex_course(course_key, user): """ - Internal method used to restart indexing on a course. + Returns True if `user` is allowed to trigger a search reindex for `course_key`. + + Mirrors the access rules enforced by `course_search_index_handler`, so callers that only + need to know whether the action is allowed (e.g. to decide whether to show a reindex link) + stay in sync with the rules enforced when the reindex is actually triggered. """ - if not user_has_course_permission( + is_authz_enabled = core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key) + if not is_authz_enabled and not GlobalStaff().has_user(user): + # When AuthZ is disabled, restrict to global staff (legacy behavior). + # When AuthZ is enabled, access control is enforced by the AuthZ layer, + # which includes staff/superuser checks and course-level permissions. + return False + return user_has_course_permission( user=user, authz_permission=COURSES_PUBLISH_COURSE_CONTENT.identifier, course_key=course_key, legacy_permission=LegacyAuthoringPermission.WRITE - ): + ) + + +def reindex_course_and_check_access(course_key, user): + """ + Internal method used to restart indexing on a course. + """ + if not user_can_reindex_course(course_key, user): raise PermissionDenied() return CoursewareSearchIndexer.do_course_reindex(modulestore(), course_key) @@ -380,12 +397,6 @@ def course_search_index_handler(request, course_key_string): json: return status of indexing task """ course_key = CourseKey.from_string(course_key_string) - is_authz_enabled = core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key) - if not is_authz_enabled and not GlobalStaff().has_user(request.user): - # When AuthZ is disabled, restrict to global staff (legacy behavior). - # When AuthZ is enabled, access control is enforced by the AuthZ layer, - # which includes staff/superuser checks and course-level permissions. - raise PermissionDenied() content_type = request.META.get('CONTENT_TYPE', None) if content_type is None: content_type = "application/json; charset=utf-8"