From 9238420b45b0e8a89efeaa2af0ba1779a3c4016e Mon Sep 17 00:00:00 2001 From: jacobo-dominguez-wgu Date: Mon, 7 Sep 2026 11:34:09 -0600 Subject: [PATCH] fix: grant xblock edit access via authz edit_course_content permission --- cms/djangoapps/contentstore/views/block.py | 90 +++--- cms/djangoapps/contentstore/views/preview.py | 24 +- .../contentstore/views/tests/test_block.py | 296 +++++++++++++++--- cms/templates/studio_xblock_wrapper.html | 6 +- 4 files changed, 320 insertions(+), 96 deletions(-) diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index 182f9c6144ee..bc4bb845703b 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -30,7 +30,7 @@ from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_tags_count, usage_key_with_run from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW from common.djangoapps.edxmako.shortcuts import render_to_response, render_to_string -from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access +from common.djangoapps.student.auth import has_studio_read_access from common.djangoapps.student.roles import enable_authz_course_authoring from common.djangoapps.util.json_request import JsonResponse, expect_json from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission @@ -134,43 +134,59 @@ def xblock_handler(request, usage_key_string=None): return handle_xblock(request, usage_key_string) -def _get_authz_permissions_flags(user, course_key): +def _user_can_edit_course_content(user, course_key): """ - Return the RBAC-authoring flags used to gate portions of the XBlock - component card template (the header-actions div and the "Manage Tags" - action). - - When ``authz.enable_course_authoring`` is off for the course all flags - default to values that preserve existing (pre-RBAC) behaviour: - - ``is_authz_authoring_enabled = False`` → template always shows the div - and the "Manage Tags" action. - - ``authz_can_edit_course_content = False`` → unused while flag is off. - - ``authz_can_manage_tags = False`` → unused while flag is off. - - When the flag is on: - - ``authz_can_edit_course_content`` reflects whether the requesting user - holds the ``courses.edit_course_content`` permission. - - ``authz_can_manage_tags`` reflects whether the requesting user holds the - ``courses.manage_tags`` permission. - - Returns: - tuple[bool, bool, bool]: (is_authz_authoring_enabled, - authz_can_edit_course_content, authz_can_manage_tags) + Return whether the user may edit course content, as a single final boolean. + + This delegates entirely to ``user_has_course_permission`` which already + encapsulates the flag logic: when ``authz.enable_course_authoring`` is on + for the course the ``courses.edit_course_content`` AuthZ permission is + checked and legacy access is ignored; when the flag is off it falls back to + the legacy studio WRITE permission. No separate legacy check is OR'd in. """ - if not enable_authz_course_authoring(course_key): - return False, False, False - can_edit = user_has_course_permission( + return user_has_course_permission( user, COURSES_EDIT_COURSE_CONTENT.identifier, course_key, legacy_permission=LegacyAuthoringPermission.WRITE, ) - can_manage_tags = user_has_course_permission( + + +def _user_can_manage_tags(user, course_key): + """ + Return whether the user may manage tags, as a single final boolean. + + Tag management has no legacy-permission concept, so when + ``authz.enable_course_authoring`` is off for the course we preserve the + pre-RBAC behaviour and return ``True``. When the flag is on we check the + ``courses.manage_tags`` AuthZ permission. + """ + if not enable_authz_course_authoring(course_key): + return True + return user_has_course_permission( user, COURSES_MANAGE_TAGS.identifier, course_key, ) - return True, can_edit, can_manage_tags + + +def _user_can_edit_title(user, course_key): + """ + Return whether the user may edit an xblock title, as a single final boolean. + + Editing a title is a content-authoring action, so when + ``authz.enable_course_authoring`` is off for the course we preserve the + pre-RBAC behaviour and return ``True`` (the "Edit Title" affordance was + historically always available). When the flag is on it tracks the + ``courses.edit_course_content`` AuthZ permission. + """ + if not enable_authz_course_authoring(course_key): + return True + return user_has_course_permission( + user, + COURSES_EDIT_COURSE_CONTENT.identifier, + course_key, + ) @require_http_methods("GET") @@ -247,16 +263,13 @@ def xblock_view_handler(request, usage_key_string, view_name): # pylint: disable is_pages_view = ( view_name == STUDENT_VIEW ) # Only the "Pages" view uses student view in Studio - can_edit = has_studio_write_access(request.user, usage_key.course_key) - # Gate the header-actions div on courses.edit_course_content and the - # "Manage Tags" action on courses.manage_tags when the authz flag is - # on. See _get_authz_permissions_flags for details. - ( - is_authz_authoring_enabled, - authz_can_edit_course_content, - authz_can_manage_tags, - ) = _get_authz_permissions_flags(request.user, usage_key.course_key) + # Resolve the final gating booleans server-side. Each helper + # encapsulates its own "authz flag off" default, so the template + # only needs these two already-final values. + can_edit = _user_can_edit_course_content(request.user, usage_key.course_key) + can_manage_tags = _user_can_manage_tags(request.user, usage_key.course_key) + can_edit_title = _user_can_edit_title(request.user, usage_key.course_key) # Determine the items to be shown as reorderable. Note that the view # 'reorderable_container_child_preview' is only rendered for xblocks that @@ -300,9 +313,8 @@ def xblock_view_handler(request, usage_key_string, view_name): # pylint: disable "is_pages_view": is_pages_view or view_name == AUTHOR_VIEW, "is_unit_page": is_unit(xblock), "can_edit": can_edit, - "is_authz_authoring_enabled": is_authz_authoring_enabled, - "authz_can_edit_course_content": authz_can_edit_course_content, - "authz_can_manage_tags": authz_can_manage_tags, + "can_manage_tags": can_manage_tags, + "can_edit_title": can_edit_title, "root_xblock": xblock if (view_name == "container_preview") else None, diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 85b073b2734d..db5f69eb9930 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -318,10 +318,9 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): can_edit = context.get('can_edit', True) can_add = context.get('can_add', True) can_move = context.get('can_move', True) - # Set by block.py; default False so callers that don't set it are unaffected. - is_authz_authoring_enabled = context.get('is_authz_authoring_enabled', False) - authz_can_edit_course_content = context.get('authz_can_edit_course_content', True) - authz_can_manage_tags = context.get('authz_can_manage_tags', True) + # Set by block.py as an already-final boolean. Default True so callers + # that don't set it preserve pre-RBAC behaviour (matching can_edit). + can_manage_tags = context.get('can_manage_tags', True) root_upstream_link = UpstreamLink.try_get_for_block(root_xblock, log_error=False) upstream_link = UpstreamLink.try_get_for_block(xblock, log_error=False) if ( @@ -338,6 +337,13 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): if upstream_link.error_message is None and upstream_link.upstream_ref: can_edit = xblock.category in editable_library_components + # All content-modifying actions require edit access. A user may reach + # the actions menu with only tag-management rights (can_manage_tags), + # so gate the edit-type flags on can_edit to keep Move/Add/Delete and + # (via can_edit_visibility below) Manage Access out of their reach. + can_add = can_add and can_edit + can_move = can_move and can_edit + # Is this a course or a library? is_course = xblock.context_key.is_course tags_count_map = context.get('tags_count_map') @@ -366,10 +372,12 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): 'language': getattr(course, 'language', None), 'is_course': is_course, 'tags_count': tags_count, - 'can_edit_title': True, # This is always true even for imported components - 'is_authz_authoring_enabled': is_authz_authoring_enabled, - 'authz_can_edit_course_content': authz_can_edit_course_content, - 'authz_can_manage_tags': authz_can_manage_tags, + # Set by block.py as an already-final boolean. Defaults True so + # callers that don't set it preserve pre-RBAC behaviour (the "Edit + # Title" affordance was historically always available, including for + # imported components). + 'can_edit_title': context.get('can_edit_title', True), + 'can_manage_tags': can_manage_tags, } add_webpack_js_to_fragment(frag, "js/factories/xblock_validation") diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index 214298c40473..b6d44a2942e4 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -554,26 +554,32 @@ class TestXBlockViewHandlerHeaderActionsAuthz(ItemTest): """ Regression tests for the ``header-actions`` div gating introduced to conditionally render the component card action menu based on the RBAC - ``courses.edit_course_content`` permission. + ``courses.edit_course_content`` and ``courses.manage_tags`` permissions. - The gate uses two independent context flags: - - ``is_authz_authoring_enabled``: True when enable_authz_course_authoring - is on for the course. - - ``authz_can_edit_course_content``: True when the user holds - courses.edit_course_content (only evaluated when the flag is on). + ``block.py`` resolves two already-final booleans server-side: + - ``can_edit``: whether the user may edit course content. Delegates to + ``user_has_course_permission`` which checks AuthZ when the flag is on and + falls back to the legacy studio WRITE permission when the flag is off. + - ``can_manage_tags``: whether the user may manage tags. True when the flag + is off (tags have no legacy-permission concept), otherwise the + ``courses.manage_tags`` AuthZ permission. - The template condition is: - ``not is_authz_authoring_enabled or authz_can_edit_course_content`` + The template opens the div on: + ``can_edit or can_manage_tags or can_edit_title`` + + ``can_edit_title`` is also resolved server-side: True when the flag is off + (preserving the historically-always-available "Edit Title" affordance) and + equal to ``courses.edit_course_content`` when the flag is on. - So the div is shown when the flag is off (preserving existing behaviour) - or when the flag is on and the user has the permission. + So the div is shown when the flag is off (all three default to permissive) + or when the flag is on and the user has edit or tag-management access. """ AUTHZ_FLAG_PATH = ( "cms.djangoapps.contentstore.views.block.enable_authz_course_authoring" ) # Patch user_has_course_permission at the block.py binding so the - # authz_can_edit_course_content value is fully controlled by the test. + # can_edit value is fully controlled by the test. # # NOTE: xblock_view_handler gates the *whole* request on # ``courses.view_course`` via this same binding before the template is ever @@ -586,6 +592,13 @@ class TestXBlockViewHandlerHeaderActionsAuthz(ItemTest): "cms.djangoapps.contentstore.views.block.user_has_course_permission" ) HEADER_ACTIONS_DIV = 'class="header-actions"' + # The component (content) "Edit" button is rendered only when + # ``not show_inline and can_edit`` in studio_xblock_wrapper.html. Match on + # its full class string so this does NOT collide with the separate + # "Edit Title" button (``title-edit-button``), which is rendered on the + # opposite condition (``can_edit_title and not can_edit``) and would + # otherwise match a bare ``edit-button`` substring. + CONTENT_EDIT_BUTTON = 'class="btn-default edit-button action-button"' @staticmethod def _permission_side_effect(*, can_edit_course_content, can_manage_tags=True): @@ -633,11 +646,40 @@ def _get_container_preview_html(self): assert resp.status_code == 200 return json.loads(resp.content.decode("utf-8"))["html"] + def _get_leaf_component_preview_html(self): + """ + Return the rendered HTML for a leaf ``html`` component card. + + The component (content) "Edit" button is gated on + ``not show_inline and can_edit`` in studio_xblock_wrapper.html, where + ``show_inline = xblock.has_children and not xblock_url``. A vertical has + children, so its card is rendered inline and never shows that edit + button regardless of ``can_edit``. We therefore create a leaf ``html`` + component (no children -> ``show_inline`` is False) inside a vertical and + request ``container_child_preview`` for it, which is the branch that the + ``can_edit`` fix actually controls. + """ + parent_usage_key = self._create_vertical() + resp = self.create_xblock( + parent_usage_key=parent_usage_key, category="html" + ) + self.assertEqual(resp.status_code, 200) # noqa: PT009 + child_usage_key = self.response_usage_key(resp) + + preview_url = reverse_usage_url( + "xblock_view_handler", + child_usage_key, + {"view_name": "container_child_preview"}, + ) + resp = self.client.get(preview_url, HTTP_ACCEPT="application/json") + self.assertEqual(resp.status_code, 200) # noqa: PT009 + return json.loads(resp.content.decode("utf-8"))["html"] + def test_header_actions_visible_when_flag_off(self): """ - When enable_authz_course_authoring is off, is_authz_authoring_enabled - is False and the template condition ``not False or *`` is always True, - so the div must be present regardless of any permission value. + When enable_authz_course_authoring is off, both can_edit (legacy WRITE + fallback) and can_manage_tags (defaults True) are permissive, so the + ``can_edit or can_manage_tags`` gate is True and the div must be present. Preserves existing behaviour for courses not yet on the authz rollout. """ with patch(self.AUTHZ_FLAG_PATH, return_value=False): @@ -648,8 +690,8 @@ def test_header_actions_visible_when_flag_off(self): def test_header_actions_visible_when_flag_on_and_user_allowed(self): """ When the flag is on and the user holds courses.edit_course_content, - is_authz_authoring_enabled=True and authz_can_edit_course_content=True, - so the template condition is True and the div must be rendered. + can_edit is True, so the ``can_edit or can_manage_tags`` gate is True and + the div must be rendered. """ with patch(self.AUTHZ_FLAG_PATH, return_value=True), \ patch( @@ -662,44 +704,126 @@ def test_header_actions_visible_when_flag_on_and_user_allowed(self): def test_header_actions_hidden_when_flag_on_and_user_denied(self): """ - When the flag is on and the user does NOT hold courses.edit_course_content, - is_authz_authoring_enabled=True and authz_can_edit_course_content=False, - so the template condition is False and the entire header-actions div - must be absent from the rendered HTML. - This is the core regression test: without the fix the div would always + When the flag is on and the user holds NEITHER courses.edit_course_content + NOR courses.manage_tags, can_edit, can_manage_tags AND can_edit_title are + all False (can_edit_title tracks courses.edit_course_content when the flag + is on), so the ``can_edit or can_manage_tags or can_edit_title`` gate is + False and the entire header-actions div must be absent from the rendered + HTML. + This is a core regression test: without the fix the div would always render even for read-only users when the authz flag is on. """ with patch(self.AUTHZ_FLAG_PATH, return_value=True), \ patch( self.AUTHZ_PERMISSION_PATH, - side_effect=self._permission_side_effect(can_edit_course_content=False), + side_effect=self._permission_side_effect( + can_edit_course_content=False, can_manage_tags=False + ), ): html = self._get_container_preview_html() assert self.HEADER_ACTIONS_DIV not in html + def test_header_actions_visible_when_flag_on_and_only_manage_tags(self): + """ + Regression test for the "Manage Tags" nesting bug. + + The header-actions div previously opened only on the edit permission, so + a role granted courses.manage_tags but NOT courses.edit_course_content + could never reach the "Manage Tags" item nested inside. The div now + opens on ``can_edit or can_manage_tags``, so with only manage_tags + granted the div must still render. + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=True), \ + patch( + self.AUTHZ_PERMISSION_PATH, + side_effect=self._permission_side_effect( + can_edit_course_content=False, can_manage_tags=True + ), + ): + html = self._get_container_preview_html() + + assert self.HEADER_ACTIONS_DIV in html + + def test_can_edit_true_via_authz_without_legacy_write_access(self): + """ + Regression test for the ``can_edit`` fix in xblock_view_handler. + + ``can_edit`` delegates entirely to ``user_has_course_permission`` with a + legacy WRITE fallback. When the authz flag is on that helper checks the + ``courses.edit_course_content`` AuthZ permission and ignores legacy + access entirely. A user granted the AuthZ permission through the + rollout (but without legacy studio write access) must therefore still + get ``can_edit=True`` and see the per-block "Edit" button. + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=True), \ + patch( + self.AUTHZ_PERMISSION_PATH, + side_effect=self._permission_side_effect(can_edit_course_content=True), + ): + html = self._get_leaf_component_preview_html() + + assert self.CONTENT_EDIT_BUTTON in html + + def test_can_edit_false_without_legacy_write_access_or_authz_permission(self): + """ + When the authz flag is on and the user is not granted + ``courses.edit_course_content``, ``user_has_course_permission`` returns + False without consulting legacy access. ``can_edit`` must therefore be + False and the per-block "Edit" button must be absent. + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=True), \ + patch( + self.AUTHZ_PERMISSION_PATH, + side_effect=self._permission_side_effect(can_edit_course_content=False), + ): + html = self._get_leaf_component_preview_html() + + assert self.CONTENT_EDIT_BUTTON not in html + + def test_can_edit_true_via_legacy_write_access_when_flag_off(self): + """ + Legacy fallback path for ``can_edit``. + + When enable_authz_course_authoring is off, ``user_has_course_permission`` + ignores AuthZ and falls back to the legacy studio WRITE permission. The + course-author test user (``CourseTestCase.user``) holds that access, so + ``can_edit`` must be True and the per-block "Edit" button must render. + + This is the complement of ``test_can_edit_true_via_authz_...``: together + they cover both branches of the ``can_edit`` computation (AuthZ-on and + AuthZ-off/legacy). Nothing is patched on ``user_has_course_permission`` + here so the real legacy check runs end to end. + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=False): + html = self._get_leaf_component_preview_html() + + assert self.CONTENT_EDIT_BUTTON in html + class TestXBlockViewHandlerManageTagsAuthz(ItemTest): """ Regression tests for the "Manage Tags" action-menu item gating based on the RBAC ``courses.manage_tags`` permission. - The gate uses two independent context flags: - - ``is_authz_authoring_enabled``: True when enable_authz_course_authoring - is on for the course. - - ``authz_can_manage_tags``: True when the user holds courses.manage_tags - (only evaluated when the flag is on). + ``block.py`` resolves ``can_manage_tags`` server-side as a single final + boolean: True when enable_authz_course_authoring is off (tags have no + legacy-permission concept), otherwise the ``courses.manage_tags`` AuthZ + permission. The template condition is: - ``use_tagging and (not is_authz_authoring_enabled or authz_can_manage_tags)`` + ``use_tagging and can_manage_tags`` So the "Manage Tags" link is shown when tagging is enabled and either the authz flag is off (preserving existing behaviour) or the flag is on and the user holds the permission. - Because the outer ``header-actions`` div is itself gated on - ``courses.edit_course_content``, these tests always grant that permission so - the menu renders and only the "Manage Tags" item is toggled. + The outer ``header-actions`` div opens on ``can_edit or can_manage_tags``. + These tests grant ``courses.edit_course_content`` so the menu renders for + the visible/hidden cases and only the "Manage Tags" item is toggled; the + edit-independent case is covered by + ``TestXBlockViewHandlerHeaderActionsAuthz`` and by + ``test_manage_tags_visible_without_edit_permission`` below. """ AUTHZ_FLAG_PATH = ( @@ -709,20 +833,27 @@ class TestXBlockViewHandlerManageTagsAuthz(ItemTest): "cms.djangoapps.contentstore.views.block.user_has_course_permission" ) MANAGE_TAGS_LINK = 'class="manage-tags-button"' + # Edit-type action-menu links. A tags-only user (can_manage_tags without + # can_edit) reaches the actions menu but must NOT see any of these. + MANAGE_ACCESS_LINK = 'class="access-button"' + MOVE_LINK = 'class="move-button"' + COPY_LINK = 'class="copy-button"' + DUPLICATE_LINK = 'class="duplicate-button"' + DELETE_LINK = 'class="delete-button"' @staticmethod - def _permission_side_effect(*, can_manage_tags): + def _permission_side_effect(*, can_manage_tags, can_edit_course_content=True): """ Build a ``user_has_course_permission`` side effect that always grants - ``courses.view_course`` (200 response) and ``courses.edit_course_content`` - (so the header-actions menu renders), and returns ``can_manage_tags`` for - ``courses.manage_tags``. + ``courses.view_course`` (200 response), returns ``can_edit_course_content`` + for ``courses.edit_course_content`` (defaulting True so the header-actions + menu renders), and returns ``can_manage_tags`` for ``courses.manage_tags``. """ def _side_effect(_user, permission_identifier, *_args, **_kwargs): if permission_identifier == COURSES_VIEW_COURSE.identifier: return True if permission_identifier == COURSES_EDIT_COURSE_CONTENT.identifier: - return True + return can_edit_course_content if permission_identifier == COURSES_MANAGE_TAGS.identifier: return can_manage_tags return False @@ -761,10 +892,11 @@ def _get_container_preview_html(self): def test_manage_tags_visible_when_flag_off(self): """ - When enable_authz_course_authoring is off, is_authz_authoring_enabled is - False and the ``not False or *`` clause is always True, so the "Manage - Tags" link must be present (tagging is enabled in the test environment). - Preserves existing behaviour for courses not yet on the authz rollout. + When enable_authz_course_authoring is off, can_manage_tags defaults to + True, so the ``use_tagging and can_manage_tags`` clause is True and the + "Manage Tags" link must be present (tagging is enabled in the test + environment). Preserves existing behaviour for courses not yet on the + authz rollout. """ with patch(self.AUTHZ_FLAG_PATH, return_value=False): html = self._get_container_preview_html() @@ -774,8 +906,8 @@ def test_manage_tags_visible_when_flag_off(self): def test_manage_tags_visible_when_flag_on_and_user_allowed(self): """ When the flag is on and the user holds courses.manage_tags, - is_authz_authoring_enabled=True and authz_can_manage_tags=True, so the - template condition is True and the "Manage Tags" link must be rendered. + can_manage_tags=True, so the template condition is True and the "Manage + Tags" link must be rendered. """ with patch(self.AUTHZ_FLAG_PATH, return_value=True), \ patch( @@ -789,10 +921,10 @@ def test_manage_tags_visible_when_flag_on_and_user_allowed(self): def test_manage_tags_hidden_when_flag_on_and_user_denied(self): """ When the flag is on and the user does NOT hold courses.manage_tags, - is_authz_authoring_enabled=True and authz_can_manage_tags=False, so the - template condition is False and the "Manage Tags" link must be absent - from the rendered HTML, even though the surrounding header-actions menu - still renders (the user retains courses.edit_course_content). + can_manage_tags=False, so the template condition is False and the + "Manage Tags" link must be absent from the rendered HTML, even though + the surrounding header-actions menu still renders (the user retains + courses.edit_course_content). This is the core regression test: without the fix the link would always render for any user who can see the actions menu when the flag is on. """ @@ -805,6 +937,78 @@ def test_manage_tags_hidden_when_flag_on_and_user_denied(self): assert self.MANAGE_TAGS_LINK not in html + def test_manage_tags_visible_without_edit_permission(self): + """ + Regression test for the "Manage Tags" nesting bug. + + A role granted courses.manage_tags but NOT courses.edit_course_content + must still see the "Manage Tags" link. Previously the item was nested + inside a div gated solely on the edit permission, so such a role could + never reach it. The div now opens on ``can_edit or can_manage_tags`` + and the item itself is gated on ``use_tagging and can_manage_tags``, so + the link must be present even with edit access denied. + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=True), \ + patch( + self.AUTHZ_PERMISSION_PATH, + side_effect=self._permission_side_effect( + can_manage_tags=True, can_edit_course_content=False + ), + ): + html = self._get_container_preview_html() + + assert self.MANAGE_TAGS_LINK in html + + def test_edit_actions_hidden_for_tags_only_user(self): + """ + A user granted courses.manage_tags but NOT courses.edit_course_content + reaches the actions menu (to use "Manage Tags"), but every + content-modifying action must be absent. Opening the menu on + ``can_edit or can_manage_tags`` must not leak edit-type items: Manage + Access, Move, Copy to Clipboard, and Duplicate are all gated on + ``can_edit`` (directly in the template or via the ``can_edit``-derived + ``can_add`` / ``can_move`` / ``can_edit_visibility`` flags). + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=True), \ + patch( + self.AUTHZ_PERMISSION_PATH, + side_effect=self._permission_side_effect( + can_manage_tags=True, can_edit_course_content=False + ), + ): + html = self._get_container_preview_html() + + # The menu is reachable via Manage Tags... + assert self.MANAGE_TAGS_LINK in html + # ...but no edit-type action leaks through. + assert self.MANAGE_ACCESS_LINK not in html + assert self.MOVE_LINK not in html + assert self.COPY_LINK not in html + assert self.DUPLICATE_LINK not in html + assert self.DELETE_LINK not in html + + def test_all_actions_visible_for_full_edit_user(self): + """ + Non-RBAC regression: gating the edit-type flags on ``can_edit`` must not + over-restrict a user who *does* have edit access. + + With enable_authz_course_authoring off, ``can_edit`` resolves via the + legacy WRITE fallback, which the course-author test user holds. Every + edit-type action (Manage Access, Move, Copy, Duplicate, Delete) must + still render alongside Manage Tags, confirming the ``can_add and + can_edit`` / ``can_move and can_edit`` guards left the full-access path + untouched. + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=False): + html = self._get_container_preview_html() + + assert self.MANAGE_TAGS_LINK in html + assert self.MANAGE_ACCESS_LINK in html + assert self.MOVE_LINK in html + assert self.COPY_LINK in html + assert self.DUPLICATE_LINK in html + assert self.DELETE_LINK in html + @ddt.ddt class DeleteItem(ItemTest): diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index 6ce4fb251fa7..b4dd7e571bcb 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -163,7 +163,7 @@ % endif - % if not is_authz_authoring_enabled or authz_can_edit_course_content: + % if can_edit or can_manage_tags or can_edit_title: