Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
6 changes: 3 additions & 3 deletions cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 21 additions & 10 deletions cms/djangoapps/contentstore/views/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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"
Expand Down
Loading