From daee143f0b2adc399b4247fffcc7765a3b2c0b8d Mon Sep 17 00:00:00 2001 From: Kevyn Suarez Date: Wed, 2 Sep 2026 20:31:48 -0500 Subject: [PATCH 1/2] fix: Course Editor gets 403 copying a unit to the clipboard ClipboardEndpoint.post() gated read access on has_studio_read_access(), a legacy-only check with no knowledge of AuthZ-native roles like course_editor and course_auditor. Switched to user_has_course_permission() with COURSES_VIEW_COURSE, falling back to the same legacy check when AuthZ course authoring isn't enabled for the course. Same pattern already used for the same class of bug in openedx-authz#384. --- .../content_staging/tests/test_clipboard.py | 41 +++++++++++++++++++ .../core/djangoapps/content_staging/views.py | 11 ++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py index 4c26f2c97cca..e4f1985b5aba 100644 --- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py +++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py @@ -6,8 +6,12 @@ from typing import cast from xml.etree import ElementTree +import ddt +from openedx_authz.constants.roles import COURSE_ADMIN, COURSE_AUDITOR, COURSE_EDITOR, COURSE_STAFF from rest_framework.test import APIClient +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content_staging import api as python_api from xmodule.contentstore.django import contentstore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course @@ -378,3 +382,40 @@ def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> None: a = ElementTree.canonicalize(xml_str_a, strip_text=True) b = ElementTree.canonicalize(xml_str_b, strip_text=True) assert a == b + + +@ddt.ddt +class ClipboardAuthzTest(CourseAuthoringAuthzTestMixin, ModuleStoreTestCase): + """ + Regression test for openedx-authz#403: ClipboardEndpoint.post() required legacy read + access via has_studio_read_access(), so AuthZ-native roles with no legacy equivalent + (course_auditor, course_editor) got a 403 when copying a unit to the clipboard despite + holding COURSES_VIEW_COURSE. + """ + + @ddt.data( + COURSE_STAFF.external_key, + COURSE_ADMIN.external_key, + COURSE_AUDITOR.external_key, + COURSE_EDITOR.external_key, + ) + def test_course_roles_can_copy_unit_to_clipboard(self, role_key): + course_key = ToyCourseFactory.create().id + html_key = course_key.make_usage_key("html", "toyhtml") + + role_user = UserFactory(password=self.password) + self.add_user_to_role_in_course(role_user, role_key, course_key) + + client = APIClient() + client.force_authenticate(user=role_user) + response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json") + + assert response.status_code == 200 + + def test_unauthorized_user_gets_permission_denied(self): + course_key = ToyCourseFactory.create().id + html_key = course_key.make_usage_key("html", "toyhtml") + + response = self.unauthorized_client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json") + + assert response.status_code == 403 diff --git a/openedx/core/djangoapps/content_staging/views.py b/openedx/core/djangoapps/content_staging/views.py index f013c0114a74..6bdab4198f26 100644 --- a/openedx/core/djangoapps/content_staging/views.py +++ b/openedx/core/djangoapps/content_staging/views.py @@ -12,11 +12,13 @@ from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.locator import CourseLocator, LibraryLocatorV2 from openedx_authz.constants import permissions as authz_permissions +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.response import Response from rest_framework.views import APIView -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.core.djangoapps.xblock import api as xblock_api from openedx.core.lib.api.view_utils import view_auth_classes from xmodule.modulestore.django import modulestore @@ -102,7 +104,12 @@ def post(self, request): try: if isinstance(course_key, CourseLocator): # Make sure the user has permission on that course - if not has_studio_read_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_COURSE.identifier, + course_key, + LegacyAuthoringPermission.READ, + ): raise PermissionDenied( "You must be a member of the course team in Studio to export OLX using this API." ) From 319f4b87e380532e7b8011df68fd8597b5a1169f Mon Sep 17 00:00:00 2001 From: Kevyn Suarez Date: Wed, 2 Sep 2026 20:39:40 -0500 Subject: [PATCH 2/2] test: wrap the 403 assertion in allow_transaction_exception Matches the existing test_no_course_permission's pattern for this same endpoint: a PermissionDenied raised mid-request under transaction.non_atomic_requests leaves ModuleStoreTestCase's cleanup unable to run its own queries afterward, unless the assertion is wrapped in allow_transaction_exception(). --- .../core/djangoapps/content_staging/tests/test_clipboard.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py index e4f1985b5aba..083125870113 100644 --- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py +++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py @@ -416,6 +416,6 @@ def test_unauthorized_user_gets_permission_denied(self): course_key = ToyCourseFactory.create().id html_key = course_key.make_usage_key("html", "toyhtml") - response = self.unauthorized_client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json") - - assert response.status_code == 403 + with self.allow_transaction_exception(): + response = self.unauthorized_client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(html_key)}, format="json") + assert response.status_code == 403