Skip to content

Commit 7316111

Browse files
fix: Improve v2 library block permissions checks for read-only authors (#35598)
1 parent 15a1295 commit 7316111

File tree

10 files changed

+148
-69
lines changed

10 files changed

+148
-69
lines changed

cms/templates/content_libraries/xblock_iframe.html renamed to common/templates/xblock_v2/xblock_iframe.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@
156156
<!-- The default stylesheet will set the body min-height to 100% (a common strategy to allow for background
157157
images to fill the viewport), but this has the undesireable side-effect of causing an infinite loop via the onResize
158158
event listeners below, in certain situations. Resetting it to the default "auto" skirts the problem.-->
159-
<body style="min-height: auto">
159+
<body style="min-height: auto; background-color: white;">
160160
<!-- fragment body -->
161161
{{ fragment.body_html | safe }}
162162
<!-- fragment foot -->

lms/templates/xblock_v2/xblock_iframe.html

Lines changed: 0 additions & 1 deletion
This file was deleted.

openedx/core/djangoapps/content_libraries/library_context.py

Lines changed: 38 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
"""
22
Definition of "Library" as a learning context.
33
"""
4-
54
import logging
65

76
from django.core.exceptions import PermissionDenied
7+
from rest_framework.exceptions import NotFound
88

99
from openedx_events.content_authoring.data import LibraryBlockData
1010
from openedx_events.content_authoring.signals import LIBRARY_BLOCK_UPDATED
11+
from opaque_keys.edx.keys import UsageKeyV2
12+
from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryLocatorV2
13+
from openedx_learning.api import authoring as authoring_api
1114

1215
from openedx.core.djangoapps.content_libraries import api, permissions
1316
from openedx.core.djangoapps.content_libraries.models import ContentLibrary
1417
from openedx.core.djangoapps.xblock.api import LearningContext
15-
16-
from openedx_learning.api import authoring as authoring_api
18+
from openedx.core.types import User as UserType
1719

1820
log = logging.getLogger(__name__)
1921

@@ -30,47 +32,51 @@ def __init__(self, **kwargs):
3032
super().__init__(**kwargs)
3133
self.use_draft = kwargs.get('use_draft', None)
3234

33-
def can_edit_block(self, user, usage_key):
35+
def can_edit_block(self, user: UserType, usage_key: UsageKeyV2) -> bool:
3436
"""
35-
Does the specified usage key exist in its context, and if so, does the
36-
specified user have permission to edit it (make changes to the authored
37-
data store)?
38-
39-
user: a Django User object (may be an AnonymousUser)
37+
Assuming a block with the specified ID (usage_key) exists, does the
38+
specified user have permission to edit it (make changes to the
39+
fields / authored data store)?
4040
41-
usage_key: the UsageKeyV2 subclass used for this learning context
41+
May raise ContentLibraryNotFound if the library does not exist.
42+
"""
43+
assert isinstance(usage_key, LibraryUsageLocatorV2)
44+
return self._check_perm(user, usage_key.lib_key, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
4245

43-
Must return a boolean.
46+
def can_view_block_for_editing(self, user: UserType, usage_key: UsageKeyV2) -> bool:
4447
"""
45-
try:
46-
api.require_permission_for_library_key(usage_key.lib_key, user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
47-
except (PermissionDenied, api.ContentLibraryNotFound):
48-
return False
48+
Assuming a block with the specified ID (usage_key) exists, does the
49+
specified user have permission to view its fields and OLX details (but
50+
not necessarily to make changes to it)?
4951
50-
return self.block_exists(usage_key)
52+
May raise ContentLibraryNotFound if the library does not exist.
53+
"""
54+
assert isinstance(usage_key, LibraryUsageLocatorV2)
55+
return self._check_perm(user, usage_key.lib_key, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
5156

52-
def can_view_block(self, user, usage_key):
57+
def can_view_block(self, user: UserType, usage_key: UsageKeyV2) -> bool:
5358
"""
5459
Does the specified usage key exist in its context, and if so, does the
5560
specified user have permission to view it and interact with it (call
5661
handlers, save user state, etc.)?
5762
58-
user: a Django User object (may be an AnonymousUser)
59-
60-
usage_key: the UsageKeyV2 subclass used for this learning context
61-
62-
Must return a boolean.
63+
May raise ContentLibraryNotFound if the library does not exist.
6364
"""
65+
assert isinstance(usage_key, LibraryUsageLocatorV2)
66+
return self._check_perm(user, usage_key.lib_key, permissions.CAN_LEARN_FROM_THIS_CONTENT_LIBRARY)
67+
68+
def _check_perm(self, user: UserType, lib_key: LibraryLocatorV2, perm) -> bool:
69+
""" Helper method to check a permission for the various can_ methods"""
6470
try:
65-
api.require_permission_for_library_key(
66-
usage_key.lib_key, user, permissions.CAN_LEARN_FROM_THIS_CONTENT_LIBRARY,
67-
)
68-
except (PermissionDenied, api.ContentLibraryNotFound):
71+
api.require_permission_for_library_key(lib_key, user, perm)
72+
return True
73+
except PermissionDenied:
6974
return False
75+
except api.ContentLibraryNotFound as exc:
76+
# A 404 is probably what you want in this case, not a 500 error, so do that by default.
77+
raise NotFound(f"Content Library '{lib_key}' does not exist") from exc
7078

71-
return self.block_exists(usage_key)
72-
73-
def block_exists(self, usage_key):
79+
def block_exists(self, usage_key: LibraryUsageLocatorV2):
7480
"""
7581
Does the block for this usage_key exist in this Library?
7682
@@ -82,7 +88,7 @@ def block_exists(self, usage_key):
8288
version of it.
8389
"""
8490
try:
85-
content_lib = ContentLibrary.objects.get_by_key(usage_key.context_key)
91+
content_lib = ContentLibrary.objects.get_by_key(usage_key.context_key) # type: ignore[attr-defined]
8692
except ContentLibrary.DoesNotExist:
8793
return False
8894

@@ -97,12 +103,11 @@ def block_exists(self, usage_key):
97103
local_key=usage_key.block_id,
98104
)
99105

100-
def send_block_updated_event(self, usage_key):
106+
def send_block_updated_event(self, usage_key: UsageKeyV2):
101107
"""
102108
Send a "block updated" event for the library block with the given usage_key.
103-
104-
usage_key: the UsageKeyV2 subclass used for this learning context
105109
"""
110+
assert isinstance(usage_key, LibraryUsageLocatorV2)
106111
LIBRARY_BLOCK_UPDATED.send_event(
107112
library_block=LibraryBlockData(
108113
library_key=usage_key.lib_key,

openedx/core/djangoapps/content_libraries/permissions.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
Permissions for Content Libraries (v2, Learning-Core-based)
33
"""
44
from bridgekeeper import perms, rules
5-
from bridgekeeper.rules import Attribute, ManyRelation, Relation, in_current_groups
5+
from bridgekeeper.rules import Attribute, ManyRelation, Relation, blanket_rule, in_current_groups
6+
from django.conf import settings
67

78
from openedx.core.djangoapps.content_libraries.models import ContentLibraryPermission
89

@@ -41,6 +42,12 @@
4142
)
4243

4344

45+
# Are we in Studio? (Is there a better or more contextual way to define this, e.g. get from learning context?)
46+
@blanket_rule
47+
def is_studio_request(_):
48+
return settings.SERVICE_VARIANT == "cms"
49+
50+
4451
########################### Permissions ###########################
4552

4653
# Is the user allowed to view XBlocks from the specified content library
@@ -51,10 +58,12 @@
5158
perms[CAN_LEARN_FROM_THIS_CONTENT_LIBRARY] = (
5259
# Global staff can learn from any library:
5360
is_global_staff |
54-
# Regular users can learn if the library allows public learning:
61+
# Regular and even anonymous users can learn if the library allows public learning:
5562
Attribute('allow_public_learning', True) |
5663
# Users/groups who are explicitly granted permission can learn from the library:
57-
(is_user_active & has_explicit_read_permission_for_library)
64+
(is_user_active & has_explicit_read_permission_for_library) |
65+
# Or, in Studio (but not the LMS) any users can access libraries with "public read" permissions:
66+
(is_studio_request & is_user_active & Attribute('allow_public_read', True))
5867
)
5968

6069
# Is the user allowed to create content libraries?

openedx/core/djangoapps/content_libraries/tests/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,12 @@ def _get_block_handler_url(self, block_key, handler_name):
308308
"""
309309
url = URL_BLOCK_GET_HANDLER_URL.format(block_key=block_key, handler_name=handler_name)
310310
return self._api('get', url, None, expect_response=200)["handler_url"]
311+
312+
def _get_library_block_fields(self, block_key, expect_response=200):
313+
""" Get the fields of a specific block in the library. This API is only used by the MFE editors. """
314+
result = self._api('get', URL_BLOCK_FIELDS_URL.format(block_key=block_key), None, expect_response)
315+
return result
316+
317+
def _set_library_block_fields(self, block_key, new_fields, expect_response=200):
318+
""" Set the fields of a specific block in the library. This API is only used by the MFE editors. """
319+
return self._api('post', URL_BLOCK_FIELDS_URL.format(block_key=block_key), new_fields, expect_response)

openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,30 @@ def test_library_blocks_type_constrained(self, slug, library_type, block_type, e
502502
# Add a 'problem' XBlock to the library:
503503
self._add_block_to_library(lib_id, block_type, 'test-block', expect_response=expect_response)
504504

505+
def test_library_not_found(self):
506+
"""Test that requests fail with 404 when the library does not exist"""
507+
valid_not_found_key = 'lb:valid:key:video:1'
508+
response = self.client.get(URL_BLOCK_METADATA_URL.format(block_key=valid_not_found_key))
509+
self.assertEqual(response.status_code, 404)
510+
self.assertEqual(response.json(), {
511+
'detail': "Content Library 'lib:valid:key' does not exist",
512+
})
513+
514+
def test_block_not_found(self):
515+
"""Test that requests fail with 404 when the library exists but the XBlock does not"""
516+
lib = self._create_library(
517+
slug="test_lib_block_event_delete",
518+
title="Event Test Library",
519+
description="Testing event in library"
520+
)
521+
library_key = LibraryLocatorV2.from_string(lib['id'])
522+
non_existent_block_key = LibraryUsageLocatorV2(lib_key=library_key, block_type='video', usage_id='123')
523+
response = self.client.get(URL_BLOCK_METADATA_URL.format(block_key=non_existent_block_key))
524+
self.assertEqual(response.status_code, 404)
525+
self.assertEqual(response.json(), {
526+
'detail': f"The component '{non_existent_block_key}' does not exist.",
527+
})
528+
505529
# Test that permissions are enforced for content libraries
506530

507531
def test_library_permissions(self): # pylint: disable=too-many-statements
@@ -635,21 +659,27 @@ def test_library_permissions(self): # pylint: disable=too-many-statements
635659
# A random user cannot read OLX nor assets (this library has allow_public_read False):
636660
with self.as_user(random_user):
637661
self._get_library_block_olx(block3_key, expect_response=403)
662+
self._get_library_block_fields(block3_key, expect_response=403)
638663
self._get_library_block_assets(block3_key, expect_response=403)
639664
self._get_library_block_asset(block3_key, file_name="whatever.png", expect_response=403)
665+
# Nor can they preview the block:
666+
self._render_block_view(block3_key, view_name="student_view", expect_response=403)
640667
# But if we grant allow_public_read, then they can:
641668
with self.as_user(admin):
642669
self._update_library(lib_id, allow_public_read=True)
643670
# self._set_library_block_asset(block3_key, "whatever.png", b"data")
644671
with self.as_user(random_user):
645672
self._get_library_block_olx(block3_key)
673+
self._render_block_view(block3_key, view_name="student_view")
674+
f = self._get_library_block_fields(block3_key)
646675
# self._get_library_block_assets(block3_key)
647676
# self._get_library_block_asset(block3_key, file_name="whatever.png")
648677

649-
# Users without authoring permission cannot edit nor delete XBlocks (this library has allow_public_read False):
678+
# Users without authoring permission cannot edit nor delete XBlocks:
650679
for user in [reader, random_user]:
651680
with self.as_user(user):
652681
self._set_library_block_olx(block3_key, "<problem/>", expect_response=403)
682+
self._set_library_block_fields(block3_key, {"data": "<problem />", "metadata": {}}, expect_response=403)
653683
# self._set_library_block_asset(block3_key, "test.txt", b"data", expect_response=403)
654684
self._delete_library_block(block3_key, expect_response=403)
655685
self._commit_library_changes(lib_id, expect_response=403)
@@ -659,6 +689,7 @@ def test_library_permissions(self): # pylint: disable=too-many-statements
659689
with self.as_user(author_group_member):
660690
olx = self._get_library_block_olx(block3_key)
661691
self._set_library_block_olx(block3_key, olx)
692+
self._set_library_block_fields(block3_key, {"data": olx, "metadata": {}})
662693
# self._get_library_block_assets(block3_key)
663694
# self._set_library_block_asset(block3_key, "test.txt", b"data")
664695
# self._get_library_block_asset(block3_key, file_name="test.txt")
@@ -1087,12 +1118,3 @@ def test_xblock_handler_invalid_key(self):
10871118
secure_token='random',
10881119
)))
10891120
self.assertEqual(response.status_code, 404)
1090-
1091-
def test_not_found_fails_correctly(self):
1092-
"""Test fails with 404 when xblock key is valid but not found."""
1093-
valid_not_found_key = 'lb:valid:key:video:1'
1094-
response = self.client.get(URL_BLOCK_METADATA_URL.format(block_key=valid_not_found_key))
1095-
self.assertEqual(response.status_code, 404)
1096-
self.assertEqual(response.json(), {
1097-
'detail': f"XBlock {valid_not_found_key} does not exist, or you don't have permission to view it.",
1098-
})

openedx/core/djangoapps/content_libraries/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -919,7 +919,7 @@ class LtiToolLaunchView(TemplateResponseMixin, LtiToolView):
919919
LTI platform. Other features and resouces are ignored.
920920
"""
921921

922-
template_name = 'content_libraries/xblock_iframe.html'
922+
template_name = 'xblock_v2/xblock_iframe.html'
923923

924924
@property
925925
def launch_data(self):

openedx/core/djangoapps/xblock/api.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
Studio APIs cover use cases like adding/deleting/editing blocks.
99
"""
1010
# pylint: disable=unused-import
11-
11+
from enum import Enum
1212
from datetime import datetime
1313
import logging
1414
import threading
1515

16+
from django.core.exceptions import PermissionDenied
1617
from django.urls import reverse
1718
from django.utils.translation import gettext as _
1819
from openedx_learning.api import authoring as authoring_api
@@ -21,7 +22,7 @@
2122
from opaque_keys.edx.locator import BundleDefinitionLocator, LibraryUsageLocatorV2
2223
from rest_framework.exceptions import NotFound
2324
from xblock.core import XBlock
24-
from xblock.exceptions import NoSuchViewError
25+
from xblock.exceptions import NoSuchUsage, NoSuchViewError
2526
from xblock.plugin import PluginMissingError
2627

2728
from openedx.core.djangoapps.xblock.apps import get_xblock_app_config
@@ -43,6 +44,16 @@
4344
log = logging.getLogger(__name__)
4445

4546

47+
class CheckPerm(Enum):
48+
""" Options for the default permission check done by load_block() """
49+
# can view the published block and call handlers etc. but not necessarily view its OLX source nor field data
50+
CAN_LEARN = 1
51+
# read-only studio view: can see the block (draft or published), see its OLX, see its field data, etc.
52+
CAN_READ_AS_AUTHOR = 2
53+
# can view everything and make changes to the block
54+
CAN_EDIT = 3
55+
56+
4657
def get_runtime_system():
4758
"""
4859
Return a new XBlockRuntimeSystem.
@@ -74,15 +85,15 @@ def get_runtime_system():
7485
return runtime
7586

7687

77-
def load_block(usage_key, user):
88+
def load_block(usage_key, user, *, check_permission: CheckPerm | None = CheckPerm.CAN_LEARN):
7889
"""
7990
Load the specified XBlock for the given user.
8091
8192
Returns an instantiated XBlock.
8293
8394
Exceptions:
84-
NotFound - if the XBlock doesn't exist or if the user doesn't have the
85-
necessary permissions
95+
NotFound - if the XBlock doesn't exist
96+
PermissionDenied - if the user doesn't have the necessary permissions
8697
8798
Args:
8899
usage_key(OpaqueKey): block identifier
@@ -94,18 +105,29 @@ def load_block(usage_key, user):
94105

95106
# Now, check if the block exists in this context and if the user has
96107
# permission to render this XBlock view:
97-
if user is not None and not context_impl.can_view_block(user, usage_key):
98-
# We do not know if the block was not found or if the user doesn't have
99-
# permission, but we want to return the same result in either case:
100-
raise NotFound(f"XBlock {usage_key} does not exist, or you don't have permission to view it.")
108+
if check_permission and user is not None:
109+
if check_permission == CheckPerm.CAN_EDIT:
110+
has_perm = context_impl.can_edit_block(user, usage_key)
111+
elif check_permission == CheckPerm.CAN_READ_AS_AUTHOR:
112+
has_perm = context_impl.can_view_block_for_editing(user, usage_key)
113+
elif check_permission == CheckPerm.CAN_LEARN:
114+
has_perm = context_impl.can_view_block(user, usage_key)
115+
else:
116+
has_perm = False
117+
if not has_perm:
118+
raise PermissionDenied(f"You don't have permission to access the component '{usage_key}'.")
101119

102120
# TODO: load field overrides from the context
103121
# e.g. a course might specify that all 'problem' XBlocks have 'max_attempts'
104122
# set to 3.
105123
# field_overrides = context_impl.get_field_overrides(usage_key)
106124
runtime = get_runtime_system().get_runtime(user=user)
107125

108-
return runtime.get_block(usage_key)
126+
try:
127+
return runtime.get_block(usage_key)
128+
except NoSuchUsage as exc:
129+
# Convert NoSuchUsage to NotFound so we do the right thing (404 not 500) by default.
130+
raise NotFound(f"The component '{usage_key}' does not exist.") from exc
109131

110132

111133
def get_block_metadata(block, includes=()):

0 commit comments

Comments
 (0)