From 812e8b1d5196287e207e962c851e45c45c45c03f Mon Sep 17 00:00:00 2001 From: Kevyn Suarez Date: Tue, 15 Sep 2026 19:50:34 -0500 Subject: [PATCH 01/10] fix: let course_id bypass library access when reviewing pending updates The library container and xblock embed endpoints used by the "review pending changes" modal only checked direct library permissions, so a Course Auditor (or any role holding courses.view_library_updates) got a 403 even though they should be able to review changes from their course. Accept an optional course_id query param on these three endpoints and grant access via courses.view_library_updates when it points to a course the user can review from, falling back to the existing library-level check otherwise. Same pattern already used for the sync endpoint in #39009 and #39055. Related to openedx/openedx-authz#441 --- openedx/core/djangoapps/authz/decorators.py | 27 +++++++ .../djangoapps/authz/tests/test_decorators.py | 74 +++++++++++++++++++ .../content_libraries/rest_api/containers.py | 27 ++++--- .../tests/test_containers.py | 60 ++++++++++++++- .../content_libraries/tests/test_runtime.py | 49 ++++++++++++ .../core/djangoapps/xblock/rest_api/views.py | 14 +++- 6 files changed, 239 insertions(+), 12 deletions(-) diff --git a/openedx/core/djangoapps/authz/decorators.py b/openedx/core/djangoapps/authz/decorators.py index f72a214f92a9..137cd2fef2dc 100644 --- a/openedx/core/djangoapps/authz/decorators.py +++ b/openedx/core/djangoapps/authz/decorators.py @@ -124,3 +124,30 @@ def get_course_key(course_id: str) -> CourseKey: # Attempt to parse it as such and extract the course key. usage_key = UsageKey.from_string(course_id) return usage_key.course_key + + +def user_has_course_permission_from_query_param( + request, + authz_permission: str, + param_name: str = "course_id", +) -> bool: + """ + Check an AuthZ course permission using a course/usage id taken from a request query param. + + Meant for endpoints that are normally scoped to a library (or another non-course resource) + but that should also grant access to a user who holds a course-level permission, e.g. a + Course Auditor reviewing a library's pending changes from within a course they can't + otherwise view the library from. The caller is expected to fall back to its regular + resource-level permission check when this returns False. + + Returns False (never raises) if the query param is absent or not a valid course/usage id, + since that just means the bypass doesn't apply, not that the request is malformed. + """ + course_id = request.GET.get(param_name) + if not course_id: + return False + try: + course_key = get_course_key(course_id) + except InvalidKeyError: + return False + return user_has_course_permission(request.user, authz_permission, course_key) diff --git a/openedx/core/djangoapps/authz/tests/test_decorators.py b/openedx/core/djangoapps/authz/tests/test_decorators.py index 78aa923ceb8c..416f7c3f4b00 100644 --- a/openedx/core/djangoapps/authz/tests/test_decorators.py +++ b/openedx/core/djangoapps/authz/tests/test_decorators.py @@ -9,6 +9,7 @@ authz_permission_required, get_course_key, user_has_course_permission, + user_has_course_permission_from_query_param, ) from openedx.core.lib.api.view_utils import DeveloperErrorResponseException @@ -240,3 +241,76 @@ def test_usage_key_string(self): result = get_course_key(str(usage_key)) self.assertEqual(result, self.course_key) # noqa: PT009 + + +class UserHasCoursePermissionFromQueryParamTests(TestCase): + """Tests for user_has_course_permission_from_query_param.""" + + def setUp(self): + self.factory = RequestFactory() + self.course_key = CourseLocator("TestX", "TST101", "2025") + self.user = Mock() + + def test_missing_param_denies_without_checking_permission(self): + """No query param at all means the bypass doesn't apply.""" + request = self.factory.get("/test") + + with patch("openedx.core.djangoapps.authz.decorators.user_has_course_permission") as mock_check: + result = user_has_course_permission_from_query_param(request, "courses.view_library_updates") + + assert result is False + mock_check.assert_not_called() + + def test_invalid_course_id_denies_without_checking_permission(self): + """A malformed course/usage id is treated as absent, not as an error.""" + request = self.factory.get("/test", {"course_id": "not-a-real-key"}) + + with patch("openedx.core.djangoapps.authz.decorators.user_has_course_permission") as mock_check: + result = user_has_course_permission_from_query_param(request, "courses.view_library_updates") + + assert result is False + mock_check.assert_not_called() + + def test_valid_course_id_delegates_to_permission_check(self): + """A valid course id is parsed and passed through to the real permission check.""" + request = self.factory.get("/test", {"course_id": str(self.course_key)}) + request.user = self.user + + with patch( + "openedx.core.djangoapps.authz.decorators.user_has_course_permission", + return_value=True, + ) as mock_check: + result = user_has_course_permission_from_query_param(request, "courses.view_library_updates") + + assert result is True + mock_check.assert_called_once_with(self.user, "courses.view_library_updates", self.course_key) + + def test_usage_key_in_param_resolves_to_its_course(self): + """A usage key (not just a bare course key) resolves to the course it belongs to.""" + usage_key = BlockUsageLocator(self.course_key, "html", "block1") + request = self.factory.get("/test", {"course_id": str(usage_key)}) + request.user = self.user + + with patch( + "openedx.core.djangoapps.authz.decorators.user_has_course_permission", + return_value=True, + ) as mock_check: + result = user_has_course_permission_from_query_param(request, "courses.view_library_updates") + + assert result is True + mock_check.assert_called_once_with(self.user, "courses.view_library_updates", self.course_key) + + def test_custom_param_name(self): + """The query param name can be overridden.""" + request = self.factory.get("/test", {"downstream_course_id": str(self.course_key)}) + request.user = self.user + + with patch( + "openedx.core.djangoapps.authz.decorators.user_has_course_permission", + return_value=True, + ): + result = user_has_course_permission_from_query_param( + request, "courses.view_library_updates", param_name="downstream_course_id", + ) + + assert result is True diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index e415620e59fd..aa5463fddc00 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -20,6 +20,7 @@ from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT from rest_framework.views import APIView +from openedx.core.djangoapps.authz.decorators import user_has_course_permission_from_query_param from openedx.core.djangoapps.content_libraries import api, permissions from openedx.core.lib.api.view_utils import view_auth_classes from openedx.core.types.http import RestRequest @@ -81,11 +82,14 @@ def get(self, request, container_key: LibraryContainerLocator): """ Get information about a container """ - api.require_permission_for_library_key( - container_key.lib_key, - request.user, - permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, - ) + if not user_has_course_permission_from_query_param( + request, authz_permissions.COURSES_VIEW_LIBRARY_UPDATES.identifier + ): + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) container = api.get_container(container_key, include_collections=True) return Response(serializers.LibraryContainerMetadataSerializer(container).data) @@ -186,11 +190,14 @@ def get(self, request, container_key: LibraryContainerLocator): ] """ published = request.GET.get('published', 'false').lower() == 'true' - api.require_permission_for_library_key( - container_key.lib_key, - request.user, - permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, - ) + if not user_has_course_permission_from_query_param( + request, authz_permissions.COURSES_VIEW_LIBRARY_UPDATES.identifier + ): + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) child_entities = api.get_container_children(container_key, published=published) if container_key.container_type == content_models.Unit.type_code: data = serializers.LibraryXBlockMetadataSerializer(child_entities, many=True).data diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 3900234f1db5..74aaefa22d5f 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -8,10 +8,16 @@ import ddt from freezegun import freeze_time from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 +from openedx_authz.constants.roles import COURSE_AUDITOR from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content_libraries import api -from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest +from openedx.core.djangoapps.content_libraries.tests.base import ( + URL_LIB_CONTAINER, + URL_LIB_CONTAINER_CHILDREN, + ContentLibrariesRestApiTest, +) from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangolib.testing.utils import skip_unless_cms @@ -1322,3 +1328,55 @@ def test_container_draft_history_permissions(self): unauthorized = UserFactory.create(username="noauth-container-hist", password="edx") with self.as_user(unauthorized): self._get_container_draft_history(unit["id"], expect_response=403) + + +@skip_unless_cms +class ContainerLibraryUpdatesAuthzBypassTest(CourseAuthoringAuthzTestMixin, ContentLibrariesRestApiTest): + """ + A course auditor has no direct permissions on the library backing a unit they're + reviewing, but does hold `courses.view_library_updates` in the course. Passing that + course as `course_id` should let them view the container/children anyway. + + See openedx-authz#441. + """ + + def setUp(self): + super().setUp() + self.course_id = "course-v1:CL-TEST+TST101+2025" + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course_id) + + self.lib = self._create_library(slug="library-updates-lib", title="Library Updates Test Library") + self.unit = self._create_container(self.lib["id"], "unit", display_name="Reviewable Unit", slug=None) + + def test_container_detail_denied_without_course_id(self): + with self.as_user(self.authorized_user): + response = self.client.get(URL_LIB_CONTAINER.format(container_key=self.unit["id"])) + assert response.status_code == 403 + + def test_container_detail_allowed_with_course_id(self): + with self.as_user(self.authorized_user): + response = self.client.get( + URL_LIB_CONTAINER.format(container_key=self.unit["id"]), {"course_id": self.course_id}, + ) + assert response.status_code == 200 + + def test_container_children_denied_without_course_id(self): + with self.as_user(self.authorized_user): + response = self.client.get(URL_LIB_CONTAINER_CHILDREN.format(container_key=self.unit["id"])) + assert response.status_code == 403 + + def test_container_children_allowed_with_course_id(self): + with self.as_user(self.authorized_user): + response = self.client.get( + URL_LIB_CONTAINER_CHILDREN.format(container_key=self.unit["id"]), {"course_id": self.course_id}, + ) + assert response.status_code == 200 + + def test_unrelated_course_id_is_denied(self): + """A course_id where the user holds no role at all must not grant access.""" + with self.as_user(self.authorized_user): + response = self.client.get( + URL_LIB_CONTAINER.format(container_key=self.unit["id"]), + {"course_id": "course-v1:CL-TEST+OTHER101+2025"}, + ) + assert response.status_code == 403 diff --git a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py index b67b21498166..6240b56ed222 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py @@ -8,6 +8,7 @@ from django.db import connections, transaction from django.test import TestCase, override_settings from django.utils.text import slugify +from openedx_authz.constants.roles import COURSE_AUDITOR from organizations.models import Organization from rest_framework.test import APIClient from xblock.core import XBlock @@ -15,9 +16,11 @@ from common.djangoapps.student.tests.factories import UserFactory from common.test.utils import assert_dict_contains_subset from lms.djangoapps.courseware.model_data import get_score +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content_libraries import api as library_api from openedx.core.djangoapps.content_libraries.constants import ALL_RIGHTS_RESERVED from openedx.core.djangoapps.content_libraries.tests.base import ( + URL_BLOCK_EMBED_VIEW, URL_BLOCK_FIELDS_URL, URL_BLOCK_GET_HANDLER_URL, URL_BLOCK_METADATA_URL, @@ -254,6 +257,52 @@ def test_xblock_fields(self): assert block_saved.display_name == 'New Display Name' +@skip_unless_cms +class ContentLibraryEmbedViewAuthzBypassTest(ContentLibraryContentTestMixin, CourseAuthoringAuthzTestMixin, TestCase): + """ + A course auditor has no direct permissions on the library backing a block they're + reviewing, but does hold `courses.view_library_updates` in the course. Passing that + course as `course_id` should let them view the block's embed anyway. + + See openedx-authz#441. + """ + + def setUp(self): + super().setUp() + self.course_id = "course-v1:CL-TEST+TST101+2025" + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course_id) + + block_metadata = library_api.create_library_block(self.library.key, "html", "html-embed-test") + library_api.set_library_block_olx(block_metadata.usage_key, "Hello world") + library_api.publish_changes(self.library.key) + self.block_usage_key = block_metadata.usage_key + + def test_embed_denied_without_course_id(self): + client = APIClient() + client.force_authenticate(user=self.authorized_user) + response = client.get(URL_BLOCK_EMBED_VIEW.format(block_key=self.block_usage_key, view_name="student_view")) + assert response.status_code == 403 + + def test_embed_allowed_with_course_id(self): + client = APIClient() + client.force_authenticate(user=self.authorized_user) + response = client.get( + URL_BLOCK_EMBED_VIEW.format(block_key=self.block_usage_key, view_name="student_view"), + {"course_id": self.course_id}, + ) + assert response.status_code == 200 + + def test_unrelated_course_id_is_denied(self): + """A course_id where the user holds no role at all must not grant access.""" + client = APIClient() + client.force_authenticate(user=self.authorized_user) + response = client.get( + URL_BLOCK_EMBED_VIEW.format(block_key=self.block_usage_key, view_name="student_view"), + {"course_id": "course-v1:CL-TEST+OTHER101+2025"}, + ) + assert response.status_code == 403 + + # EphemeralKeyValueStore requires a working cache, and the default test cache is a dummy cache. @override_settings( XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE='default', diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py index 36fc1b9ae6d1..1d043f361166 100644 --- a/openedx/core/djangoapps/xblock/rest_api/views.py +++ b/openedx/core/djangoapps/xblock/rest_api/views.py @@ -14,6 +14,7 @@ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import csrf_exempt from opaque_keys.edx.keys import UsageKeyV2 +from openedx_authz.constants.permissions import COURSES_VIEW_LIBRARY_UPDATES from rest_framework import permissions, serializers from rest_framework.decorators import api_view, permission_classes # pylint: disable=unused-import from rest_framework.exceptions import AuthenticationFailed, NotFound, PermissionDenied @@ -26,6 +27,7 @@ import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers from common.djangoapps.util.json_request import JsonResponse +from openedx.core.djangoapps.authz.decorators import user_has_course_permission_from_query_param from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl from openedx.core.lib.api.view_utils import view_auth_classes @@ -104,8 +106,18 @@ def embed_block_view(request, usage_key: UsageKeyV2, view_name: str): except ValueError as exc: raise serializers.ValidationError("Invalid version specifier") from exc + # A user reviewing a library's pending changes from within a course (e.g. a Course + # Auditor with courses.view_library_updates) may not have direct access to the + # upstream library. That course-level permission substitutes for the regular + # library-level check below. + check_permission = CheckPerm.CAN_LEARN + if user_has_course_permission_from_query_param( + request, COURSES_VIEW_LIBRARY_UPDATES.identifier + ): + check_permission = None + try: - block = load_block(usage_key, request.user, check_permission=CheckPerm.CAN_LEARN, version=version) + block = load_block(usage_key, request.user, check_permission=check_permission, version=version) except NoSuchUsage as exc: raise NotFound(f"{usage_key} not found") from exc From aecd276f2ba352ea51954bc160101e9fa6a91149 Mon Sep 17 00:00:00 2001 From: Kevyn Suarez Date: Tue, 15 Sep 2026 20:22:44 -0500 Subject: [PATCH 02/10] fix: address CI failures from static analysis and test coverage mypy needs check_permission annotated as CheckPerm | None explicitly, since the first assignment alone made it infer plain CheckPerm. Move the embed view's authz bypass test out of test_runtime.py (a plain TestCase, missing CORS_ORIGIN_WHITELIST in the CMS test environment) into test_embed_block.py, next to the other embed view tests, using the same override_settings(CORS_ORIGIN_WHITELIST=[]) workaround already in place there for the same reason. --- .../tests/test_embed_block.py | 49 ++++++++++++++++++- .../content_libraries/tests/test_runtime.py | 49 ------------------- .../core/djangoapps/xblock/rest_api/views.py | 2 +- 3 files changed, 49 insertions(+), 51 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py index 8f0fb85eebc8..9e7f258a21f1 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py @@ -9,9 +9,11 @@ import pytest from django.core.exceptions import ValidationError from django.test.utils import override_settings +from openedx_authz.constants.roles import COURSE_AUDITOR from xblock.core import XBlock -from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from openedx.core.djangoapps.content_libraries.tests.base import URL_BLOCK_EMBED_VIEW, ContentLibrariesRestApiTest from openedx.core.djangolib.testing.utils import skip_unless_cms from .fields_test_block import FieldsTestBlock @@ -227,3 +229,48 @@ def test_embed_view_versions_static_assets(self): # TODO: if we are ever able to run these tests in the LMS, test that the LMS only allows accessing the published # version. + + +@skip_unless_cms +@override_settings(CORS_ORIGIN_WHITELIST=[]) # For some reason, this setting isn't defined in our test environment? +class EmbedViewAuthzBypassTest(CourseAuthoringAuthzTestMixin, ContentLibrariesRestApiTest): + """ + A course auditor has no direct permissions on the library backing a block they're + reviewing, but does hold `courses.view_library_updates` in the course. Passing that + course as `course_id` should let them view the block's embed anyway. + + See openedx-authz#441. + """ + + def setUp(self): + super().setUp() + self.course_id = "course-v1:CL-TEST+TST101+2025" + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course_id) + + lib = self._create_library(slug="embed-authz-bypass-lib", title="Embed AuthZ Bypass Test Library") + create_response = self._add_block_to_library(lib["id"], "html", "block1") + self.block_id = create_response["id"] + self._set_library_block_olx(self.block_id, "Hello world") + self._commit_library_changes(lib["id"]) + + def test_embed_denied_without_course_id(self): + with self.as_user(self.authorized_user): + response = self.client.get(URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view")) + assert response.status_code == 403 + + def test_embed_allowed_with_course_id(self): + with self.as_user(self.authorized_user): + response = self.client.get( + URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view"), + {"course_id": self.course_id}, + ) + assert response.status_code == 200 + + def test_unrelated_course_id_is_denied(self): + """A course_id where the user holds no role at all must not grant access.""" + with self.as_user(self.authorized_user): + response = self.client.get( + URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view"), + {"course_id": "course-v1:CL-TEST+OTHER101+2025"}, + ) + assert response.status_code == 403 diff --git a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py index 6240b56ed222..b67b21498166 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py @@ -8,7 +8,6 @@ from django.db import connections, transaction from django.test import TestCase, override_settings from django.utils.text import slugify -from openedx_authz.constants.roles import COURSE_AUDITOR from organizations.models import Organization from rest_framework.test import APIClient from xblock.core import XBlock @@ -16,11 +15,9 @@ from common.djangoapps.student.tests.factories import UserFactory from common.test.utils import assert_dict_contains_subset from lms.djangoapps.courseware.model_data import get_score -from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content_libraries import api as library_api from openedx.core.djangoapps.content_libraries.constants import ALL_RIGHTS_RESERVED from openedx.core.djangoapps.content_libraries.tests.base import ( - URL_BLOCK_EMBED_VIEW, URL_BLOCK_FIELDS_URL, URL_BLOCK_GET_HANDLER_URL, URL_BLOCK_METADATA_URL, @@ -257,52 +254,6 @@ def test_xblock_fields(self): assert block_saved.display_name == 'New Display Name' -@skip_unless_cms -class ContentLibraryEmbedViewAuthzBypassTest(ContentLibraryContentTestMixin, CourseAuthoringAuthzTestMixin, TestCase): - """ - A course auditor has no direct permissions on the library backing a block they're - reviewing, but does hold `courses.view_library_updates` in the course. Passing that - course as `course_id` should let them view the block's embed anyway. - - See openedx-authz#441. - """ - - def setUp(self): - super().setUp() - self.course_id = "course-v1:CL-TEST+TST101+2025" - self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course_id) - - block_metadata = library_api.create_library_block(self.library.key, "html", "html-embed-test") - library_api.set_library_block_olx(block_metadata.usage_key, "Hello world") - library_api.publish_changes(self.library.key) - self.block_usage_key = block_metadata.usage_key - - def test_embed_denied_without_course_id(self): - client = APIClient() - client.force_authenticate(user=self.authorized_user) - response = client.get(URL_BLOCK_EMBED_VIEW.format(block_key=self.block_usage_key, view_name="student_view")) - assert response.status_code == 403 - - def test_embed_allowed_with_course_id(self): - client = APIClient() - client.force_authenticate(user=self.authorized_user) - response = client.get( - URL_BLOCK_EMBED_VIEW.format(block_key=self.block_usage_key, view_name="student_view"), - {"course_id": self.course_id}, - ) - assert response.status_code == 200 - - def test_unrelated_course_id_is_denied(self): - """A course_id where the user holds no role at all must not grant access.""" - client = APIClient() - client.force_authenticate(user=self.authorized_user) - response = client.get( - URL_BLOCK_EMBED_VIEW.format(block_key=self.block_usage_key, view_name="student_view"), - {"course_id": "course-v1:CL-TEST+OTHER101+2025"}, - ) - assert response.status_code == 403 - - # EphemeralKeyValueStore requires a working cache, and the default test cache is a dummy cache. @override_settings( XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE='default', diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py index 1d043f361166..403c99a89a7c 100644 --- a/openedx/core/djangoapps/xblock/rest_api/views.py +++ b/openedx/core/djangoapps/xblock/rest_api/views.py @@ -110,7 +110,7 @@ def embed_block_view(request, usage_key: UsageKeyV2, view_name: str): # Auditor with courses.view_library_updates) may not have direct access to the # upstream library. That course-level permission substitutes for the regular # library-level check below. - check_permission = CheckPerm.CAN_LEARN + check_permission: CheckPerm | None = CheckPerm.CAN_LEARN if user_has_course_permission_from_query_param( request, COURSES_VIEW_LIBRARY_UPDATES.identifier ): From 37e9422935b429615b1edf438d88f8dcf28c0e0b Mon Sep 17 00:00:00 2001 From: Kevyn Suarez Date: Wed, 23 Sep 2026 11:58:06 -0500 Subject: [PATCH 03/10] fix: verify the downstream actually links to the requested resource The course_id bypass only checked that the user held courses.view_library_updates in the given course, never that the course actually used the requested library resource. That meant holding the permission in any course was enough to read any library container or block in the system. Require the query param to be a real downstream usage key, resolve its UpstreamLink, and only grant access when that link points at the resource being requested. Same approach already used by upstream_sync_block.py/upstream_sync_container.py for the sync endpoint. Also: - add regression tests for the mismatched-downstream case and for existing library-permission callers who never pass course_id - fix cms/envs/test.py to actually define CORS_ORIGIN_WHITELIST instead of overriding it per test class, embed_block_view reads it directly with no fallback Addresses review from @mariajgrimaldi and @BryanttV. --- cms/envs/test.py | 5 + openedx/core/djangoapps/authz/decorators.py | 59 ++++++--- .../djangoapps/authz/tests/test_decorators.py | 120 +++++++++++++----- .../content_libraries/rest_api/containers.py | 10 +- .../tests/test_containers.py | 72 +++++++++-- .../tests/test_embed_block.py | 56 ++++++-- .../core/djangoapps/xblock/rest_api/views.py | 6 +- 7 files changed, 252 insertions(+), 76 deletions(-) diff --git a/cms/envs/test.py b/cms/envs/test.py index b4d32a8aecc5..afa96990677f 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -240,3 +240,8 @@ INACTIVE_USER_URL = "http://localhost:18010" POLICY_CHANGE_GRADES_ROUTING_KEY = "edx.lms.core.default" SINGLE_LEARNER_COURSE_REGRADE_ROUTING_KEY = "edx.lms.core.default" + +# lms/envs/test.py sets this; the CMS side never has, but embed_block_view (used to preview +# library content, including from Studio) reads settings.CORS_ORIGIN_WHITELIST directly with +# no fallback, so any test that exercises it fails with an AttributeError unless this is set. +CORS_ORIGIN_WHITELIST = [] diff --git a/openedx/core/djangoapps/authz/decorators.py b/openedx/core/djangoapps/authz/decorators.py index 137cd2fef2dc..299378f6d733 100644 --- a/openedx/core/djangoapps/authz/decorators.py +++ b/openedx/core/djangoapps/authz/decorators.py @@ -126,28 +126,57 @@ def get_course_key(course_id: str) -> CourseKey: return usage_key.course_key -def user_has_course_permission_from_query_param( +def user_has_course_permission_for_upstream( request, authz_permission: str, + upstream_key, param_name: str = "course_id", ) -> bool: """ - Check an AuthZ course permission using a course/usage id taken from a request query param. - - Meant for endpoints that are normally scoped to a library (or another non-course resource) - but that should also grant access to a user who holds a course-level permission, e.g. a - Course Auditor reviewing a library's pending changes from within a course they can't - otherwise view the library from. The caller is expected to fall back to its regular - resource-level permission check when this returns False. - - Returns False (never raises) if the query param is absent or not a valid course/usage id, - since that just means the bypass doesn't apply, not that the request is malformed. + Check an AuthZ course permission for the course of a downstream block, but only if + that downstream's upstream link actually points at ``upstream_key``. + + Meant for endpoints that are normally scoped to a library resource (``upstream_key``, + a ``LibraryUsageLocatorV2`` or ``LibraryContainerLocator``) but should also grant + access to a user reviewing that exact resource from a course they can't otherwise + view the library from, e.g. a Course Auditor reviewing pending library updates. The + caller is expected to fall back to its regular resource-level permission check when + this returns False. + + The query param must be the *downstream block's* usage key, not a bare course id: we + resolve its real upstream link and compare it against ``upstream_key`` ourselves, + rather than trusting the caller's claim that the two are related. Otherwise, holding + the permission in any course would grant access to any library resource, whether or + not that course actually uses it. + + Returns False (never raises) for any reason the bypass doesn't apply: the param is + absent, not a valid usage key, the downstream doesn't exist, has no upstream link, or + that link doesn't match ``upstream_key``. """ - course_id = request.GET.get(param_name) - if not course_id: + # Imported locally: this pulls in CMS/xmodule-only code, which isn't available to + # every process that imports this module (e.g. a pure LMS process). + from cms.lib.xblock.upstream_sync import ( # pylint: disable=import-outside-toplevel + UpstreamLink, + UpstreamLinkException, + ) + from xmodule.modulestore.django import modulestore # pylint: disable=import-outside-toplevel + from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=import-outside-toplevel + + downstream_id = request.GET.get(param_name) + if not downstream_id: return False try: - course_key = get_course_key(course_id) + downstream_key = UsageKey.from_string(downstream_id) except InvalidKeyError: return False - return user_has_course_permission(request.user, authz_permission, course_key) + try: + downstream = modulestore().get_item(downstream_key) + except ItemNotFoundError: + return False + try: + link = UpstreamLink.get_for_block(downstream) + except UpstreamLinkException: + return False + if link.upstream_key != upstream_key: + return False + return user_has_course_permission(request.user, authz_permission, downstream_key.course_key) diff --git a/openedx/core/djangoapps/authz/tests/test_decorators.py b/openedx/core/djangoapps/authz/tests/test_decorators.py index 416f7c3f4b00..2c9f0fbf5d47 100644 --- a/openedx/core/djangoapps/authz/tests/test_decorators.py +++ b/openedx/core/djangoapps/authz/tests/test_decorators.py @@ -9,7 +9,7 @@ authz_permission_required, get_course_key, user_has_course_permission, - user_has_course_permission_from_query_param, + user_has_course_permission_for_upstream, ) from openedx.core.lib.api.view_utils import DeveloperErrorResponseException @@ -243,74 +243,134 @@ def test_usage_key_string(self): self.assertEqual(result, self.course_key) # noqa: PT009 -class UserHasCoursePermissionFromQueryParamTests(TestCase): - """Tests for user_has_course_permission_from_query_param.""" +class UserHasCoursePermissionForUpstreamTests(TestCase): + """Tests for user_has_course_permission_for_upstream.""" def setUp(self): self.factory = RequestFactory() self.course_key = CourseLocator("TestX", "TST101", "2025") + self.downstream_key = BlockUsageLocator(self.course_key, "html", "downstream1") + self.upstream_key = Mock(name="upstream_key") self.user = Mock() - def test_missing_param_denies_without_checking_permission(self): + def test_missing_param_denies_without_loading_anything(self): """No query param at all means the bypass doesn't apply.""" request = self.factory.get("/test") - with patch("openedx.core.djangoapps.authz.decorators.user_has_course_permission") as mock_check: - result = user_has_course_permission_from_query_param(request, "courses.view_library_updates") + with patch("xmodule.modulestore.django.modulestore") as mock_modulestore: + result = user_has_course_permission_for_upstream( + request, "courses.view_library_updates", self.upstream_key, + ) assert result is False - mock_check.assert_not_called() + mock_modulestore.assert_not_called() - def test_invalid_course_id_denies_without_checking_permission(self): - """A malformed course/usage id is treated as absent, not as an error.""" + def test_invalid_usage_key_denies_without_loading_anything(self): + """A malformed usage key is treated as absent, not as an error.""" request = self.factory.get("/test", {"course_id": "not-a-real-key"}) - with patch("openedx.core.djangoapps.authz.decorators.user_has_course_permission") as mock_check: - result = user_has_course_permission_from_query_param(request, "courses.view_library_updates") + with patch("xmodule.modulestore.django.modulestore") as mock_modulestore: + result = user_has_course_permission_for_upstream( + request, "courses.view_library_updates", self.upstream_key, + ) + + assert result is False + mock_modulestore.assert_not_called() + + def test_downstream_not_found_denies(self): + """If the downstream block doesn't exist, the bypass doesn't apply.""" + from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=import-outside-toplevel + + request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) + + with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + "openedx.core.djangoapps.authz.decorators.user_has_course_permission", + ) as mock_check: + mock_modulestore.return_value.get_item.side_effect = ItemNotFoundError + result = user_has_course_permission_for_upstream( + request, "courses.view_library_updates", self.upstream_key, + ) assert result is False mock_check.assert_not_called() - def test_valid_course_id_delegates_to_permission_check(self): - """A valid course id is parsed and passed through to the real permission check.""" - request = self.factory.get("/test", {"course_id": str(self.course_key)}) - request.user = self.user + def test_downstream_with_no_upstream_link_denies(self): + """If the downstream has no (valid) link to any upstream, the bypass doesn't apply.""" + from cms.lib.xblock.upstream_sync import NoUpstream # pylint: disable=import-outside-toplevel - with patch( + request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) + + with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + "cms.lib.xblock.upstream_sync.UpstreamLink.get_for_block", side_effect=NoUpstream(), + ), patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", - return_value=True, ) as mock_check: - result = user_has_course_permission_from_query_param(request, "courses.view_library_updates") + mock_modulestore.return_value.get_item.return_value = Mock() + result = user_has_course_permission_for_upstream( + request, "courses.view_library_updates", self.upstream_key, + ) - assert result is True - mock_check.assert_called_once_with(self.user, "courses.view_library_updates", self.course_key) + assert result is False + mock_check.assert_not_called() + + def test_mismatched_upstream_denies(self): + """ + If the downstream links to a *different* upstream than the one being requested, + the bypass doesn't apply. This is the crux of the fix for openedx-authz#441: holding + the course permission isn't enough by itself, the linked resource has to match. + """ + request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) + unrelated_upstream_key = Mock(name="a different upstream_key") + + with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + "cms.lib.xblock.upstream_sync.UpstreamLink.get_for_block", + return_value=Mock(upstream_key=unrelated_upstream_key), + ), patch( + "openedx.core.djangoapps.authz.decorators.user_has_course_permission", + ) as mock_check: + mock_modulestore.return_value.get_item.return_value = Mock() + result = user_has_course_permission_for_upstream( + request, "courses.view_library_updates", self.upstream_key, + ) - def test_usage_key_in_param_resolves_to_its_course(self): - """A usage key (not just a bare course key) resolves to the course it belongs to.""" - usage_key = BlockUsageLocator(self.course_key, "html", "block1") - request = self.factory.get("/test", {"course_id": str(usage_key)}) + assert result is False + mock_check.assert_not_called() + + def test_matching_upstream_delegates_to_permission_check(self): + """If the downstream's upstream link matches, we check the course permission.""" + request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) request.user = self.user - with patch( + with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + "cms.lib.xblock.upstream_sync.UpstreamLink.get_for_block", + return_value=Mock(upstream_key=self.upstream_key), + ), patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", return_value=True, ) as mock_check: - result = user_has_course_permission_from_query_param(request, "courses.view_library_updates") + mock_modulestore.return_value.get_item.return_value = Mock() + result = user_has_course_permission_for_upstream( + request, "courses.view_library_updates", self.upstream_key, + ) assert result is True mock_check.assert_called_once_with(self.user, "courses.view_library_updates", self.course_key) def test_custom_param_name(self): """The query param name can be overridden.""" - request = self.factory.get("/test", {"downstream_course_id": str(self.course_key)}) + request = self.factory.get("/test", {"downstream_id": str(self.downstream_key)}) request.user = self.user - with patch( + with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + "cms.lib.xblock.upstream_sync.UpstreamLink.get_for_block", + return_value=Mock(upstream_key=self.upstream_key), + ), patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", return_value=True, ): - result = user_has_course_permission_from_query_param( - request, "courses.view_library_updates", param_name="downstream_course_id", + mock_modulestore.return_value.get_item.return_value = Mock() + result = user_has_course_permission_for_upstream( + request, "courses.view_library_updates", self.upstream_key, param_name="downstream_id", ) assert result is True diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index aa5463fddc00..fe0fb2a508e2 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -20,7 +20,7 @@ from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT from rest_framework.views import APIView -from openedx.core.djangoapps.authz.decorators import user_has_course_permission_from_query_param +from openedx.core.djangoapps.authz.decorators import user_has_course_permission_for_upstream from openedx.core.djangoapps.content_libraries import api, permissions from openedx.core.lib.api.view_utils import view_auth_classes from openedx.core.types.http import RestRequest @@ -82,8 +82,8 @@ def get(self, request, container_key: LibraryContainerLocator): """ Get information about a container """ - if not user_has_course_permission_from_query_param( - request, authz_permissions.COURSES_VIEW_LIBRARY_UPDATES.identifier + if not user_has_course_permission_for_upstream( + request, authz_permissions.COURSES_VIEW_LIBRARY_UPDATES.identifier, container_key ): api.require_permission_for_library_key( container_key.lib_key, @@ -190,8 +190,8 @@ def get(self, request, container_key: LibraryContainerLocator): ] """ published = request.GET.get('published', 'false').lower() == 'true' - if not user_has_course_permission_from_query_param( - request, authz_permissions.COURSES_VIEW_LIBRARY_UPDATES.identifier + if not user_has_course_permission_for_upstream( + request, authz_permissions.COURSES_VIEW_LIBRARY_UPDATES.identifier, container_key ): api.require_permission_for_library_key( container_key.lib_key, diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 74aaefa22d5f..9ebddb319507 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -20,6 +20,8 @@ ) from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangolib.testing.utils import skip_unless_cms +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory @skip_unless_cms @@ -1331,44 +1333,65 @@ def test_container_draft_history_permissions(self): @skip_unless_cms -class ContainerLibraryUpdatesAuthzBypassTest(CourseAuthoringAuthzTestMixin, ContentLibrariesRestApiTest): +class ContainerLibraryUpdatesAuthzBypassTest(CourseAuthoringAuthzTestMixin, ContentLibrariesRestApiTest, ModuleStoreTestCase): """ A course auditor has no direct permissions on the library backing a unit they're - reviewing, but does hold `courses.view_library_updates` in the course. Passing that - course as `course_id` should let them view the container/children anyway. + reviewing, but does hold `courses.view_library_updates` in the course. Passing the + linked downstream unit's usage key as `course_id` should let them view the upstream + container/children anyway, but only because that specific downstream is actually + linked to it: holding the permission in the course alone isn't enough. See openedx-authz#441. """ def setUp(self): super().setUp() - self.course_id = "course-v1:CL-TEST+TST101+2025" - self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course_id) + self.course = CourseFactory.create() + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, str(self.course.id)) self.lib = self._create_library(slug="library-updates-lib", title="Library Updates Test Library") - self.unit = self._create_container(self.lib["id"], "unit", display_name="Reviewable Unit", slug=None) + self.container_key = self._create_container( + self.lib["id"], "unit", display_name="Reviewable Unit", slug=None, + )["id"] + + # Linked to self.container_key: this is the downstream that should grant access. + self.linked_downstream_id = str( + BlockFactory.create(category="vertical", parent=self.course, upstream=self.container_key).location + ) + # In the same (accessible) course, but not linked to self.container_key at all: this + # is what openedx-authz#441 was actually about, holding the course permission must + # not be enough on its own, the resource has to be linked to that specific downstream. + self.unrelated_downstream_id = str( + BlockFactory.create(category="vertical", parent=self.course).location + ) def test_container_detail_denied_without_course_id(self): + """A course auditor with no library access is denied when course_id isn't given.""" with self.as_user(self.authorized_user): - response = self.client.get(URL_LIB_CONTAINER.format(container_key=self.unit["id"])) + response = self.client.get(URL_LIB_CONTAINER.format(container_key=self.container_key)) assert response.status_code == 403 - def test_container_detail_allowed_with_course_id(self): + def test_container_detail_allowed_with_linked_downstream(self): + """The same course auditor is allowed once course_id points at the linked downstream.""" with self.as_user(self.authorized_user): response = self.client.get( - URL_LIB_CONTAINER.format(container_key=self.unit["id"]), {"course_id": self.course_id}, + URL_LIB_CONTAINER.format(container_key=self.container_key), + {"course_id": self.linked_downstream_id}, ) assert response.status_code == 200 def test_container_children_denied_without_course_id(self): + """Same as test_container_detail_denied_without_course_id, for the children endpoint.""" with self.as_user(self.authorized_user): - response = self.client.get(URL_LIB_CONTAINER_CHILDREN.format(container_key=self.unit["id"])) + response = self.client.get(URL_LIB_CONTAINER_CHILDREN.format(container_key=self.container_key)) assert response.status_code == 403 - def test_container_children_allowed_with_course_id(self): + def test_container_children_allowed_with_linked_downstream(self): + """Same as test_container_detail_allowed_with_linked_downstream, for the children endpoint.""" with self.as_user(self.authorized_user): response = self.client.get( - URL_LIB_CONTAINER_CHILDREN.format(container_key=self.unit["id"]), {"course_id": self.course_id}, + URL_LIB_CONTAINER_CHILDREN.format(container_key=self.container_key), + {"course_id": self.linked_downstream_id}, ) assert response.status_code == 200 @@ -1376,7 +1399,30 @@ def test_unrelated_course_id_is_denied(self): """A course_id where the user holds no role at all must not grant access.""" with self.as_user(self.authorized_user): response = self.client.get( - URL_LIB_CONTAINER.format(container_key=self.unit["id"]), + URL_LIB_CONTAINER.format(container_key=self.container_key), {"course_id": "course-v1:CL-TEST+OTHER101+2025"}, ) assert response.status_code == 403 + + def test_downstream_not_linked_to_this_container_is_denied(self): + """ + A downstream in a course the user can review, but that isn't linked to this + container, must not grant access. Regression test for openedx-authz#441: the + original implementation trusted the course_id alone, so holding the permission in + any course was enough to read any library resource, whether or not that course + actually used it. + """ + with self.as_user(self.authorized_user): + response = self.client.get( + URL_LIB_CONTAINER.format(container_key=self.container_key), + {"course_id": self.unrelated_downstream_id}, + ) + assert response.status_code == 403 + + def test_library_permission_alone_still_works_without_course_id(self): + """ + Backward compatibility: a user with direct library access (self.user, who created + the library) keeps working exactly as before, without ever passing course_id. + """ + response = self.client.get(URL_LIB_CONTAINER.format(container_key=self.container_key)) + assert response.status_code == 200 diff --git a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py index 9e7f258a21f1..e3ea459f991d 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py @@ -8,20 +8,20 @@ import ddt import pytest from django.core.exceptions import ValidationError -from django.test.utils import override_settings from openedx_authz.constants.roles import COURSE_AUDITOR from xblock.core import XBlock from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content_libraries.tests.base import URL_BLOCK_EMBED_VIEW, ContentLibrariesRestApiTest from openedx.core.djangolib.testing.utils import skip_unless_cms +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory from .fields_test_block import FieldsTestBlock @skip_unless_cms @ddt.ddt -@override_settings(CORS_ORIGIN_WHITELIST=[]) # For some reason, this setting isn't defined in our test environment? class LibrariesEmbedViewTestCase(ContentLibrariesRestApiTest): """ Tests for embed_view and interacting with draft/published/past versions of @@ -232,20 +232,21 @@ def test_embed_view_versions_static_assets(self): @skip_unless_cms -@override_settings(CORS_ORIGIN_WHITELIST=[]) # For some reason, this setting isn't defined in our test environment? -class EmbedViewAuthzBypassTest(CourseAuthoringAuthzTestMixin, ContentLibrariesRestApiTest): +class EmbedViewAuthzBypassTest(CourseAuthoringAuthzTestMixin, ContentLibrariesRestApiTest, ModuleStoreTestCase): """ A course auditor has no direct permissions on the library backing a block they're - reviewing, but does hold `courses.view_library_updates` in the course. Passing that - course as `course_id` should let them view the block's embed anyway. + reviewing, but does hold `courses.view_library_updates` in the course. Passing the + linked downstream block's usage key as `course_id` should let them view the upstream + block's embed anyway, but only because that specific downstream is actually linked to + it: holding the permission in the course alone isn't enough. See openedx-authz#441. """ def setUp(self): super().setUp() - self.course_id = "course-v1:CL-TEST+TST101+2025" - self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course_id) + self.course = CourseFactory.create() + self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, str(self.course.id)) lib = self._create_library(slug="embed-authz-bypass-lib", title="Embed AuthZ Bypass Test Library") create_response = self._add_block_to_library(lib["id"], "html", "block1") @@ -253,16 +254,29 @@ def setUp(self): self._set_library_block_olx(self.block_id, "Hello world") self._commit_library_changes(lib["id"]) + # Linked to self.block_id: this is the downstream that should grant access. + self.linked_downstream_id = str( + BlockFactory.create(category="html", parent=self.course, upstream=self.block_id).location + ) + # In the same (accessible) course, but not linked to self.block_id at all: this is + # what openedx-authz#441 was actually about, holding the course permission must not + # be enough on its own, the resource has to be linked to that specific downstream. + self.unrelated_downstream_id = str( + BlockFactory.create(category="html", parent=self.course).location + ) + def test_embed_denied_without_course_id(self): + """A course auditor with no library access is denied when course_id isn't given.""" with self.as_user(self.authorized_user): response = self.client.get(URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view")) assert response.status_code == 403 - def test_embed_allowed_with_course_id(self): + def test_embed_allowed_with_linked_downstream(self): + """The same course auditor is allowed once course_id points at the linked downstream.""" with self.as_user(self.authorized_user): response = self.client.get( URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view"), - {"course_id": self.course_id}, + {"course_id": self.linked_downstream_id}, ) assert response.status_code == 200 @@ -274,3 +288,25 @@ def test_unrelated_course_id_is_denied(self): {"course_id": "course-v1:CL-TEST+OTHER101+2025"}, ) assert response.status_code == 403 + + def test_downstream_not_linked_to_this_block_is_denied(self): + """ + A downstream in a course the user can review, but that isn't linked to this block, + must not grant access. Regression test for openedx-authz#441: the original + implementation trusted the course_id alone, so holding the permission in any course + was enough to read any library resource, whether or not that course actually used it. + """ + with self.as_user(self.authorized_user): + response = self.client.get( + URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view"), + {"course_id": self.unrelated_downstream_id}, + ) + assert response.status_code == 403 + + def test_library_permission_alone_still_works_without_course_id(self): + """ + Backward compatibility: a user with direct library access (self.user, who created + the library) keeps working exactly as before, without ever passing course_id. + """ + response = self.client.get(URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view")) + assert response.status_code == 200 diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py index 403c99a89a7c..562e7df71a29 100644 --- a/openedx/core/djangoapps/xblock/rest_api/views.py +++ b/openedx/core/djangoapps/xblock/rest_api/views.py @@ -27,7 +27,7 @@ import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers from common.djangoapps.util.json_request import JsonResponse -from openedx.core.djangoapps.authz.decorators import user_has_course_permission_from_query_param +from openedx.core.djangoapps.authz.decorators import user_has_course_permission_for_upstream from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl from openedx.core.lib.api.view_utils import view_auth_classes @@ -111,8 +111,8 @@ def embed_block_view(request, usage_key: UsageKeyV2, view_name: str): # upstream library. That course-level permission substitutes for the regular # library-level check below. check_permission: CheckPerm | None = CheckPerm.CAN_LEARN - if user_has_course_permission_from_query_param( - request, COURSES_VIEW_LIBRARY_UPDATES.identifier + if user_has_course_permission_for_upstream( + request, COURSES_VIEW_LIBRARY_UPDATES.identifier, usage_key ): check_permission = None From c47cb3310983cc114ee303a7adfcae290d1e18a7 Mon Sep 17 00:00:00 2001 From: Kevyn Suarez Date: Thu, 24 Sep 2026 10:32:44 -0500 Subject: [PATCH 04/10] fix: stop importing cms.lib.xblock.upstream_sync from a low-level app user_has_course_permission_for_upstream imported UpstreamLink from cms.lib.xblock.upstream_sync, which import-linter's layers contract places above content_libraries, content_staging and xblock. Those apps import authz.decorators, so the import violated the contract even though it was function-local, and broke CI's Lint Python Imports job for all three. Read the downstream block's upstream field directly instead of going through UpstreamLink, and parse it with LibraryUsageLocatorV2 or LibraryContainerLocator from opaque_keys, which isn't subject to the layers contract. Updated the tests to match: they now use real library keys and mock modulestore().get_item() to return an object with a plain upstream attribute, instead of mocking UpstreamLink. --- openedx/core/djangoapps/authz/decorators.py | 35 +++++++--- .../djangoapps/authz/tests/test_decorators.py | 67 +++++++++++++------ 2 files changed, 73 insertions(+), 29 deletions(-) diff --git a/openedx/core/djangoapps/authz/decorators.py b/openedx/core/djangoapps/authz/decorators.py index 299378f6d733..4d4603016135 100644 --- a/openedx/core/djangoapps/authz/decorators.py +++ b/openedx/core/djangoapps/authz/decorators.py @@ -152,13 +152,22 @@ def user_has_course_permission_for_upstream( Returns False (never raises) for any reason the bypass doesn't apply: the param is absent, not a valid usage key, the downstream doesn't exist, has no upstream link, or that link doesn't match ``upstream_key``. + + Deliberately doesn't use cms.lib.xblock.upstream_sync.UpstreamLink: this module is a + dependency of low-level apps like content_libraries and xblock (per the "low-level + apps should not depend on high-level apps" import-linter contract), so it can't import + from upstream_sync without creating a cycle. We only need the raw upstream reference, + so we read the block's ``upstream`` field directly (relying on it having the mixin + that provides that field applied elsewhere, not on importing that mixin ourselves) and + parse it ourselves, the same way UpstreamLink.get_for_block does internally. """ - # Imported locally: this pulls in CMS/xmodule-only code, which isn't available to - # every process that imports this module (e.g. a pure LMS process). - from cms.lib.xblock.upstream_sync import ( # pylint: disable=import-outside-toplevel - UpstreamLink, - UpstreamLinkException, + # Imported locally: this pulls in xmodule-only code, which isn't available to every + # process that imports this module (e.g. a pure LMS process without Studio installed). + from opaque_keys.edx.locator import ( # pylint: disable=import-outside-toplevel + LibraryContainerLocator, + LibraryUsageLocatorV2, ) + from xmodule.modulestore.django import modulestore # pylint: disable=import-outside-toplevel from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=import-outside-toplevel @@ -173,10 +182,18 @@ def user_has_course_permission_for_upstream( downstream = modulestore().get_item(downstream_key) except ItemNotFoundError: return False - try: - link = UpstreamLink.get_for_block(downstream) - except UpstreamLinkException: + + upstream_ref = getattr(downstream, "upstream", None) + if not upstream_ref: return False - if link.upstream_key != upstream_key: + try: + downstream_upstream_key = LibraryUsageLocatorV2.from_string(upstream_ref) + except InvalidKeyError: + try: + downstream_upstream_key = LibraryContainerLocator.from_string(upstream_ref) + except InvalidKeyError: + return False + + if downstream_upstream_key != upstream_key: return False return user_has_course_permission(request.user, authz_permission, downstream_key.course_key) diff --git a/openedx/core/djangoapps/authz/tests/test_decorators.py b/openedx/core/djangoapps/authz/tests/test_decorators.py index 2c9f0fbf5d47..a2f171e5e125 100644 --- a/openedx/core/djangoapps/authz/tests/test_decorators.py +++ b/openedx/core/djangoapps/authz/tests/test_decorators.py @@ -2,7 +2,13 @@ from unittest.mock import Mock, patch from django.test import RequestFactory, TestCase -from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator +from opaque_keys.edx.locator import ( + BlockUsageLocator, + CourseLocator, + LibraryContainerLocator, + LibraryLocatorV2, + LibraryUsageLocatorV2, +) from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission from openedx.core.djangoapps.authz.decorators import ( @@ -250,7 +256,8 @@ def setUp(self): self.factory = RequestFactory() self.course_key = CourseLocator("TestX", "TST101", "2025") self.downstream_key = BlockUsageLocator(self.course_key, "html", "downstream1") - self.upstream_key = Mock(name="upstream_key") + self.library_key = LibraryLocatorV2(org="TestX", slug="lib1") + self.upstream_key = LibraryUsageLocatorV2(self.library_key, block_type="html", usage_id="upstream1") self.user = Mock() def test_missing_param_denies_without_loading_anything(self): @@ -295,17 +302,28 @@ def test_downstream_not_found_denies(self): mock_check.assert_not_called() def test_downstream_with_no_upstream_link_denies(self): - """If the downstream has no (valid) link to any upstream, the bypass doesn't apply.""" - from cms.lib.xblock.upstream_sync import NoUpstream # pylint: disable=import-outside-toplevel + """If the downstream has no upstream field set at all, the bypass doesn't apply.""" + request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) + with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + "openedx.core.djangoapps.authz.decorators.user_has_course_permission", + ) as mock_check: + mock_modulestore.return_value.get_item.return_value = Mock(spec=["usage_key"]) + result = user_has_course_permission_for_upstream( + request, "courses.view_library_updates", self.upstream_key, + ) + + assert result is False + mock_check.assert_not_called() + + def test_downstream_with_malformed_upstream_denies(self): + """If the downstream's upstream field isn't a parseable library key, deny.""" request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( - "cms.lib.xblock.upstream_sync.UpstreamLink.get_for_block", side_effect=NoUpstream(), - ), patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", ) as mock_check: - mock_modulestore.return_value.get_item.return_value = Mock() + mock_modulestore.return_value.get_item.return_value = Mock(upstream="not-a-real-key") result = user_has_course_permission_for_upstream( request, "courses.view_library_updates", self.upstream_key, ) @@ -320,15 +338,12 @@ def test_mismatched_upstream_denies(self): the course permission isn't enough by itself, the linked resource has to match. """ request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) - unrelated_upstream_key = Mock(name="a different upstream_key") + unrelated_upstream_key = LibraryUsageLocatorV2(self.library_key, block_type="html", usage_id="other") with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( - "cms.lib.xblock.upstream_sync.UpstreamLink.get_for_block", - return_value=Mock(upstream_key=unrelated_upstream_key), - ), patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", ) as mock_check: - mock_modulestore.return_value.get_item.return_value = Mock() + mock_modulestore.return_value.get_item.return_value = Mock(upstream=str(unrelated_upstream_key)) result = user_has_course_permission_for_upstream( request, "courses.view_library_updates", self.upstream_key, ) @@ -342,13 +357,10 @@ def test_matching_upstream_delegates_to_permission_check(self): request.user = self.user with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( - "cms.lib.xblock.upstream_sync.UpstreamLink.get_for_block", - return_value=Mock(upstream_key=self.upstream_key), - ), patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", return_value=True, ) as mock_check: - mock_modulestore.return_value.get_item.return_value = Mock() + mock_modulestore.return_value.get_item.return_value = Mock(upstream=str(self.upstream_key)) result = user_has_course_permission_for_upstream( request, "courses.view_library_updates", self.upstream_key, ) @@ -356,19 +368,34 @@ def test_matching_upstream_delegates_to_permission_check(self): assert result is True mock_check.assert_called_once_with(self.user, "courses.view_library_updates", self.course_key) + def test_matching_container_upstream_delegates_to_permission_check(self): + """Same as above, but the upstream is a container (unit/subsection/section), not a block.""" + container_key = LibraryContainerLocator(self.library_key, container_type="unit", container_id="upstream-unit") + request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) + request.user = self.user + + with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + "openedx.core.djangoapps.authz.decorators.user_has_course_permission", + return_value=True, + ) as mock_check: + mock_modulestore.return_value.get_item.return_value = Mock(upstream=str(container_key)) + result = user_has_course_permission_for_upstream( + request, "courses.view_library_updates", container_key, + ) + + assert result is True + mock_check.assert_called_once_with(self.user, "courses.view_library_updates", self.course_key) + def test_custom_param_name(self): """The query param name can be overridden.""" request = self.factory.get("/test", {"downstream_id": str(self.downstream_key)}) request.user = self.user with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( - "cms.lib.xblock.upstream_sync.UpstreamLink.get_for_block", - return_value=Mock(upstream_key=self.upstream_key), - ), patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", return_value=True, ): - mock_modulestore.return_value.get_item.return_value = Mock() + mock_modulestore.return_value.get_item.return_value = Mock(upstream=str(self.upstream_key)) result = user_has_course_permission_for_upstream( request, "courses.view_library_updates", self.upstream_key, param_name="downstream_id", ) From 3a0fda65532ce447597bc86185e073abf805c5bf Mon Sep 17 00:00:00 2001 From: Kevyn Suarez Date: Thu, 24 Sep 2026 10:45:07 -0500 Subject: [PATCH 05/10] fix: wrap long class definition to satisfy pylint line-too-long --- .../djangoapps/content_libraries/tests/test_containers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 9ebddb319507..3cc7afec3344 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -1333,7 +1333,9 @@ def test_container_draft_history_permissions(self): @skip_unless_cms -class ContainerLibraryUpdatesAuthzBypassTest(CourseAuthoringAuthzTestMixin, ContentLibrariesRestApiTest, ModuleStoreTestCase): +class ContainerLibraryUpdatesAuthzBypassTest( + CourseAuthoringAuthzTestMixin, ContentLibrariesRestApiTest, ModuleStoreTestCase, +): """ A course auditor has no direct permissions on the library backing a unit they're reviewing, but does hold `courses.view_library_updates` in the course. Passing the From ebd25d9ff69a366e27b459c02c848b57f02d6d28 Mon Sep 17 00:00:00 2001 From: Kevyn Suarez Date: Fri, 25 Sep 2026 10:31:06 -0500 Subject: [PATCH 06/10] fix: avoid TransactionManagementError in denied-response tests Under ModuleStoreTestCase, ATOMIC_REQUESTS wraps each request in a savepoint nested inside the test's own outer transaction. When a view returns a DRF PermissionDenied, DRF marks that savepoint for rollback, and response middleware that runs a query afterward (lang_pref's process_response, checking the user's language preference) hits TransactionManagementError because the savepoint hasn't been rolled back yet. Wrap the requests that intentionally get a 403 in self.allow_transaction_exception(), the same pattern already used in test_clipboard.py for the same kind of test. --- .../djangoapps/content_libraries/tests/test_containers.py | 8 ++++---- .../content_libraries/tests/test_embed_block.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 3cc7afec3344..44f46dc51dbd 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -1369,7 +1369,7 @@ def setUp(self): def test_container_detail_denied_without_course_id(self): """A course auditor with no library access is denied when course_id isn't given.""" - with self.as_user(self.authorized_user): + with self.as_user(self.authorized_user), self.allow_transaction_exception(): response = self.client.get(URL_LIB_CONTAINER.format(container_key=self.container_key)) assert response.status_code == 403 @@ -1384,7 +1384,7 @@ def test_container_detail_allowed_with_linked_downstream(self): def test_container_children_denied_without_course_id(self): """Same as test_container_detail_denied_without_course_id, for the children endpoint.""" - with self.as_user(self.authorized_user): + with self.as_user(self.authorized_user), self.allow_transaction_exception(): response = self.client.get(URL_LIB_CONTAINER_CHILDREN.format(container_key=self.container_key)) assert response.status_code == 403 @@ -1399,7 +1399,7 @@ def test_container_children_allowed_with_linked_downstream(self): def test_unrelated_course_id_is_denied(self): """A course_id where the user holds no role at all must not grant access.""" - with self.as_user(self.authorized_user): + with self.as_user(self.authorized_user), self.allow_transaction_exception(): response = self.client.get( URL_LIB_CONTAINER.format(container_key=self.container_key), {"course_id": "course-v1:CL-TEST+OTHER101+2025"}, @@ -1414,7 +1414,7 @@ def test_downstream_not_linked_to_this_container_is_denied(self): any course was enough to read any library resource, whether or not that course actually used it. """ - with self.as_user(self.authorized_user): + with self.as_user(self.authorized_user), self.allow_transaction_exception(): response = self.client.get( URL_LIB_CONTAINER.format(container_key=self.container_key), {"course_id": self.unrelated_downstream_id}, diff --git a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py index e3ea459f991d..f20222cc35dc 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py @@ -267,7 +267,7 @@ def setUp(self): def test_embed_denied_without_course_id(self): """A course auditor with no library access is denied when course_id isn't given.""" - with self.as_user(self.authorized_user): + with self.as_user(self.authorized_user), self.allow_transaction_exception(): response = self.client.get(URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view")) assert response.status_code == 403 @@ -282,7 +282,7 @@ def test_embed_allowed_with_linked_downstream(self): def test_unrelated_course_id_is_denied(self): """A course_id where the user holds no role at all must not grant access.""" - with self.as_user(self.authorized_user): + with self.as_user(self.authorized_user), self.allow_transaction_exception(): response = self.client.get( URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view"), {"course_id": "course-v1:CL-TEST+OTHER101+2025"}, @@ -296,7 +296,7 @@ def test_downstream_not_linked_to_this_block_is_denied(self): implementation trusted the course_id alone, so holding the permission in any course was enough to read any library resource, whether or not that course actually used it. """ - with self.as_user(self.authorized_user): + with self.as_user(self.authorized_user), self.allow_transaction_exception(): response = self.client.get( URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view"), {"course_id": self.unrelated_downstream_id}, From f38325c264454d2abf37e6d25c55040156214a43 Mon Sep 17 00:00:00 2001 From: Kevyn Suarez Date: Wed, 14 Oct 2026 13:13:36 -0500 Subject: [PATCH 07/10] refactor: move xmodule imports in authz decorators to module level Function-level imports hid the dependency until the function ran and could produce import-order surprises later. Moving opaque_keys locators and xmodule.modulestore to the top is safe here: opaque_keys is already imported at the top of this module, xmodule is available in both LMS and CMS, other core apps already import modulestore at module level, and lint-imports reports the same dependency graph either way since grimp counts function-level imports too. Update test_decorators.py to patch the new module-level name (openedx.core.djangoapps.authz.decorators.modulestore) and move the local ItemNotFoundError import in test_downstream_not_found_denies to the top of the test module. --- openedx/core/djangoapps/authz/decorators.py | 13 +++--------- .../djangoapps/authz/tests/test_decorators.py | 21 +++++++++---------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/openedx/core/djangoapps/authz/decorators.py b/openedx/core/djangoapps/authz/decorators.py index 4d4603016135..72e05277ffd6 100644 --- a/openedx/core/djangoapps/authz/decorators.py +++ b/openedx/core/djangoapps/authz/decorators.py @@ -6,12 +6,15 @@ from django.contrib.auth.models import AbstractUser from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2 from openedx_authz import api as authz_api from rest_framework import status from common.djangoapps.student.roles import enable_authz_course_authoring from openedx.core.djangoapps.authz.constants import LEGACY_PERMISSION_HANDLER_MAP, LegacyAuthoringPermission from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError log = logging.getLogger(__name__) @@ -161,16 +164,6 @@ def user_has_course_permission_for_upstream( that provides that field applied elsewhere, not on importing that mixin ourselves) and parse it ourselves, the same way UpstreamLink.get_for_block does internally. """ - # Imported locally: this pulls in xmodule-only code, which isn't available to every - # process that imports this module (e.g. a pure LMS process without Studio installed). - from opaque_keys.edx.locator import ( # pylint: disable=import-outside-toplevel - LibraryContainerLocator, - LibraryUsageLocatorV2, - ) - - from xmodule.modulestore.django import modulestore # pylint: disable=import-outside-toplevel - from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=import-outside-toplevel - downstream_id = request.GET.get(param_name) if not downstream_id: return False diff --git a/openedx/core/djangoapps/authz/tests/test_decorators.py b/openedx/core/djangoapps/authz/tests/test_decorators.py index a2f171e5e125..c55af785bd05 100644 --- a/openedx/core/djangoapps/authz/tests/test_decorators.py +++ b/openedx/core/djangoapps/authz/tests/test_decorators.py @@ -18,6 +18,7 @@ user_has_course_permission_for_upstream, ) from openedx.core.lib.api.view_utils import DeveloperErrorResponseException +from xmodule.modulestore.exceptions import ItemNotFoundError class AuthzPermissionRequiredDecoratorTests(TestCase): @@ -264,7 +265,7 @@ def test_missing_param_denies_without_loading_anything(self): """No query param at all means the bypass doesn't apply.""" request = self.factory.get("/test") - with patch("xmodule.modulestore.django.modulestore") as mock_modulestore: + with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore: result = user_has_course_permission_for_upstream( request, "courses.view_library_updates", self.upstream_key, ) @@ -276,7 +277,7 @@ def test_invalid_usage_key_denies_without_loading_anything(self): """A malformed usage key is treated as absent, not as an error.""" request = self.factory.get("/test", {"course_id": "not-a-real-key"}) - with patch("xmodule.modulestore.django.modulestore") as mock_modulestore: + with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore: result = user_has_course_permission_for_upstream( request, "courses.view_library_updates", self.upstream_key, ) @@ -286,11 +287,9 @@ def test_invalid_usage_key_denies_without_loading_anything(self): def test_downstream_not_found_denies(self): """If the downstream block doesn't exist, the bypass doesn't apply.""" - from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=import-outside-toplevel - request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) - with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", ) as mock_check: mock_modulestore.return_value.get_item.side_effect = ItemNotFoundError @@ -305,7 +304,7 @@ def test_downstream_with_no_upstream_link_denies(self): """If the downstream has no upstream field set at all, the bypass doesn't apply.""" request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) - with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", ) as mock_check: mock_modulestore.return_value.get_item.return_value = Mock(spec=["usage_key"]) @@ -320,7 +319,7 @@ def test_downstream_with_malformed_upstream_denies(self): """If the downstream's upstream field isn't a parseable library key, deny.""" request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) - with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", ) as mock_check: mock_modulestore.return_value.get_item.return_value = Mock(upstream="not-a-real-key") @@ -340,7 +339,7 @@ def test_mismatched_upstream_denies(self): request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) unrelated_upstream_key = LibraryUsageLocatorV2(self.library_key, block_type="html", usage_id="other") - with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", ) as mock_check: mock_modulestore.return_value.get_item.return_value = Mock(upstream=str(unrelated_upstream_key)) @@ -356,7 +355,7 @@ def test_matching_upstream_delegates_to_permission_check(self): request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) request.user = self.user - with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", return_value=True, ) as mock_check: @@ -374,7 +373,7 @@ def test_matching_container_upstream_delegates_to_permission_check(self): request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) request.user = self.user - with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", return_value=True, ) as mock_check: @@ -391,7 +390,7 @@ def test_custom_param_name(self): request = self.factory.get("/test", {"downstream_id": str(self.downstream_key)}) request.user = self.user - with patch("xmodule.modulestore.django.modulestore") as mock_modulestore, patch( + with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", return_value=True, ): From 082264916f20583a76e05d0ddee3453115acb07c Mon Sep 17 00:00:00 2001 From: Kevyn Suarez Date: Thu, 15 Oct 2026 11:53:17 -0500 Subject: [PATCH 08/10] refactor: rename the course_id query param to downstream_block_id The param was never actually a course id, it's always been the downstream block's usage key (the decorator derives the course from it after verifying the upstream link), so the name was misleading to API consumers. Matches the frontend-app-authoring PR's own rename of the prop from courseId to downstreamBlockId for the same reason. No call site passes param_name explicitly, so this only changes the function's default and the tests that exercise it directly. --- openedx/core/djangoapps/authz/decorators.py | 2 +- .../core/djangoapps/authz/tests/test_decorators.py | 14 +++++++------- .../content_libraries/tests/test_containers.py | 8 ++++---- .../content_libraries/tests/test_embed_block.py | 6 +++--- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/openedx/core/djangoapps/authz/decorators.py b/openedx/core/djangoapps/authz/decorators.py index 72e05277ffd6..8046d6471589 100644 --- a/openedx/core/djangoapps/authz/decorators.py +++ b/openedx/core/djangoapps/authz/decorators.py @@ -133,7 +133,7 @@ def user_has_course_permission_for_upstream( request, authz_permission: str, upstream_key, - param_name: str = "course_id", + param_name: str = "downstream_block_id", ) -> bool: """ Check an AuthZ course permission for the course of a downstream block, but only if diff --git a/openedx/core/djangoapps/authz/tests/test_decorators.py b/openedx/core/djangoapps/authz/tests/test_decorators.py index c55af785bd05..be4bc4ad287d 100644 --- a/openedx/core/djangoapps/authz/tests/test_decorators.py +++ b/openedx/core/djangoapps/authz/tests/test_decorators.py @@ -275,7 +275,7 @@ def test_missing_param_denies_without_loading_anything(self): def test_invalid_usage_key_denies_without_loading_anything(self): """A malformed usage key is treated as absent, not as an error.""" - request = self.factory.get("/test", {"course_id": "not-a-real-key"}) + request = self.factory.get("/test", {"downstream_block_id": "not-a-real-key"}) with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore: result = user_has_course_permission_for_upstream( @@ -287,7 +287,7 @@ def test_invalid_usage_key_denies_without_loading_anything(self): def test_downstream_not_found_denies(self): """If the downstream block doesn't exist, the bypass doesn't apply.""" - request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) + request = self.factory.get("/test", {"downstream_block_id": str(self.downstream_key)}) with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", @@ -302,7 +302,7 @@ def test_downstream_not_found_denies(self): def test_downstream_with_no_upstream_link_denies(self): """If the downstream has no upstream field set at all, the bypass doesn't apply.""" - request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) + request = self.factory.get("/test", {"downstream_block_id": str(self.downstream_key)}) with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", @@ -317,7 +317,7 @@ def test_downstream_with_no_upstream_link_denies(self): def test_downstream_with_malformed_upstream_denies(self): """If the downstream's upstream field isn't a parseable library key, deny.""" - request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) + request = self.factory.get("/test", {"downstream_block_id": str(self.downstream_key)}) with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( "openedx.core.djangoapps.authz.decorators.user_has_course_permission", @@ -336,7 +336,7 @@ def test_mismatched_upstream_denies(self): the bypass doesn't apply. This is the crux of the fix for openedx-authz#441: holding the course permission isn't enough by itself, the linked resource has to match. """ - request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) + request = self.factory.get("/test", {"downstream_block_id": str(self.downstream_key)}) unrelated_upstream_key = LibraryUsageLocatorV2(self.library_key, block_type="html", usage_id="other") with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( @@ -352,7 +352,7 @@ def test_mismatched_upstream_denies(self): def test_matching_upstream_delegates_to_permission_check(self): """If the downstream's upstream link matches, we check the course permission.""" - request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) + request = self.factory.get("/test", {"downstream_block_id": str(self.downstream_key)}) request.user = self.user with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( @@ -370,7 +370,7 @@ def test_matching_upstream_delegates_to_permission_check(self): def test_matching_container_upstream_delegates_to_permission_check(self): """Same as above, but the upstream is a container (unit/subsection/section), not a block.""" container_key = LibraryContainerLocator(self.library_key, container_type="unit", container_id="upstream-unit") - request = self.factory.get("/test", {"course_id": str(self.downstream_key)}) + request = self.factory.get("/test", {"downstream_block_id": str(self.downstream_key)}) request.user = self.user with patch("openedx.core.djangoapps.authz.decorators.modulestore") as mock_modulestore, patch( diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 44f46dc51dbd..7ae70e5b54b9 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -1378,7 +1378,7 @@ def test_container_detail_allowed_with_linked_downstream(self): with self.as_user(self.authorized_user): response = self.client.get( URL_LIB_CONTAINER.format(container_key=self.container_key), - {"course_id": self.linked_downstream_id}, + {"downstream_block_id": self.linked_downstream_id}, ) assert response.status_code == 200 @@ -1393,7 +1393,7 @@ def test_container_children_allowed_with_linked_downstream(self): with self.as_user(self.authorized_user): response = self.client.get( URL_LIB_CONTAINER_CHILDREN.format(container_key=self.container_key), - {"course_id": self.linked_downstream_id}, + {"downstream_block_id": self.linked_downstream_id}, ) assert response.status_code == 200 @@ -1402,7 +1402,7 @@ def test_unrelated_course_id_is_denied(self): with self.as_user(self.authorized_user), self.allow_transaction_exception(): response = self.client.get( URL_LIB_CONTAINER.format(container_key=self.container_key), - {"course_id": "course-v1:CL-TEST+OTHER101+2025"}, + {"downstream_block_id": "course-v1:CL-TEST+OTHER101+2025"}, ) assert response.status_code == 403 @@ -1417,7 +1417,7 @@ def test_downstream_not_linked_to_this_container_is_denied(self): with self.as_user(self.authorized_user), self.allow_transaction_exception(): response = self.client.get( URL_LIB_CONTAINER.format(container_key=self.container_key), - {"course_id": self.unrelated_downstream_id}, + {"downstream_block_id": self.unrelated_downstream_id}, ) assert response.status_code == 403 diff --git a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py index f20222cc35dc..ed12475a8015 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py @@ -276,7 +276,7 @@ def test_embed_allowed_with_linked_downstream(self): with self.as_user(self.authorized_user): response = self.client.get( URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view"), - {"course_id": self.linked_downstream_id}, + {"downstream_block_id": self.linked_downstream_id}, ) assert response.status_code == 200 @@ -285,7 +285,7 @@ def test_unrelated_course_id_is_denied(self): with self.as_user(self.authorized_user), self.allow_transaction_exception(): response = self.client.get( URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view"), - {"course_id": "course-v1:CL-TEST+OTHER101+2025"}, + {"downstream_block_id": "course-v1:CL-TEST+OTHER101+2025"}, ) assert response.status_code == 403 @@ -299,7 +299,7 @@ def test_downstream_not_linked_to_this_block_is_denied(self): with self.as_user(self.authorized_user), self.allow_transaction_exception(): response = self.client.get( URL_BLOCK_EMBED_VIEW.format(block_key=self.block_id, view_name="student_view"), - {"course_id": self.unrelated_downstream_id}, + {"downstream_block_id": self.unrelated_downstream_id}, ) assert response.status_code == 403 From 4b4d3a3278dbab47b88190de75d6edc667930dbf Mon Sep 17 00:00:00 2001 From: Kevyn Suarez Date: Fri, 16 Oct 2026 13:26:27 -0500 Subject: [PATCH 09/10] fix: forward downstream_block_id when previewing a single library component The legacy Studio unit page's preview-changes modal only sent upstreamBlockId and old to the comparison iframe, dropping the downstreamBlockId it already had on hand. Without that param, the course-permission bypass in embed_block_view never triggers for either version, so a course auditor reviewing a single library component (as opposed to a section, subsection, or unit) gets denied instead of falling back to the regular library check, which they don't hold. Also add the same user_has_course_permission_for_upstream bypass to LibraryBlockView.get that LibraryContainerView.get already has: even with downstream_block_id correctly forwarded, the block metadata endpoint had no way to honor it. --- .../modals/preview_v2_library_changes.js | 2 +- .../content_libraries/rest_api/blocks.py | 6 +- .../tests/test_embed_block.py | 76 ++++++++++++++++++- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/cms/static/js/views/modals/preview_v2_library_changes.js b/cms/static/js/views/modals/preview_v2_library_changes.js index 943fe103220e..2f65bc65756f 100644 --- a/cms/static/js/views/modals/preview_v2_library_changes.js +++ b/cms/static/js/views/modals/preview_v2_library_changes.js @@ -65,7 +65,7 @@ function($, _, gettext, BaseModal, ViewUtils) { getContentHtml: function() { return ` -