diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs_permissions.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs_permissions.py new file mode 100644 index 000000000000..ee541045b865 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs_permissions.py @@ -0,0 +1,104 @@ +""" +Integration tests verifying authz permissions for v0 tabs REST API views. +""" +from urllib.parse import urlencode + +from django.urls import reverse +from rest_framework.test import APIClient + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_STAFF + + +class TabsV0AuthzTest(CourseAuthoringAuthzTestMixin, CourseTestCase): + """ + Integration tests for v0 tabs API authz permissions. + """ + + def setUp(self): + super().setUp() + self.list_url = reverse( + 'cms.djangoapps.contentstore:v0:course_tab_list', + kwargs={'course_id': self.course.id}, + ) + self.settings_url = reverse( + 'cms.djangoapps.contentstore:v0:course_tab_settings', + kwargs={'course_id': self.course.id}, + ) + self.reorder_url = reverse( + 'cms.djangoapps.contentstore:v0:course_tab_reorder', + kwargs={'course_id': self.course.id}, + ) + + # --- CourseTabListView (GET) - requires courses.view_pages_and_resources --- + + def test_staff_can_list_tabs(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.authorized_client.get(self.list_url) + assert resp.status_code == 200 + + def test_auditor_can_list_tabs(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.authorized_client.get(self.list_url) + assert resp.status_code == 200 + + def test_unauthorized_cannot_list_tabs(self): + resp = self.unauthorized_client.get(self.list_url) + assert resp.status_code == 403 + + # --- CourseTabSettingsView (POST) - requires courses.manage_pages_and_resources --- + + def test_staff_can_update_tab_settings(self): + """Asserts not-403 rather than 200 because the minimal payload may fail validation.""" + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.authorized_client.post( + f'{self.settings_url}?{urlencode({"tab_id": "wiki"})}', + data={'is_hidden': True}, + format='json', + ) + assert resp.status_code != 403 + + def test_auditor_cannot_update_tab_settings(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.authorized_client.post( + f'{self.settings_url}?{urlencode({"tab_id": "wiki"})}', + data={'is_hidden': True}, + format='json', + ) + assert resp.status_code == 403 + + def test_unauthorized_cannot_update_tab_settings(self): + resp = self.unauthorized_client.post( + f'{self.settings_url}?{urlencode({"tab_id": "wiki"})}', + data={'is_hidden': True}, + format='json', + ) + assert resp.status_code == 403 + + # --- CourseTabReorderView (POST) - requires courses.manage_pages_and_resources --- + + def test_staff_can_reorder_tabs(self): + """Asserts not-403 rather than 200 because the empty tab list may fail validation.""" + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.authorized_client.post(self.reorder_url, data=[], format='json') + assert resp.status_code != 403 + + def test_auditor_cannot_reorder_tabs(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.authorized_client.post(self.reorder_url, data=[], format='json') + assert resp.status_code == 403 + + def test_unauthorized_cannot_reorder_tabs(self): + resp = self.unauthorized_client.post(self.reorder_url, data=[], format='json') + assert resp.status_code == 403 + + # --- Superuser bypass --- + + def test_superuser_can_list_tabs(self): + superuser = UserFactory(is_superuser=True) + client = APIClient() + client.force_authenticate(user=superuser) + resp = client.get(self.list_url) + assert resp.status_code == 200 diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py b/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py index 968af2246aa3..d89d5d1a7e10 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py @@ -10,7 +10,12 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError -from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission +from openedx_authz.constants.permissions import ( + COURSES_MANAGE_PAGES_AND_RESOURCES, + COURSES_VIEW_PAGES_AND_RESOURCES, +) from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes from ..serializers import CourseTabSerializer, CourseTabUpdateSerializer, TabIDLocatorSerializer from ....views.tabs import edit_tab_handler, get_course_tabs, reorder_tabs_handler @@ -78,7 +83,12 @@ def get(self, request: Request, course_id: str) -> Response: ``` """ course_key = CourseKey.from_string(course_id) - if not has_studio_read_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_PAGES_AND_RESOURCES.identifier, + course_key, + LegacyAuthoringPermission.READ, + ): self.permission_denied(request) course_block = modulestore().get_course(course_key) @@ -149,7 +159,12 @@ def post(self, request: Request, course_id: str) -> Response: without any content. """ course_key = CourseKey.from_string(course_id) - if not has_studio_write_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_MANAGE_PAGES_AND_RESOURCES.identifier, + course_key, + LegacyAuthoringPermission.WRITE, + ): self.permission_denied(request) tab_id_locator = TabIDLocatorSerializer(data=request.query_params) @@ -221,7 +236,12 @@ def post(self, request: Request, course_id: str) -> Response: without any content. """ course_key = CourseKey.from_string(course_id) - if not has_studio_write_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_MANAGE_PAGES_AND_RESOURCES.identifier, + course_key, + LegacyAuthoringPermission.WRITE, + ): self.permission_denied(request) course_block = modulestore().get_course(course_key) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_textbooks.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_textbooks.py index d4dd80f8ab05..8270bb4f5648 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_textbooks.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_textbooks.py @@ -2,9 +2,12 @@ Unit tests for the course's textbooks. """ from django.urls import reverse -from rest_framework import status +from rest_framework.test import APIClient from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_STAFF from ...mixins import PermissionAccessMixin @@ -39,5 +42,39 @@ def test_success_response(self): self.save_course() response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, 200) self.assertEqual(response.data["textbooks"], expected_textbook) + + +class CourseTextbooksAuthzTest(CourseAuthoringAuthzTestMixin, CourseTestCase): + """ + Integration tests for CourseTextbooksView authz permissions. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:textbooks", + kwargs={"course_id": self.course.id}, + ) + + def test_staff_can_view_textbooks(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.authorized_client.get(self.url) + assert resp.status_code == 200 + + def test_auditor_can_view_textbooks(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.authorized_client.get(self.url) + assert resp.status_code == 200 + + def test_unauthorized_cannot_view_textbooks(self): + resp = self.unauthorized_client.get(self.url) + assert resp.status_code == 403 + + def test_superuser_can_view_textbooks(self): + superuser = UserFactory(is_superuser=True) + client = APIClient() + client.force_authenticate(user=superuser) + resp = client.get(self.url) + assert resp.status_code == 200 diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py b/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py index 620b40235b73..1363900e1788 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py @@ -10,7 +10,9 @@ from cms.djangoapps.contentstore.rest_api.v1.serializers import ( CourseTextbooksSerializer, ) -from common.djangoapps.student.auth import has_studio_read_access +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission +from openedx_authz.constants.permissions import COURSES_VIEW_PAGES_AND_RESOURCES from openedx.core.lib.api.view_utils import ( DeveloperErrorViewMixin, verify_course_exists, @@ -80,7 +82,12 @@ def get(self, request: Request, course_id: str): course_key = CourseKey.from_string(course_id) store = modulestore() - if not has_studio_read_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_PAGES_AND_RESOURCES.identifier, + course_key, + LegacyAuthoringPermission.READ, + ): self.permission_denied(request) with store.bulk_operations(course_key): diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 2e4d7e27ffde..2f75a413f2c9 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -60,7 +60,9 @@ from openedx.core.djangoapps.authz.decorators import user_has_course_permission from openedx_authz.constants.permissions import ( COURSES_MANAGE_COURSE_UPDATES, + COURSES_MANAGE_PAGES_AND_RESOURCES, COURSES_VIEW_COURSE_UPDATES, + COURSES_VIEW_PAGES_AND_RESOURCES, COURSES_MANAGE_GROUP_CONFIGURATIONS, ) from common.djangoapps.student.roles import ( @@ -1483,16 +1485,23 @@ def textbooks_list_handler(request, course_key_string): """ course_key = CourseKey.from_string(course_key_string) if "application/json" not in request.META.get('HTTP_ACCEPT', 'text/html'): - # return HTML page - # We don't need to do an access check here because - # that is done when the endpoint for the actual content of the page. - # This is just to handle redirecting anyone that has bookmarked the old - # textbooks page. + # Legacy HTML bookmark redirect — no data is exposed here. + # Access is enforced when the MFE fetches data from the textbooks API. return redirect(get_textbooks_url(course_key)) + if request.method == 'GET': + authz_perm = COURSES_VIEW_PAGES_AND_RESOURCES.identifier + legacy_perm = LegacyAuthoringPermission.READ + else: + authz_perm = COURSES_MANAGE_PAGES_AND_RESOURCES.identifier + legacy_perm = LegacyAuthoringPermission.WRITE + + if not user_has_course_permission(request.user, authz_perm, course_key, legacy_perm): + raise PermissionDenied() + store = modulestore() with store.bulk_operations(course_key): - course = get_course_and_check_access(course_key, request.user) + course = _get_course_block(course_key) # from here on down, we know the client has requested JSON if request.method == 'GET': @@ -1555,9 +1564,20 @@ def textbooks_detail_handler(request, course_key_string, textbook_id): json: remove textbook """ course_key = CourseKey.from_string(course_key_string) + + if request.method == 'GET': + authz_perm = COURSES_VIEW_PAGES_AND_RESOURCES.identifier + legacy_perm = LegacyAuthoringPermission.READ + else: + authz_perm = COURSES_MANAGE_PAGES_AND_RESOURCES.identifier + legacy_perm = LegacyAuthoringPermission.WRITE + + if not user_has_course_permission(request.user, authz_perm, course_key, legacy_perm): + raise PermissionDenied() + store = modulestore() with store.bulk_operations(course_key): - course_block = get_course_and_check_access(course_key, request.user) + course_block = _get_course_block(course_key) matching_id = [tb for tb in course_block.pdf_textbooks if str(tb.get("id")) == str(textbook_id)] if matching_id: diff --git a/cms/djangoapps/contentstore/views/tests/test_textbooks_permissions.py b/cms/djangoapps/contentstore/views/tests/test_textbooks_permissions.py new file mode 100644 index 000000000000..186ca5ca304c --- /dev/null +++ b/cms/djangoapps/contentstore/views/tests/test_textbooks_permissions.py @@ -0,0 +1,138 @@ +""" +Integration tests verifying authz permissions for legacy textbook handler views. +""" +import json + +from django.test import Client + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.utils import reverse_course_url +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_STAFF + + +class TextbooksListHandlerAuthzTest(CourseAuthoringAuthzTestMixin, CourseTestCase): + """ + Integration tests for textbooks_list_handler authz permissions. + + Uses Django test Client (not DRF APIClient) because textbooks_list_handler + is a function-based view with @login_required. + """ + + def setUp(self): + super().setUp() + self.url = reverse_course_url('textbooks_list_handler', self.course.id) + + self.staff_client = Client() + self.staff_client.login(username=self.authorized_user.username, password=self.password) + + self.unauth_client = Client() + self.unauth_client.login(username=self.unauthorized_user.username, password=self.password) + + # --- GET (JSON) - requires courses.view_pages_and_resources --- + + def test_staff_can_get(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.staff_client.get(self.url, HTTP_ACCEPT='application/json') + assert resp.status_code == 200 + + def test_auditor_can_get(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.staff_client.get(self.url, HTTP_ACCEPT='application/json') + assert resp.status_code == 200 + + def test_unauthorized_cannot_get(self): + resp = self.unauth_client.get(self.url, HTTP_ACCEPT='application/json') + assert resp.status_code == 403 + + # --- POST - requires courses.manage_pages_and_resources --- + + def test_staff_can_post(self): + """Asserts not-403 rather than 200 because minimal payload may fail validation.""" + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.staff_client.post( + self.url, + data=json.dumps({"tab_title": "Test", "chapters": []}), + content_type='application/json', + HTTP_ACCEPT='application/json', + ) + assert resp.status_code != 403 + + def test_auditor_cannot_post(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.staff_client.post( + self.url, + data=json.dumps({"tab_title": "Test", "chapters": []}), + content_type='application/json', + HTTP_ACCEPT='application/json', + ) + assert resp.status_code == 403 + + def test_unauthorized_cannot_post(self): + resp = self.unauth_client.post( + self.url, + data=json.dumps({"tab_title": "Test", "chapters": []}), + content_type='application/json', + HTTP_ACCEPT='application/json', + ) + assert resp.status_code == 403 + + # --- Superuser bypass --- + + def test_superuser_can_get(self): + superuser = UserFactory(is_superuser=True, password=self.password) + client = Client() + client.login(username=superuser.username, password=self.password) + resp = client.get(self.url, HTTP_ACCEPT='application/json') + assert resp.status_code == 200 + + +class TextbooksDetailHandlerAuthzTest(CourseAuthoringAuthzTestMixin, CourseTestCase): + """ + Integration tests for textbooks_detail_handler authz permissions. + """ + + def setUp(self): + super().setUp() + self.course.pdf_textbooks = [{"tab_title": "Test", "chapters": [], "id": "1test"}] + self.save_course() + self.url = f'/textbooks/{self.course.id}/1test' + + self.staff_client = Client() + self.staff_client.login(username=self.authorized_user.username, password=self.password) + + self.unauth_client = Client() + self.unauth_client.login(username=self.unauthorized_user.username, password=self.password) + + # --- GET - requires courses.view_pages_and_resources --- + + def test_staff_can_get(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.staff_client.get(self.url, HTTP_ACCEPT='application/json') + assert resp.status_code == 200 + + def test_auditor_can_get(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.staff_client.get(self.url, HTTP_ACCEPT='application/json') + assert resp.status_code == 200 + + def test_unauthorized_cannot_get(self): + resp = self.unauth_client.get(self.url, HTTP_ACCEPT='application/json') + assert resp.status_code == 403 + + # --- DELETE - requires courses.manage_pages_and_resources --- + + def test_staff_can_delete(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.staff_client.delete(self.url, HTTP_ACCEPT='application/json') + assert resp.status_code == 204 + + def test_auditor_cannot_delete(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.staff_client.delete(self.url, HTTP_ACCEPT='application/json') + assert resp.status_code == 403 + + def test_unauthorized_cannot_delete(self): + resp = self.unauth_client.delete(self.url, HTTP_ACCEPT='application/json') + assert resp.status_code == 403 diff --git a/openedx/core/djangoapps/course_apps/rest_api/tests/test_views_permissions.py b/openedx/core/djangoapps/course_apps/rest_api/tests/test_views_permissions.py new file mode 100644 index 000000000000..f5002627dcf4 --- /dev/null +++ b/openedx/core/djangoapps/course_apps/rest_api/tests/test_views_permissions.py @@ -0,0 +1,96 @@ +""" +Integration tests verifying authz permissions for CourseAppsView. +""" +import contextlib +from unittest import mock + +from django.urls import reverse +from rest_framework.test import APIClient +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from openedx.core.djangolib.testing.utils import skip_unless_cms +from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_STAFF +from ...tests.utils import make_test_course_app + + +@skip_unless_cms +class CourseAppsAuthzTest(CourseAuthoringAuthzTestMixin, SharedModuleStoreTestCase): + """ + Integration tests for CourseAppsView authz permissions. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split) + + def setUp(self): + super().setUp() + self.url = reverse('course_apps_api:v1:course_apps', kwargs={'course_id': self.course.id}) + + @contextlib.contextmanager + def _setup_plugin_mock(self): + """Patch get_available_plugins to return a test plugin.""" + patcher = mock.patch('openedx.core.djangoapps.course_apps.plugins.PluginManager.get_available_plugins') + mock_plugins = patcher.start() + mock_plugins.return_value = { + 'app1': make_test_course_app(app_id='app1', name='App One', is_available=True), + } + yield + patcher.stop() + + # --- GET - requires courses.view_pages_and_resources --- + + def test_staff_can_list_apps(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + with self._setup_plugin_mock(): + resp = self.authorized_client.get(self.url) + assert resp.status_code == 200 + + def test_auditor_can_list_apps(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + with self._setup_plugin_mock(): + resp = self.authorized_client.get(self.url) + assert resp.status_code == 200 + + def test_unauthorized_cannot_list_apps(self): + resp = self.unauthorized_client.get(self.url) + assert resp.status_code == 403 + + # --- PATCH - requires courses.manage_pages_and_resources --- + + def test_staff_can_toggle_app(self): + """Asserts not-403 rather than 200 because the test plugin may not fully process the toggle.""" + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + with self._setup_plugin_mock(): + resp = self.authorized_client.patch( + self.url, data={'id': 'app1', 'enabled': True}, format='json', + ) + assert resp.status_code != 403 + + def test_auditor_cannot_toggle_app(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.authorized_client.patch( + self.url, data={'id': 'app1', 'enabled': True}, format='json', + ) + assert resp.status_code == 403 + + def test_unauthorized_cannot_toggle_app(self): + resp = self.unauthorized_client.patch( + self.url, data={'id': 'app1', 'enabled': True}, format='json', + ) + assert resp.status_code == 403 + + # --- Superuser bypass --- + + def test_superuser_can_list_apps(self): + superuser = UserFactory(is_superuser=True) + client = APIClient() + client.force_authenticate(user=superuser) + with self._setup_plugin_mock(): + resp = client.get(self.url) + assert resp.status_code == 200 diff --git a/openedx/core/djangoapps/course_apps/rest_api/v1/views.py b/openedx/core/djangoapps/course_apps/rest_api/v1/views.py index 511dd4d956b4..7da551adda4a 100644 --- a/openedx/core/djangoapps/course_apps/rest_api/v1/views.py +++ b/openedx/core/djangoapps/course_apps/rest_api/v1/views.py @@ -14,10 +14,15 @@ from rest_framework.request import Request from rest_framework.response import Response -from common.djangoapps.student.auth import has_studio_write_access +from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission +from openedx.core.djangoapps.authz.decorators import user_has_course_permission from openedx.core.djangoapps.course_apps.models import CourseAppStatus from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, validate_course_key, verify_course_exists +from openedx_authz.constants.permissions import ( + COURSES_MANAGE_PAGES_AND_RESOURCES, + COURSES_VIEW_PAGES_AND_RESOURCES, +) from ...api import is_course_app_enabled, set_course_app_enabled from ...plugins import CourseApp, CourseAppsPluginManager @@ -25,19 +30,26 @@ log = logging.getLogger(__name__) -class HasStudioWriteAccess(BasePermission): +class HasPagesAndResourcesAccess(BasePermission): """ - Check if the user has write access to studio. + Check if the user has access to Pages & Resources. + + Uses authz permissions when the feature flag is enabled, + falling back to legacy studio access. + GET requests check view permission, all others check manage permission. """ def has_permission(self, request, view): - """ - Check if the user has write access to studio. - """ user = request.user course_key_string = view.kwargs.get("course_id") course_key = validate_course_key(course_key_string) - return has_studio_write_access(user, course_key) + if request.method == 'GET': + authz_perm = COURSES_VIEW_PAGES_AND_RESOURCES.identifier + legacy_perm = LegacyAuthoringPermission.READ + else: + authz_perm = COURSES_MANAGE_PAGES_AND_RESOURCES.identifier + legacy_perm = LegacyAuthoringPermission.WRITE + return user_has_course_permission(user, authz_perm, course_key, legacy_perm) class CourseAppSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -87,7 +99,7 @@ class CourseAppsView(DeveloperErrorViewMixin, views.APIView): BearerAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser, ) - permission_classes = (HasStudioWriteAccess,) + permission_classes = (HasPagesAndResourcesAccess,) @schema( parameters=[ diff --git a/openedx/core/djangoapps/discussions/permissions.py b/openedx/core/djangoapps/discussions/permissions.py index 7028defc3e00..b7381af120de 100644 --- a/openedx/core/djangoapps/discussions/permissions.py +++ b/openedx/core/djangoapps/discussions/permissions.py @@ -6,7 +6,14 @@ from common.djangoapps.student.roles import CourseStaffRole, GlobalStaff, CourseInstructorRole from lms.djangoapps.discussion.django_comment_client.utils import has_discussion_privileges +from openedx.core.djangoapps.authz.decorators import user_has_course_permission from openedx.core.lib.api.view_utils import validate_course_key +from openedx_authz.constants.permissions import ( + COURSES_MANAGE_PAGES_AND_RESOURCES, + COURSES_VIEW_PAGES_AND_RESOURCES, +) + +from openedx.core import toggles as core_toggles DEFAULT_MESSAGE = "You're not authorized to perform this operation." PERMISSION_MESSAGES = { @@ -14,27 +21,39 @@ } -class IsStaffOrCourseTeam(BasePermission): +def _legacy_is_staff_or_course_team(user, course_key): + """Legacy permission check: allows global staff, course instructor, course staff, or discussion moderators/TAs.""" + if GlobalStaff().has_user(user): + return True + return ( + CourseInstructorRole(course_key).has_user(user) + or CourseStaffRole(course_key).has_user(user) + or has_discussion_privileges(user, course_key) + ) + + +class HasPagesAndResourcesAccess(BasePermission): """ - Check if user is global or course staff + Check if user has access to Pages & Resources. - Permission that checks to see if the user is global staff, course - staff, course admin, or has discussion privileges. If none of those conditions are - met, HTTP403 is returned. + When the authz feature flag is enabled, uses authz permissions. + GET requests check view permission, all others check manage permission. + When the flag is off, falls back to legacy behavior: global staff, + course instructor, course staff, or discussion privileges. """ def has_permission(self, request, view): course_key_string = view.kwargs.get('course_key_string') course_key = validate_course_key(course_key_string) - if GlobalStaff().has_user(request.user): - return True + if core_toggles.enable_authz_course_authoring(course_key): + if request.method == 'GET': + authz_perm = COURSES_VIEW_PAGES_AND_RESOURCES.identifier + else: + authz_perm = COURSES_MANAGE_PAGES_AND_RESOURCES.identifier + return user_has_course_permission(request.user, authz_perm, course_key) - return ( - CourseInstructorRole(course_key).has_user(request.user) or - CourseStaffRole(course_key).has_user(request.user) or - has_discussion_privileges(request.user, course_key) - ) + return _legacy_is_staff_or_course_team(request.user, course_key) def user_permissions_for_course(course, user): diff --git a/openedx/core/djangoapps/discussions/tests/test_views.py b/openedx/core/djangoapps/discussions/tests/test_views.py index 64d342480a4b..64c5e5da5644 100644 --- a/openedx/core/djangoapps/discussions/tests/test_views.py +++ b/openedx/core/djangoapps/discussions/tests/test_views.py @@ -23,7 +23,7 @@ from ..config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS from ..models import AVAILABLE_PROVIDER_MAP, DEFAULT_CONFIG_ENABLED, Provider, get_default_provider_type -from ..permissions import IsStaffOrCourseTeam +from ..permissions import HasPagesAndResourcesAccess DATA_LEGACY_COHORTS = { 'divided_inline_discussions': [], @@ -864,12 +864,12 @@ def setUp(self): self.url = reverse('sync-discussion-topics', kwargs={'course_key_string': self.course_key_string}) # Mock the permission class for course team checking - self.original_has_permission = IsStaffOrCourseTeam.has_permission - IsStaffOrCourseTeam.has_permission = Mock(return_value=True) + self.original_has_permission = HasPagesAndResourcesAccess.has_permission + HasPagesAndResourcesAccess.has_permission = Mock(return_value=True) def tearDown(self): # Restore original permission method - IsStaffOrCourseTeam.has_permission = self.original_has_permission + HasPagesAndResourcesAccess.has_permission = self.original_has_permission super().tearDown() @patch('openedx.core.djangoapps.discussions.views.update_discussions_settings_from_course_task') @@ -892,7 +892,7 @@ def test_sync_discussion_topics_course_team(self, mock_update): self.client.force_authenticate(user=self.instructor_user) # Mock the course team permission check - IsStaffOrCourseTeam.has_permission = Mock(return_value=True) + HasPagesAndResourcesAccess.has_permission = Mock(return_value=True) response = self.client.post(self.url) @@ -916,7 +916,7 @@ def test_sync_discussion_topics_forbidden(self): self.client.force_authenticate(user=self.student_user) # Mock the course team permission check to return False - IsStaffOrCourseTeam.has_permission = Mock(return_value=False) + HasPagesAndResourcesAccess.has_permission = Mock(return_value=False) response = self.client.post(self.url) diff --git a/openedx/core/djangoapps/discussions/tests/test_views_permissions.py b/openedx/core/djangoapps/discussions/tests/test_views_permissions.py new file mode 100644 index 000000000000..4608f84fb1b0 --- /dev/null +++ b/openedx/core/djangoapps/discussions/tests/test_views_permissions.py @@ -0,0 +1,88 @@ +""" +Integration tests verifying authz permissions for discussions views. +""" +from django.urls import reverse +from rest_framework.test import APIClient +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_STAFF + + +class DiscussionsAuthzTest(CourseAuthoringAuthzTestMixin, ModuleStoreTestCase): + """ + Integration tests for discussions views authz permissions. + """ + + def setUp(self): + super().setUp() + self.course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split) + self.settings_url = reverse( + 'discussions-settings', + kwargs={'course_key_string': str(self.course.id)}, + ) + self.providers_url = reverse( + 'discussions-providers', + kwargs={'course_key_string': str(self.course.id)}, + ) + + # --- GET settings - requires courses.view_pages_and_resources --- + + def test_staff_can_get_settings(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.authorized_client.get(self.settings_url) + assert resp.status_code == 200 + + def test_auditor_can_get_settings(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.authorized_client.get(self.settings_url) + assert resp.status_code == 200 + + def test_unauthorized_cannot_get_settings(self): + resp = self.unauthorized_client.get(self.settings_url) + assert resp.status_code == 403 + + # --- POST settings - requires courses.manage_pages_and_resources --- + + def test_staff_can_post_settings(self): + """Asserts not-403 rather than 200 because the empty payload may fail validation.""" + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.authorized_client.post(self.settings_url, data={}, format='json') + assert resp.status_code != 403 + + def test_auditor_cannot_post_settings(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.authorized_client.post(self.settings_url, data={}, format='json') + assert resp.status_code == 403 + + def test_unauthorized_cannot_post_settings(self): + resp = self.unauthorized_client.post(self.settings_url, data={}, format='json') + assert resp.status_code == 403 + + # --- GET providers - requires courses.view_pages_and_resources --- + + def test_staff_can_get_providers(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id) + resp = self.authorized_client.get(self.providers_url) + assert resp.status_code == 200 + + def test_auditor_can_get_providers(self): + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id) + resp = self.authorized_client.get(self.providers_url) + assert resp.status_code == 200 + + def test_unauthorized_cannot_get_providers(self): + resp = self.unauthorized_client.get(self.providers_url) + assert resp.status_code == 403 + + # --- Superuser bypass --- + + def test_superuser_can_get_settings(self): + superuser = UserFactory(is_superuser=True) + client = APIClient() + client.force_authenticate(user=superuser) + resp = client.get(self.settings_url) + assert resp.status_code == 200 diff --git a/openedx/core/djangoapps/discussions/views.py b/openedx/core/djangoapps/discussions/views.py index b74d88ca038f..c07cdd024a77 100644 --- a/openedx/core/djangoapps/discussions/views.py +++ b/openedx/core/djangoapps/discussions/views.py @@ -18,7 +18,7 @@ from .config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS from .models import AVAILABLE_PROVIDER_MAP, DiscussionsConfiguration, Features, Provider -from .permissions import IsStaffOrCourseTeam, check_course_permissions +from .permissions import HasPagesAndResourcesAccess, check_course_permissions from .serializers import DiscussionsConfigurationSerializer, DiscussionsProvidersSerializer from .tasks import update_discussions_settings_from_course_task @@ -32,7 +32,7 @@ class DiscussionsConfigurationSettingsView(APIView): BearerAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser ) - permission_classes = (IsStaffOrCourseTeam,) + permission_classes = (HasPagesAndResourcesAccess,) @apidocs.schema( parameters=[ @@ -134,7 +134,7 @@ class DiscussionsProvidersView(APIView): BearerAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser ) - permission_classes = (IsStaffOrCourseTeam,) + permission_classes = (HasPagesAndResourcesAccess,) @apidocs.schema( parameters=[ @@ -258,7 +258,7 @@ class SyncDiscussionTopicsView(APIView): View for syncing discussion topics for a course. """ authentication_classes = (BearerAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser) - permission_classes = (IsAuthenticated, IsStaffOrCourseTeam) + permission_classes = (IsAuthenticated, HasPagesAndResourcesAccess) def post(self, request, course_key_string): """