From 6010daa640a3ed89485e7d8272b225ab65483bcc Mon Sep 17 00:00:00 2001 From: carlos-marquez-wgu Date: Tue, 1 Sep 2026 13:26:10 -0600 Subject: [PATCH 1/2] fix: allow Course Admin/Staff/Editor to sync library updates into a course Users with the courses.manage_library_updates permission (granted by Course Staff, Course Editor, and Course Admin roles) were unable to sync library updates into a course unless they also had explicit view permissions on the source library. This adds a course-level permission check in both sync_from_upstream_block and sync_from_upstream_container that skips the library-level permission check when the user holds manage_library_updates for the downstream course. A shared helper (user_has_manage_library_updates) centralizes the check with a legacy write-access fallback. --- .../v2/views/tests/test_downstreams.py | 60 +++++++++++++++++++ cms/lib/xblock/upstream_sync.py | 26 ++++++++ cms/lib/xblock/upstream_sync_block.py | 15 ++++- cms/lib/xblock/upstream_sync_container.py | 19 ++++-- 4 files changed, 112 insertions(+), 8 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index 149732437215..ea488f3b338a 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -1686,3 +1686,63 @@ def test_delete_component_should_be_ready_to_sync(self): } self.assertDictEqual(data[0], expected_results) # noqa: PT009 + + +class PostDownstreamSyncAuthzViewTest( + CourseAuthoringAuthzTestMixin, + _BaseDownstreamViewTestMixin, + ImmediateOnCommitMixin, + SharedModuleStoreTestCase, +): + """ + AuthZ tests for: + POST /api/contentstore/v2/downstreams/{usage_key}/sync + + Verifies that a user with the ``course_staff`` authz role (which includes + ``courses.manage_library_updates``) can sync a downstream container even + when the user has **no** permissions on the source library. + """ + + def call_api(self, usage_key_string): + return self.authorized_client.post( + f"/api/contentstore/v2/downstreams/{usage_key_string}/sync" + ) + + def test_course_staff_can_sync_container_without_library_access(self): + """ + A user with Course Staff role (which carries + ``courses.manage_library_updates``) should be able to sync a + downstream container from its upstream library, even when the user + has no explicit permissions on the library. + """ + # Give the user Course Staff in authz so they get manage_library_updates + from openedx_authz.constants.roles import COURSE_STAFF + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_STAFF.external_key, + self.course.id, + ) + + # Also give legacy CourseStaffRole so _load_accessible_block passes + add_users(self.superuser, CourseStaffRole(self.course.id), self.authorized_user) + + # Confirm the user has NO explicit permissions on the library. + assert lib_api.get_library_user_permissions( + self.library_key, self.authorized_user, + ) is None + + # The downstream_unit_key is linked to a container upstream in self.library. + # The unit was updated (display_name changed + republished) in setUp, + # so it is ready to sync. + response = self.call_api(self.downstream_unit_key) + + assert response.status_code == 200, ( + f"Expected 200 but got {response.status_code}: {getattr(response, 'data', '')}" + ) + + # Same test but for a block sync instead of a container one + response = self.call_api(self.downstream_html_key) + + assert response.status_code == 200, ( + f"Expected 200 but got {response.status_code}: {getattr(response, 'data', '')}" + ) diff --git a/cms/lib/xblock/upstream_sync.py b/cms/lib/xblock/upstream_sync.py index a8d9bbe298cd..ebde499ec0d8 100644 --- a/cms/lib/xblock/upstream_sync.py +++ b/cms/lib/xblock/upstream_sync.py @@ -359,6 +359,32 @@ def decline_sync(downstream: XBlock, user_id=None) -> None: store.update_item(downstream, user_id) +def user_has_manage_library_updates(user: User, course_key: CourseKey | None) -> bool: + """ + Return True if *course_key* is provided and *user* holds the + ``courses.manage_library_updates`` permission for that course. + + This is intentionally a thin wrapper so that both + ``upstream_sync_container`` and ``upstream_sync_block`` can share the + same check without duplicating authz imports. + """ + if course_key is None: + return False + + from openedx.core.djangoapps.authz.decorators import ( # pylint: disable=wrong-import-order + LegacyAuthoringPermission, + user_has_course_permission, + ) + from openedx_authz.constants.permissions import COURSES_MANAGE_LIBRARY_UPDATES # pylint: disable=wrong-import-order + + return user_has_course_permission( + user, + COURSES_MANAGE_LIBRARY_UPDATES.identifier, + course_key, + LegacyAuthoringPermission.WRITE, + ) + + def _update_children_top_level_parent( downstream: XBlock, new_top_level_parent_key: str | None, diff --git a/cms/lib/xblock/upstream_sync_block.py b/cms/lib/xblock/upstream_sync_block.py index 85a18072144f..243d73880a84 100644 --- a/cms/lib/xblock/upstream_sync_block.py +++ b/cms/lib/xblock/upstream_sync_block.py @@ -16,7 +16,7 @@ from xblock.core import XBlock from xblock.fields import Scope -from .upstream_sync import BadDownstream, BadUpstream, UpstreamLink +from .upstream_sync import BadDownstream, BadUpstream, UpstreamLink, user_has_manage_library_updates if t.TYPE_CHECKING: from django.contrib.auth.models import User # pylint: disable=imported-auth-user @@ -94,6 +94,10 @@ def _load_upstream_block(downstream: XBlock, user: User) -> XBlock: library. This assumption may need to be relaxed in the future (see module docstring). If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException. + + If the user holds ``courses.manage_library_updates`` for the course that + owns ``downstream``, the library-level permission check is bypassed. + Otherwise the default ``CAN_READ_AS_AUTHOR`` check is applied. """ # We import load_block here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready. from openedx.core.djangoapps.xblock.api import ( # pylint: disable=wrong-import-order @@ -101,11 +105,18 @@ def _load_upstream_block(downstream: XBlock, user: User) -> XBlock: LatestVersion, load_block, ) + + # Try course-level permission first; fall back to library-level check. + if user_has_manage_library_updates(user, downstream.usage_key.context_key): + check_perm = None + else: + check_perm = CheckPerm.CAN_READ_AS_AUTHOR + try: lib_block: XBlock = load_block( LibraryUsageLocatorV2.from_string(downstream.upstream), user, - check_permission=CheckPerm.CAN_READ_AS_AUTHOR, + check_permission=check_perm, version=LatestVersion.PUBLISHED, ) except (NotFound, PermissionDenied) as exc: diff --git a/cms/lib/xblock/upstream_sync_container.py b/cms/lib/xblock/upstream_sync_container.py index d6117509d238..3d50c316a0dd 100644 --- a/cms/lib/xblock/upstream_sync_container.py +++ b/cms/lib/xblock/upstream_sync_container.py @@ -14,7 +14,7 @@ from openedx.core.djangoapps.content_libraries import api as lib_api -from .upstream_sync import UpstreamLink +from .upstream_sync import UpstreamLink, user_has_manage_library_updates if t.TYPE_CHECKING: from django.contrib.auth.models import User # pylint: disable=imported-auth-user @@ -37,15 +37,22 @@ def sync_from_upstream_container( Should children be handled in here? Maybe if sync_from_upstream_block were updated to handle static assets and also save changes to modulestore. + + The library-level permission check is skipped when the user holds + ``courses.manage_library_updates`` for ``downstream``'s course (derived + from ``downstream.usage_key.context_key``). """ link = UpstreamLink.get_for_block(downstream) # can raise UpstreamLinkException if not isinstance(link.upstream_key, LibraryContainerLocator): raise TypeError("sync_from_upstream_container() only supports Container upstreams, not containers") - lib_api.require_permission_for_library_key( # TODO: should permissions be checked at this low level? - link.upstream_key.lib_key, - user, - permission=lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, - ) + + # Try course-level permission first; fall back to library-level check. + if not user_has_manage_library_updates(user, downstream.usage_key.context_key): + lib_api.require_permission_for_library_key( + link.upstream_key.lib_key, + user, + permission=lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) upstream_meta = lib_api.get_container(link.upstream_key) upstream_children = lib_api.get_container_children(link.upstream_key, published=True) _update_customizable_fields(upstream=upstream_meta, downstream=downstream, only_fetch=False) From 23abc136112d1204b7203a09624b419c0256913a Mon Sep 17 00:00:00 2001 From: carlos-marquez-wgu Date: Thu, 3 Sep 2026 10:49:50 -0600 Subject: [PATCH 2/2] refactor: inline authz permission check in upstream_sync modules --- cms/lib/xblock/upstream_sync.py | 26 ---------------------- cms/lib/xblock/upstream_sync_block.py | 27 ++++++++++++++++------- cms/lib/xblock/upstream_sync_container.py | 14 +++++++++--- 3 files changed, 30 insertions(+), 37 deletions(-) diff --git a/cms/lib/xblock/upstream_sync.py b/cms/lib/xblock/upstream_sync.py index ebde499ec0d8..a8d9bbe298cd 100644 --- a/cms/lib/xblock/upstream_sync.py +++ b/cms/lib/xblock/upstream_sync.py @@ -359,32 +359,6 @@ def decline_sync(downstream: XBlock, user_id=None) -> None: store.update_item(downstream, user_id) -def user_has_manage_library_updates(user: User, course_key: CourseKey | None) -> bool: - """ - Return True if *course_key* is provided and *user* holds the - ``courses.manage_library_updates`` permission for that course. - - This is intentionally a thin wrapper so that both - ``upstream_sync_container`` and ``upstream_sync_block`` can share the - same check without duplicating authz imports. - """ - if course_key is None: - return False - - from openedx.core.djangoapps.authz.decorators import ( # pylint: disable=wrong-import-order - LegacyAuthoringPermission, - user_has_course_permission, - ) - from openedx_authz.constants.permissions import COURSES_MANAGE_LIBRARY_UPDATES # pylint: disable=wrong-import-order - - return user_has_course_permission( - user, - COURSES_MANAGE_LIBRARY_UPDATES.identifier, - course_key, - LegacyAuthoringPermission.WRITE, - ) - - def _update_children_top_level_parent( downstream: XBlock, new_top_level_parent_key: str | None, diff --git a/cms/lib/xblock/upstream_sync_block.py b/cms/lib/xblock/upstream_sync_block.py index 243d73880a84..42226949b842 100644 --- a/cms/lib/xblock/upstream_sync_block.py +++ b/cms/lib/xblock/upstream_sync_block.py @@ -12,11 +12,22 @@ from django.core.exceptions import PermissionDenied from django.utils.translation import gettext_lazy as _ from opaque_keys.edx.locator import LibraryUsageLocatorV2 +from openedx_authz.constants.permissions import COURSES_MANAGE_LIBRARY_UPDATES from rest_framework.exceptions import NotFound from xblock.core import XBlock from xblock.fields import Scope -from .upstream_sync import BadDownstream, BadUpstream, UpstreamLink, user_has_manage_library_updates +from openedx.core.djangoapps.authz.decorators import ( + LegacyAuthoringPermission, + user_has_course_permission, +) +from openedx.core.djangoapps.xblock.api import ( + CheckPerm, + LatestVersion, + load_block, +) + +from .upstream_sync import BadDownstream, BadUpstream, UpstreamLink if t.TYPE_CHECKING: from django.contrib.auth.models import User # pylint: disable=imported-auth-user @@ -99,15 +110,15 @@ def _load_upstream_block(downstream: XBlock, user: User) -> XBlock: owns ``downstream``, the library-level permission check is bypassed. Otherwise the default ``CAN_READ_AS_AUTHOR`` check is applied. """ - # We import load_block here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready. - from openedx.core.djangoapps.xblock.api import ( # pylint: disable=wrong-import-order - CheckPerm, - LatestVersion, - load_block, - ) # Try course-level permission first; fall back to library-level check. - if user_has_manage_library_updates(user, downstream.usage_key.context_key): + course_key = downstream.usage_key.context_key + if course_key and user_has_course_permission( + user, + COURSES_MANAGE_LIBRARY_UPDATES.identifier, + course_key, + LegacyAuthoringPermission.WRITE, + ): check_perm = None else: check_perm = CheckPerm.CAN_READ_AS_AUTHOR diff --git a/cms/lib/xblock/upstream_sync_container.py b/cms/lib/xblock/upstream_sync_container.py index 3d50c316a0dd..e79502f6465c 100644 --- a/cms/lib/xblock/upstream_sync_container.py +++ b/cms/lib/xblock/upstream_sync_container.py @@ -10,11 +10,13 @@ from django.utils.translation import gettext_lazy as _ # noqa: F401 from opaque_keys.edx.locator import LibraryContainerLocator +from openedx_authz.constants.permissions import COURSES_MANAGE_LIBRARY_UPDATES from xblock.core import XBlock +from openedx.core.djangoapps.authz.decorators import LegacyAuthoringPermission, user_has_course_permission from openedx.core.djangoapps.content_libraries import api as lib_api -from .upstream_sync import UpstreamLink, user_has_manage_library_updates +from .upstream_sync import UpstreamLink if t.TYPE_CHECKING: from django.contrib.auth.models import User # pylint: disable=imported-auth-user @@ -47,8 +49,14 @@ def sync_from_upstream_container( raise TypeError("sync_from_upstream_container() only supports Container upstreams, not containers") # Try course-level permission first; fall back to library-level check. - if not user_has_manage_library_updates(user, downstream.usage_key.context_key): - lib_api.require_permission_for_library_key( + course_key = downstream.usage_key.context_key + if not (course_key and user_has_course_permission( + user, + COURSES_MANAGE_LIBRARY_UPDATES.identifier, + course_key, + LegacyAuthoringPermission.WRITE, + )): + lib_api.require_permission_for_library_key( # TODO: should permissions be checked at this low level? link.upstream_key.lib_key, user, permission=lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY,