diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index b1f1ab8f5056..182f9c6144ee 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -12,7 +12,11 @@ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.http import require_http_methods from opaque_keys.edx.keys import CourseKey -from openedx_authz.constants.permissions import COURSES_VIEW_COURSE +from openedx_authz.constants.permissions import ( + COURSES_EDIT_COURSE_CONTENT, + COURSES_MANAGE_TAGS, + COURSES_VIEW_COURSE, +) from web_fragments.fragment import Fragment from cms.djangoapps.contentstore.utils import load_services_for_studio @@ -27,6 +31,7 @@ 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.roles import enable_authz_course_authoring from common.djangoapps.util.json_request import JsonResponse, expect_json from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission from openedx.core.djangoapps.authz.decorators import user_has_course_permission @@ -129,10 +134,49 @@ def xblock_handler(request, usage_key_string=None): return handle_xblock(request, usage_key_string) +def _get_authz_permissions_flags(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) + """ + if not enable_authz_course_authoring(course_key): + return False, False, False + can_edit = user_has_course_permission( + user, + COURSES_EDIT_COURSE_CONTENT.identifier, + course_key, + legacy_permission=LegacyAuthoringPermission.WRITE, + ) + can_manage_tags = user_has_course_permission( + user, + COURSES_MANAGE_TAGS.identifier, + course_key, + ) + return True, can_edit, can_manage_tags + + @require_http_methods("GET") @login_required @expect_json -def xblock_view_handler(request, usage_key_string, view_name): +def xblock_view_handler(request, usage_key_string, view_name): # pylint: disable=too-many-statements """ The restful handler for requests for rendered xblock views. @@ -205,6 +249,15 @@ def xblock_view_handler(request, usage_key_string, view_name): ) # 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) + # Determine the items to be shown as reorderable. Note that the view # 'reorderable_container_child_preview' is only rendered for xblocks that # are being shown in a reorderable container, so the xblock is automatically @@ -247,6 +300,9 @@ def xblock_view_handler(request, usage_key_string, view_name): "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, "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 408c5649c8d5..85b073b2734d 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -318,6 +318,10 @@ 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) 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 ( @@ -363,6 +367,9 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): '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, } 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 dd087fc1418f..21b18df26410 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -19,6 +19,11 @@ from opaque_keys.edx.asides import AsideUsageKeyV2 from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator +from openedx_authz.constants.permissions import ( + COURSES_EDIT_COURSE_CONTENT, + COURSES_MANAGE_TAGS, + COURSES_VIEW_COURSE, +) from openedx_authz.constants.roles import COURSE_ADMIN, COURSE_AUDITOR, COURSE_EDITOR, COURSE_STAFF from openedx_events.content_authoring.data import DuplicatedXBlockData from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED @@ -545,6 +550,262 @@ def assert_xblock_info(xblock, xblock_info): self.assertEqual(xblock_info, response) # noqa: PT009 +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. + + 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). + + The template condition is: + ``not is_authz_authoring_enabled or authz_can_edit_course_content`` + + 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. + """ + + 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. + # + # NOTE: xblock_view_handler gates the *whole* request on + # ``courses.view_course`` via this same binding before the template is ever + # rendered. A blanket return_value=False would make that view-access gate + # fail and the handler would raise PermissionDenied (403) before reaching + # the header-actions logic. We therefore drive the mock with a + # permission-aware side effect that always grants view access and only + # toggles the ``courses.edit_course_content`` permission. + AUTHZ_PERMISSION_PATH = ( + "cms.djangoapps.contentstore.views.block.user_has_course_permission" + ) + HEADER_ACTIONS_DIV = 'class="header-actions"' + + @staticmethod + def _permission_side_effect(*, can_edit_course_content, can_manage_tags=True): + """ + Build a ``user_has_course_permission`` side effect that always grants + ``courses.view_course`` (so the handler returns 200), returns + ``can_edit_course_content`` for ``courses.edit_course_content`` and + ``can_manage_tags`` for ``courses.manage_tags``. + + The permission identifier is passed as the second positional argument + by every call site in ``block.py``. + """ + 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 can_edit_course_content + if permission_identifier == COURSES_MANAGE_TAGS.identifier: + return can_manage_tags + return False + + return _side_effect + + def _get_container_preview_html(self): + """ + Return the rendered HTML for a child vertical card inside a parent vertical. + + ``header-actions`` only appears on non-root blocks (``is_root=False``). + We replicate the setup used by ``test_draft_container_preview_html`` in + ``test_container_page.py``: create a parent vertical, add a child + vertical inside it, then request ``reorderable_container_child_preview`` + for that child. A vertical renders cleanly in the test environment + without needing any external services, and its card includes the full + ``header-actions`` section. + """ + parent_usage_key = self._create_vertical() + child_usage_key = self._create_vertical(parent_usage_key=parent_usage_key) + + preview_url = reverse_usage_url( + "xblock_view_handler", + child_usage_key, + {"view_name": "reorderable_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. + 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() + + self.assertIn(self.HEADER_ACTIONS_DIV, html) # noqa: PT009 + + 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. + """ + 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_container_preview_html() + + self.assertIn(self.HEADER_ACTIONS_DIV, html) # noqa: PT009 + + 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 + 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), + ): + html = self._get_container_preview_html() + + self.assertNotIn(self.HEADER_ACTIONS_DIV, html) # noqa: PT009 + + +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). + + The template condition is: + ``use_tagging and (not is_authz_authoring_enabled or authz_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. + """ + + AUTHZ_FLAG_PATH = ( + "cms.djangoapps.contentstore.views.block.enable_authz_course_authoring" + ) + AUTHZ_PERMISSION_PATH = ( + "cms.djangoapps.contentstore.views.block.user_has_course_permission" + ) + MANAGE_TAGS_LINK = 'class="manage-tags-button"' + + @staticmethod + def _permission_side_effect(*, can_manage_tags): + """ + 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``. + """ + 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 + if permission_identifier == COURSES_MANAGE_TAGS.identifier: + return can_manage_tags + return False + + return _side_effect + + def _get_container_preview_html(self): + """ + Return the rendered HTML for a leaf ``html`` component card inside a + parent vertical. + + The "Manage Tags" action lives inside the ``% if not show_inline:`` + block of the card template, where ``show_inline = xblock.has_children + and not xblock_url``. A vertical has children and no studio URL, so it + is rendered inline and its "Manage Tags" item is never emitted. A leaf + ``html`` component has no children and does have a studio URL, so + ``show_inline`` is False and the action menu (including "Manage Tags") + is rendered. Requesting ``reorderable_container_child_preview`` for the + component yields a non-root card that includes the full action menu. + """ + 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": "reorderable_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_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. + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=False): + html = self._get_container_preview_html() + + self.assertIn(self.MANAGE_TAGS_LINK, html) # noqa: PT009 + + 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. + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=True), \ + patch( + self.AUTHZ_PERMISSION_PATH, + side_effect=self._permission_side_effect(can_manage_tags=True), + ): + html = self._get_container_preview_html() + + self.assertIn(self.MANAGE_TAGS_LINK, html) # noqa: PT009 + + 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). + 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. + """ + with patch(self.AUTHZ_FLAG_PATH, return_value=True), \ + patch( + self.AUTHZ_PERMISSION_PATH, + side_effect=self._permission_side_effect(can_manage_tags=False), + ): + html = self._get_container_preview_html() + + self.assertNotIn(self.MANAGE_TAGS_LINK, html) # noqa: PT009 + + @ddt.ddt class DeleteItem(ItemTest): """Tests for '/xblock' DELETE url.""" diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index e05bee6017bc..6ce4fb251fa7 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -163,6 +163,7 @@ % endif + % if not is_authz_authoring_enabled or authz_can_edit_course_content: