Skip to content

Commit

Permalink
fix: Improve v2 library block permissions checks for read-only authors (
Browse files Browse the repository at this point in the history
  • Loading branch information
bradenmacdonald authored Oct 8, 2024
1 parent 15a1295 commit 7316111
Show file tree
Hide file tree
Showing 10 changed files with 148 additions and 69 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@
<!-- The default stylesheet will set the body min-height to 100% (a common strategy to allow for background
images to fill the viewport), but this has the undesireable side-effect of causing an infinite loop via the onResize
event listeners below, in certain situations. Resetting it to the default "auto" skirts the problem.-->
<body style="min-height: auto">
<body style="min-height: auto; background-color: white;">
<!-- fragment body -->
{{ fragment.body_html | safe }}
<!-- fragment foot -->
Expand Down
1 change: 0 additions & 1 deletion lms/templates/xblock_v2/xblock_iframe.html

This file was deleted.

71 changes: 38 additions & 33 deletions openedx/core/djangoapps/content_libraries/library_context.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
"""
Definition of "Library" as a learning context.
"""

import logging

from django.core.exceptions import PermissionDenied
from rest_framework.exceptions import NotFound

from openedx_events.content_authoring.data import LibraryBlockData
from openedx_events.content_authoring.signals import LIBRARY_BLOCK_UPDATED
from opaque_keys.edx.keys import UsageKeyV2
from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryLocatorV2
from openedx_learning.api import authoring as authoring_api

from openedx.core.djangoapps.content_libraries import api, permissions
from openedx.core.djangoapps.content_libraries.models import ContentLibrary
from openedx.core.djangoapps.xblock.api import LearningContext

from openedx_learning.api import authoring as authoring_api
from openedx.core.types import User as UserType

log = logging.getLogger(__name__)

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

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

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

def can_view_block(self, user, usage_key):
def can_view_block(self, user: UserType, usage_key: UsageKeyV2) -> bool:
"""
Does the specified usage key exist in its context, and if so, does the
specified user have permission to view it and interact with it (call
handlers, save user state, etc.)?
user: a Django User object (may be an AnonymousUser)
usage_key: the UsageKeyV2 subclass used for this learning context
Must return a boolean.
May raise ContentLibraryNotFound if the library does not exist.
"""
assert isinstance(usage_key, LibraryUsageLocatorV2)
return self._check_perm(user, usage_key.lib_key, permissions.CAN_LEARN_FROM_THIS_CONTENT_LIBRARY)

def _check_perm(self, user: UserType, lib_key: LibraryLocatorV2, perm) -> bool:
""" Helper method to check a permission for the various can_ methods"""
try:
api.require_permission_for_library_key(
usage_key.lib_key, user, permissions.CAN_LEARN_FROM_THIS_CONTENT_LIBRARY,
)
except (PermissionDenied, api.ContentLibraryNotFound):
api.require_permission_for_library_key(lib_key, user, perm)
return True
except PermissionDenied:
return False
except api.ContentLibraryNotFound as exc:
# A 404 is probably what you want in this case, not a 500 error, so do that by default.
raise NotFound(f"Content Library '{lib_key}' does not exist") from exc

return self.block_exists(usage_key)

def block_exists(self, usage_key):
def block_exists(self, usage_key: LibraryUsageLocatorV2):
"""
Does the block for this usage_key exist in this Library?
Expand All @@ -82,7 +88,7 @@ def block_exists(self, usage_key):
version of it.
"""
try:
content_lib = ContentLibrary.objects.get_by_key(usage_key.context_key)
content_lib = ContentLibrary.objects.get_by_key(usage_key.context_key) # type: ignore[attr-defined]
except ContentLibrary.DoesNotExist:
return False

Expand All @@ -97,12 +103,11 @@ def block_exists(self, usage_key):
local_key=usage_key.block_id,
)

def send_block_updated_event(self, usage_key):
def send_block_updated_event(self, usage_key: UsageKeyV2):
"""
Send a "block updated" event for the library block with the given usage_key.
usage_key: the UsageKeyV2 subclass used for this learning context
"""
assert isinstance(usage_key, LibraryUsageLocatorV2)
LIBRARY_BLOCK_UPDATED.send_event(
library_block=LibraryBlockData(
library_key=usage_key.lib_key,
Expand Down
15 changes: 12 additions & 3 deletions openedx/core/djangoapps/content_libraries/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
Permissions for Content Libraries (v2, Learning-Core-based)
"""
from bridgekeeper import perms, rules
from bridgekeeper.rules import Attribute, ManyRelation, Relation, in_current_groups
from bridgekeeper.rules import Attribute, ManyRelation, Relation, blanket_rule, in_current_groups
from django.conf import settings

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

Expand Down Expand Up @@ -41,6 +42,12 @@
)


# Are we in Studio? (Is there a better or more contextual way to define this, e.g. get from learning context?)
@blanket_rule
def is_studio_request(_):
return settings.SERVICE_VARIANT == "cms"


########################### Permissions ###########################

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

# Is the user allowed to create content libraries?
Expand Down
9 changes: 9 additions & 0 deletions openedx/core/djangoapps/content_libraries/tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,12 @@ def _get_block_handler_url(self, block_key, handler_name):
"""
url = URL_BLOCK_GET_HANDLER_URL.format(block_key=block_key, handler_name=handler_name)
return self._api('get', url, None, expect_response=200)["handler_url"]

def _get_library_block_fields(self, block_key, expect_response=200):
""" Get the fields of a specific block in the library. This API is only used by the MFE editors. """
result = self._api('get', URL_BLOCK_FIELDS_URL.format(block_key=block_key), None, expect_response)
return result

def _set_library_block_fields(self, block_key, new_fields, expect_response=200):
""" Set the fields of a specific block in the library. This API is only used by the MFE editors. """
return self._api('post', URL_BLOCK_FIELDS_URL.format(block_key=block_key), new_fields, expect_response)
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,30 @@ def test_library_blocks_type_constrained(self, slug, library_type, block_type, e
# Add a 'problem' XBlock to the library:
self._add_block_to_library(lib_id, block_type, 'test-block', expect_response=expect_response)

def test_library_not_found(self):
"""Test that requests fail with 404 when the library does not exist"""
valid_not_found_key = 'lb:valid:key:video:1'
response = self.client.get(URL_BLOCK_METADATA_URL.format(block_key=valid_not_found_key))
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json(), {
'detail': "Content Library 'lib:valid:key' does not exist",
})

def test_block_not_found(self):
"""Test that requests fail with 404 when the library exists but the XBlock does not"""
lib = self._create_library(
slug="test_lib_block_event_delete",
title="Event Test Library",
description="Testing event in library"
)
library_key = LibraryLocatorV2.from_string(lib['id'])
non_existent_block_key = LibraryUsageLocatorV2(lib_key=library_key, block_type='video', usage_id='123')
response = self.client.get(URL_BLOCK_METADATA_URL.format(block_key=non_existent_block_key))
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json(), {
'detail': f"The component '{non_existent_block_key}' does not exist.",
})

# Test that permissions are enforced for content libraries

def test_library_permissions(self): # pylint: disable=too-many-statements
Expand Down Expand Up @@ -635,21 +659,27 @@ def test_library_permissions(self): # pylint: disable=too-many-statements
# A random user cannot read OLX nor assets (this library has allow_public_read False):
with self.as_user(random_user):
self._get_library_block_olx(block3_key, expect_response=403)
self._get_library_block_fields(block3_key, expect_response=403)
self._get_library_block_assets(block3_key, expect_response=403)
self._get_library_block_asset(block3_key, file_name="whatever.png", expect_response=403)
# Nor can they preview the block:
self._render_block_view(block3_key, view_name="student_view", expect_response=403)
# But if we grant allow_public_read, then they can:
with self.as_user(admin):
self._update_library(lib_id, allow_public_read=True)
# self._set_library_block_asset(block3_key, "whatever.png", b"data")
with self.as_user(random_user):
self._get_library_block_olx(block3_key)
self._render_block_view(block3_key, view_name="student_view")
f = self._get_library_block_fields(block3_key)
# self._get_library_block_assets(block3_key)
# self._get_library_block_asset(block3_key, file_name="whatever.png")

# Users without authoring permission cannot edit nor delete XBlocks (this library has allow_public_read False):
# Users without authoring permission cannot edit nor delete XBlocks:
for user in [reader, random_user]:
with self.as_user(user):
self._set_library_block_olx(block3_key, "<problem/>", expect_response=403)
self._set_library_block_fields(block3_key, {"data": "<problem />", "metadata": {}}, expect_response=403)
# self._set_library_block_asset(block3_key, "test.txt", b"data", expect_response=403)
self._delete_library_block(block3_key, expect_response=403)
self._commit_library_changes(lib_id, expect_response=403)
Expand All @@ -659,6 +689,7 @@ def test_library_permissions(self): # pylint: disable=too-many-statements
with self.as_user(author_group_member):
olx = self._get_library_block_olx(block3_key)
self._set_library_block_olx(block3_key, olx)
self._set_library_block_fields(block3_key, {"data": olx, "metadata": {}})
# self._get_library_block_assets(block3_key)
# self._set_library_block_asset(block3_key, "test.txt", b"data")
# self._get_library_block_asset(block3_key, file_name="test.txt")
Expand Down Expand Up @@ -1087,12 +1118,3 @@ def test_xblock_handler_invalid_key(self):
secure_token='random',
)))
self.assertEqual(response.status_code, 404)

