Skip to content
Merged
18 changes: 13 additions & 5 deletions cms/djangoapps/contentstore/views/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
COURSES_MANAGE_COURSE_UPDATES,
COURSES_MANAGE_GROUP_CONFIGURATIONS,
COURSES_MANAGE_PAGES_AND_RESOURCES,
COURSES_PUBLISH_COURSE_CONTENT,
COURSES_VIEW_COURSE,
COURSES_VIEW_COURSE_UPDATES,
COURSES_VIEW_PAGES_AND_RESOURCES,
Expand All @@ -56,7 +57,6 @@
from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager
from common.djangoapps.edxmako.shortcuts import render_to_response
from common.djangoapps.student.auth import (
has_course_author_access,
has_studio_advanced_settings_access,
has_studio_read_access,
has_studio_write_access,
Expand Down Expand Up @@ -191,7 +191,12 @@ def reindex_course_and_check_access(course_key, user):
"""
Internal method used to restart indexing on a course.
"""
if not has_course_author_access(user, course_key):
if not user_has_course_permission(
user=user,
authz_permission=COURSES_PUBLISH_COURSE_CONTENT.identifier,
course_key=course_key,
legacy_permission=LegacyAuthoringPermission.WRITE
):
raise PermissionDenied()
return CoursewareSearchIndexer.do_course_reindex(modulestore(), course_key)

Expand Down Expand Up @@ -367,10 +372,13 @@ def course_search_index_handler(request, course_key_string):
html: return status of indexing task
json: return status of indexing task
"""
# Only global staff (PMs) are able to index courses
if not GlobalStaff().has_user(request.user):
raise PermissionDenied()
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
87 changes: 86 additions & 1 deletion cms/djangoapps/contentstore/views/tests/test_course_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
import pytz
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext as _
from openedx_authz.constants.roles import COURSE_STAFF
from search.api import perform_search

from cms.djangoapps.contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase
from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import VisibilityState, create_xblock_info
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
Expand Down Expand Up @@ -541,3 +543,86 @@ def test_indexing_no_item(self, mock_get_course):
# Start manual reindex and check error in response
with self.assertRaises(SearchIndexingError): # noqa: PT027
CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id)


class TestCourseReIndexAuthz(CourseAuthoringAuthzTestMixin, CourseTestCase):
"""
AuthZ-based tests for course reindex.
"""

MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
SUCCESSFUL_RESPONSE = _("Course has been successfully reindexed.")
ENABLED_SIGNALS = ['course_published']

@mock.patch(
'cms.djangoapps.contentstore.signals.handlers.transaction.on_commit',
new=mock.Mock(side_effect=lambda func: func()),
)
def setUp(self):
super().setUp()

self.url = reverse_course_url('course_search_index_handler', self.course.id)

self.non_staff_client = AjaxEnabledTestClient()
self.non_staff_user, self.non_staff_password = self.create_non_staff_user()
self.non_staff_client.login(username=self.non_staff_user.username, password=self.non_staff_password)

self.course.start = datetime.datetime(2014, 1, 1, tzinfo=pytz.utc)
modulestore().update_item(self.course, self.user.id)

self.chapter = BlockFactory.create(
parent_location=self.course.location,
category='chapter',
display_name="Week 1"
)
self.sequential = BlockFactory.create(
parent_location=self.chapter.location,
category='sequential',
display_name="Lesson 1"
)
self.vertical = BlockFactory.create(
parent_location=self.sequential.location,
category='vertical',
display_name='Subsection 1'
)
self.video = BlockFactory.create(
parent_location=self.vertical.location,
category="video",
display_name="My Video"
)
self.html = BlockFactory.create(
parent_location=self.vertical.location,
category="html",
display_name="My HTML",
data="<div>This is my unique HTML content</div>",
)

def test_staff_user_can_reindex(self):
""" Verify that staff user can reindex the course. """

response = self.client.get(self.url, HTTP_ACCEPT='application/json')

assert self.user.is_staff
assert response.status_code == 200
assert self.SUCCESSFUL_RESPONSE in response.content.decode()

Comment thread
dwong2708 marked this conversation as resolved.
def test_non_staff_user_cannot_reindex(self):
""" Verify that non-staff user without course authoring permissions cannot reindex the course. """
Comment thread
dwong2708 marked this conversation as resolved.
response = self.non_staff_client.get(self.url, HTTP_ACCEPT='application/json')

assert not self.non_staff_user.is_staff
assert response.status_code == 403

def test_non_staff_user_can_reindex(self):
""" Verify that non-staff user with course authoring permissions can reindex the course. """

# Grant access helper
self.add_user_to_role_in_course(
self.non_staff_user,
COURSE_STAFF.external_key,
self.course.id
)
response = self.non_staff_client.get(self.url, HTTP_ACCEPT='application/json')
assert not self.non_staff_user.is_staff
assert response.status_code == 200
assert self.SUCCESSFUL_RESPONSE in response.content.decode()
3 changes: 2 additions & 1 deletion common/djangoapps/student/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ def _has_content_creator_access(user, org):
"""
if settings.FEATURES.get('DISABLE_COURSE_CREATION', False):
return False
org_scope_key = f"course-v1:{org}+*"
# 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,
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,7 @@ openedx-atlas==0.7.0
# enterprise-integrated-channels
# openedx-authz
# openedx-forum
openedx-authz==1.5.0
openedx-authz==1.11.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 @@ -1377,7 +1377,7 @@ openedx-atlas==0.7.0
# enterprise-integrated-channels
# openedx-authz
# openedx-forum
openedx-authz==1.5.0
openedx-authz==1.11.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 @@ -1005,7 +1005,7 @@ openedx-atlas==0.7.0
# enterprise-integrated-channels
# openedx-authz
# openedx-forum
openedx-authz==1.5.0
openedx-authz==1.11.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.5.0
openedx-authz==1.11.0
# via -r requirements/edx/base.txt
openedx-calc==5.0.0
# via
Expand Down
Loading