def test_not_found_fails_correctly(self):
"""Test fails with 404 when xblock key is valid but not found."""
valid_not_found_key = 'lb:valid:key:video:1'
response = self.client.get(URL_BLOCK_METADATA_URL.format(block_key=valid_not_found_key))
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json(), {
'detail': f"XBlock {valid_not_found_key} does not exist, or you don't have permission to view it.",
})
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/content_libraries/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -919,7 +919,7 @@ class LtiToolLaunchView(TemplateResponseMixin, LtiToolView):
LTI platform. Other features and resouces are ignored.
"""

template_name = 'content_libraries/xblock_iframe.html'
template_name = 'xblock_v2/xblock_iframe.html'

@property
def launch_data(self):
Expand Down
42 changes: 32 additions & 10 deletions openedx/core/djangoapps/xblock/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
Studio APIs cover use cases like adding/deleting/editing blocks.
"""
# pylint: disable=unused-import

from enum import Enum
from datetime import datetime
import logging
import threading

from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.utils.translation import gettext as _
from openedx_learning.api import authoring as authoring_api
Expand All @@ -21,7 +22,7 @@
from opaque_keys.edx.locator import BundleDefinitionLocator, LibraryUsageLocatorV2
from rest_framework.exceptions import NotFound
from xblock.core import XBlock
from xblock.exceptions import NoSuchViewError
from xblock.exceptions import NoSuchUsage, NoSuchViewError
from xblock.plugin import PluginMissingError

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


class CheckPerm(Enum):
""" Options for the default permission check done by load_block() """
# can view the published block and call handlers etc. but not necessarily view its OLX source nor field data
CAN_LEARN = 1
# read-only studio view: can see the block (draft or published), see its OLX, see its field data, etc.
CAN_READ_AS_AUTHOR = 2
# can view everything and make changes to the block
CAN_EDIT = 3


def get_runtime_system():
"""
Return a new XBlockRuntimeSystem.
Expand Down Expand Up @@ -74,15 +85,15 @@ def get_runtime_system():
return runtime


def load_block(usage_key, user):
def load_block(usage_key, user, *, check_permission: CheckPerm | None = CheckPerm.CAN_LEARN):
"""
Load the specified XBlock for the given user.
Returns an instantiated XBlock.
Exceptions:
NotFound - if the XBlock doesn't exist or if the user doesn't have the
necessary permissions
NotFound - if the XBlock doesn't exist
PermissionDenied - if the user doesn't have the necessary permissions
Args:
usage_key(OpaqueKey): block identifier
Expand All @@ -94,18 +105,29 @@ def load_block(usage_key, user):

# Now, check if the block exists in this context and if the user has
# permission to render this XBlock view:
if user is not None and not context_impl.can_view_block(user, usage_key):
# We do not know if the block was not found or if the user doesn't have
# permission, but we want to return the same result in either case:
raise NotFound(f"XBlock {usage_key} does not exist, or you don't have permission to view it.")
if check_permission and user is not None:
if check_permission == CheckPerm.CAN_EDIT:
has_perm = context_impl.can_edit_block(user, usage_key)
elif check_permission == CheckPerm.CAN_READ_AS_AUTHOR:
has_perm = context_impl.can_view_block_for_editing(user, usage_key)
elif check_permission == CheckPerm.CAN_LEARN:
has_perm = context_impl.can_view_block(user, usage_key)
else:
has_perm = False
if not has_perm:
raise PermissionDenied(f"You don't have permission to access the component '{usage_key}'.")

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

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


def get_block_metadata(block, includes=()):
Expand Down
Loading

0 comments on commit 7316111

Please sign in to comment.