Enforce permissions for content libraries, add REST API to edit perms

This commit is contained in:
Braden MacDonald
2019-11-05 14:04:50 -08:00
parent 2fa432beda
commit af6cab86c3
16 changed files with 697 additions and 88 deletions

View File

@@ -10,7 +10,7 @@ class ContentLibraryPermissionInline(admin.TabularInline):
Inline form for a content library's permissions
"""
model = ContentLibraryPermission
raw_id_fields = ("user", )
raw_id_fields = ("user", "group", )
extra = 0

View File

@@ -4,11 +4,12 @@ Python API for content libraries.
Unless otherwise specified, all APIs in this file deal with the DRAFT version
of the content library.
"""
from uuid import UUID
import logging
import attr
from django.contrib.auth.models import AbstractUser, Group
from django.core.exceptions import PermissionDenied
from django.core.validators import validate_unicode_slug
from django.db import IntegrityError
from lxml import etree
@@ -18,7 +19,9 @@ import six
from xblock.core import XBlock
from xblock.exceptions import XBlockNotFoundError
from openedx.core.djangoapps.content_libraries import permissions
from openedx.core.djangoapps.content_libraries.library_bundle import LibraryBundle
from openedx.core.djangoapps.content_libraries.models import ContentLibrary, ContentLibraryPermission
from openedx.core.djangoapps.xblock.api import get_block_display_name, load_block
from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl
from openedx.core.djangoapps.xblock.runtime.olx_parsing import XBlockInclude
@@ -36,7 +39,6 @@ from openedx.core.lib.blockstore_api import (
)
from openedx.core.djangolib import blockstore_cache
from openedx.core.djangolib.blockstore_cache import BundleCache
from .models import ContentLibrary, ContentLibraryPermission
log = logging.getLogger(__name__)
@@ -80,6 +82,30 @@ class ContentLibraryMetadata(object):
# has_unpublished_deletes will be true when the draft version of the library's bundle
# contains deletes of any XBlocks that were in the most recently published version
has_unpublished_deletes = attr.ib(False)
# Allow any user (even unregistered users) to view and interact directly
# with this library's content in the LMS
allow_public_learning = attr.ib(False)
# Allow any user with Studio access to view this library's content in
# Studio, use it in their courses, and copy content out of this library.
allow_public_read = attr.ib(False)
class AccessLevel(object):
""" Enum defining library access levels/permissions """
ADMIN_LEVEL = ContentLibraryPermission.ADMIN_LEVEL
AUTHOR_LEVEL = ContentLibraryPermission.AUTHOR_LEVEL
READ_LEVEL = ContentLibraryPermission.READ_LEVEL
NO_ACCESS = None
@attr.s
class ContentLibraryPermissionEntry(object):
"""
A user or group granted permission to use a content library.
"""
user = attr.ib(type=AbstractUser, default=None)
group = attr.ib(type=Group, default=None)
access_level = attr.ib(AccessLevel.NO_ACCESS)
@attr.s
@@ -117,23 +143,32 @@ class LibraryXBlockType(object):
display_name = attr.ib("")
class AccessLevel(object):
""" Enum defining library access levels/permissions """
ADMIN_LEVEL = ContentLibraryPermission.ADMIN_LEVEL
AUTHOR_LEVEL = ContentLibraryPermission.AUTHOR_LEVEL
READ_LEVEL = ContentLibraryPermission.READ_LEVEL
NO_ACCESS = None
def list_libraries_for_user(user):
"""
Lists up to 50 content libraries that the user has permission to view.
This method makes at least one HTTP call per library so should only be used
for development until we have something more efficient.
"""
qs = ContentLibrary.objects.all()
filtered_qs = permissions.perms[permissions.CAN_VIEW_THIS_CONTENT_LIBRARY].filter(user, qs)
return [get_library(ref.library_key) for ref in filtered_qs[:50]]
def list_libraries():
def require_permission_for_library_key(library_key, user, permission):
"""
TEMPORARY method for testing. Lists all content libraries.
This should be replaced with a method for listing all libraries that belong
to a particular user, and/or has permission to view. This method makes at
least one HTTP call per library so should only be used for development.
Given any of the content library permission strings defined in
openedx.core.djangoapps.content_libraries.permissions,
check if the given user has that permission for the library with the
specified library ID.
Raises django.core.exceptions.PermissionDenied if the user doesn't have
permission.
"""
refs = ContentLibrary.objects.all()[:1000]
return [get_library(ref.library_key) for ref in refs]
assert isinstance(library_key, LibraryLocatorV2)
library_obj = ContentLibrary.objects.get_by_key(library_key)
if not user.has_perm(permission, obj=library_obj):
raise PermissionDenied
def get_library(library_key):
@@ -154,12 +189,14 @@ def get_library(library_key):
title=bundle_metadata.title,
description=bundle_metadata.description,
version=bundle_metadata.latest_version,
allow_public_learning=ref.allow_public_learning,
allow_public_read=ref.allow_public_read,
has_unpublished_changes=has_unpublished_changes,
has_unpublished_deletes=has_unpublished_deletes,
)
def create_library(collection_uuid, org, slug, title, description):
def create_library(collection_uuid, org, slug, title, description, allow_public_learning, allow_public_read):
"""
Create a new content library.
@@ -171,6 +208,10 @@ def create_library(collection_uuid, org, slug, title, description):
description: description of this library
allow_public_learning: Allow anyone to read/learn from blocks in the LMS
allow_public_read: Allow anyone to view blocks (including source) in Studio?
Returns a ContentLibraryMetadata instance.
"""
assert isinstance(collection_uuid, UUID)
@@ -189,8 +230,8 @@ def create_library(collection_uuid, org, slug, title, description):
org=org,
slug=slug,
bundle_uuid=bundle.uuid,
allow_public_learning=True,
allow_public_read=True,
allow_public_learning=allow_public_learning,
allow_public_read=allow_public_read,
)
except IntegrityError:
delete_bundle(bundle.uuid)
@@ -201,9 +242,22 @@ def create_library(collection_uuid, org, slug, title, description):
title=title,
description=description,
version=0,
allow_public_learning=ref.allow_public_learning,
allow_public_read=ref.allow_public_read,
)
def get_library_team(library_key):
"""
Get the list of users/groups granted permission to use this library.
"""
ref = ContentLibrary.objects.get_by_key(library_key)
return [
ContentLibraryPermissionEntry(user=entry.user, group=entry.group, access_level=entry.access_level)
for entry in ref.permission_grants.all()
]
def set_library_user_permissions(library_key, user, access_level):
"""
Change the specified user's level of access to this library.
@@ -212,12 +266,39 @@ def set_library_user_permissions(library_key, user, access_level):
"""
ref = ContentLibrary.objects.get_by_key(library_key)
if access_level is None:
ref.authorized_users.filter(user=user).delete()
ref.permission_grants.filter(user=user).delete()
else:
ContentLibraryPermission.objects.update_or_create(user=user, library=ref, access_level=access_level)
ContentLibraryPermission.objects.update_or_create(
library=ref,
user=user,
defaults={"access_level": access_level},
)
def update_library(library_key, title=None, description=None):
def set_library_group_permissions(library_key, group, access_level):
"""
Change the specified group's level of access to this library.
access_level should be one of the AccessLevel values defined above.
"""
ref = ContentLibrary.objects.get_by_key(library_key)
if access_level is None:
ref.permission_grants.filter(group=group).delete()
else:
ContentLibraryPermission.objects.update_or_create(
library=ref,
group=group,
defaults={"access_level": access_level},
)
def update_library(
library_key,
title=None,
description=None,
allow_public_learning=None,
allow_public_read=None,
):
"""
Update a library's title or description.
(Slug cannot be changed as it would break IDs throughout the system.)
@@ -225,6 +306,17 @@ def update_library(library_key, title=None, description=None):
A value of None means "don't change".
"""
ref = ContentLibrary.objects.get_by_key(library_key)
# Update MySQL model:
changed = False
if allow_public_learning is not None:
ref.allow_public_learning = allow_public_learning
changed = True
if allow_public_read is not None:
ref.allow_public_read = allow_public_read
changed = True
if changed:
ref.save()
# Update Blockstore:
fields = {
# We don't ever read the "slug" value from the Blockstore bundle, but
# we might as well always do our best to keep it in sync with the "slug"

View File

@@ -4,6 +4,9 @@ Definition of "Library" as a learning context.
import logging
from django.core.exceptions import PermissionDenied
from openedx.core.djangoapps.content_libraries import api, permissions
from openedx.core.djangoapps.content_libraries.library_bundle import (
LibraryBundle,
bundle_uuid_for_library_key,
@@ -30,29 +33,45 @@ class LibraryContextImpl(LearningContext):
def can_edit_block(self, user, usage_key):
"""
Does the specified usage key exist in its context, and if so, does the
specified user (which may be an AnonymousUser) have permission to edit
it?
specified user have permission to edit it (make changes to the authored
data store)?
user: a Django User object (may be an AnonymousUser)
usage_key: the UsageKeyV2 subclass used for this learning context
Must return a boolean.
"""
try:
api.require_permission_for_library_key(usage_key.lib_key, user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
except PermissionDenied:
return False
def_key = self.definition_for_usage(usage_key)
if not def_key:
return False
# TODO: implement permissions
return True
def can_view_block(self, user, usage_key):
"""
Does the specified usage key exist in its context, and if so, does the
specified user (which may be an AnonymousUser) have permission to view
it and interact with it (call handlers, save user state, etc.)?
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.
"""
try:
api.require_permission_for_library_key(
usage_key.lib_key, user, permissions.CAN_LEARN_FROM_THIS_CONTENT_LIBRARY,
)
except PermissionDenied:
return False
def_key = self.definition_for_usage(usage_key)
if not def_key:
return False
# TODO: implement permissions
return True
def definition_for_usage(self, usage_key, **kwargs):

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.26 on 2019-12-11 19:20
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('auth', '0008_alter_user_username_max_length'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('content_libraries', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='contentlibrarypermission',
options={'ordering': ('user__username', 'group__name')},
),
migrations.RemoveField(
model_name='contentlibrary',
name='authorized_users',
),
migrations.AddField(
model_name='contentlibrarypermission',
name='group',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='auth.Group', blank=True),
),
migrations.AlterField(
model_name='contentlibrarypermission',
name='library',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permission_grants', to='content_libraries.ContentLibrary'),
),
migrations.AlterField(
model_name='contentlibrarypermission',
name='user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, blank=True),
),
migrations.AlterUniqueTogether(
name='contentlibrarypermission',
unique_together=set([('library', 'user'), ('library', 'group')]),
),
]

View File

@@ -1,11 +1,10 @@
"""
Models for new Content Libraries
"""
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from opaque_keys.edx.locator import LibraryLocatorV2
from organizations.models import Organization
@@ -26,7 +25,6 @@ class ContentLibraryManager(models.Manager):
return self.get(org__short_name=library_key.org, slug=library_key.slug)
@python_2_unicode_compatible
class ContentLibrary(models.Model):
"""
A Content Library is a collection of content (XBlocks and/or static assets)
@@ -68,8 +66,6 @@ class ContentLibrary(models.Model):
"""),
)
authorized_users = models.ManyToManyField(User, through='ContentLibraryPermission')
class Meta:
verbose_name_plural = "Content Libraries"
unique_together = ("org", "slug")
@@ -85,14 +81,15 @@ class ContentLibrary(models.Model):
return "ContentLibrary ({})".format(six.text_type(self.library_key))
@python_2_unicode_compatible
class ContentLibraryPermission(models.Model):
"""
Row recording permissions for a content library
"""
library = models.ForeignKey(ContentLibrary, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
# TODO: allow permissions to be assign to a group, not just a user
library = models.ForeignKey(ContentLibrary, on_delete=models.CASCADE, related_name="permission_grants")
# One of the following must be set (but not both):
user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.CASCADE)
# What level of access is granted to the above user or group:
ADMIN_LEVEL = 'admin'
AUTHOR_LEVEL = 'author'
READ_LEVEL = 'read'
@@ -103,5 +100,25 @@ class ContentLibraryPermission(models.Model):
)
access_level = models.CharField(max_length=30, choices=ACCESS_LEVEL_CHOICES)
class Meta:
ordering = ('user__username', 'group__name')
unique_together = [
('library', 'user'),
('library', 'group'),
]
def save(self, *args, **kwargs): # pylint: disable=arguments-differ
"""
Validate any constraints on the model.
We can remove this and replace it with a proper database constraint
once we're upgraded to Django 2.2+
"""
# if both are nonexistent or both are existing, error
if (not self.user) == (not self.group):
raise ValidationError(_("One and only one of 'user' and 'group' must be set."))
return super().save(*args, **kwargs)
def __str__(self):
return "ContentLibraryPermission ({} for {})".format(self.access_level, self.user.username)
who = self.user.username if self.user else self.group.name
return "ContentLibraryPermission ({} for {})".format(self.access_level, who)

View File

@@ -0,0 +1,98 @@
"""
Permissions for Content Libraries (v2, Blockstore-based)
"""
from bridgekeeper import perms, rules
from bridgekeeper.rules import Attribute, ManyRelation, Relation, in_current_groups
from django.contrib.auth.models import Group
from openedx.core.djangoapps.content_libraries.models import ContentLibraryPermission
# Is the user active (and their email verified)?
is_user_active = rules.is_authenticated & rules.is_active
# Is the user global staff?
is_global_staff = is_user_active & rules.is_staff
# Helper rules used to define the permissions below
# Does the user have at least read permission for the specified library?
has_explicit_read_permission_for_library = (
ManyRelation(
# In newer versions of bridgekeeper, the 1st and 3rd arguments below aren't needed.
'permission_grants', 'contentlibrarypermission', ContentLibraryPermission,
Attribute('user', lambda user: user) | Relation('group', Group, in_current_groups)
)
# We don't check 'access_level' here because all access levels grant read permission
)
# Does the user have at least author permission for the specified library?
has_explicit_author_permission_for_library = (
ManyRelation(
'permission_grants', 'contentlibrarypermission', ContentLibraryPermission,
(Attribute('user', lambda user: user) | Relation('group', Group, in_current_groups)) & (
Attribute('access_level', ContentLibraryPermission.AUTHOR_LEVEL) |
Attribute('access_level', ContentLibraryPermission.ADMIN_LEVEL)
)
)
)
# Does the user have admin permission for the specified library?
has_explicit_admin_permission_for_library = (
ManyRelation(
'permission_grants', 'contentlibrarypermission', ContentLibraryPermission,
(Attribute('user', lambda user: user) | Relation('group', Group, in_current_groups)) &
Attribute('access_level', ContentLibraryPermission.ADMIN_LEVEL)
)
)
########################### Permissions ###########################
# Is the user allowed to view XBlocks from the specified content library
# directly in the LMS, and interact with them?
# Note there is no is_authenticated/is_active check for this one - we allow
# anonymous users to learn if the library allows public learning.
CAN_LEARN_FROM_THIS_CONTENT_LIBRARY = 'content_libraries.learn_from_library'
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:
Attribute('allow_public_learning', True) |
# Users/groups who are explicitly granted permission can learn from the library:
has_explicit_read_permission_for_library
)
# Is the user allowed to create content libraries?
CAN_CREATE_CONTENT_LIBRARY = 'content_libraries.create_library'
perms[CAN_CREATE_CONTENT_LIBRARY] = is_user_active
# Is the user allowed to view the specified content library in Studio,
# including to view the raw OLX and asset files?
CAN_VIEW_THIS_CONTENT_LIBRARY = 'content_libraries.view_library'
perms[CAN_VIEW_THIS_CONTENT_LIBRARY] = is_user_active & (
# Global staff can access any library
is_global_staff |
# Some libraries allow anyone to view them in Studio:
Attribute('allow_public_read', True) |
# Otherwise the user must be part of the library's team
has_explicit_read_permission_for_library
)
# Is the user allowed to edit the specified content library?
CAN_EDIT_THIS_CONTENT_LIBRARY = 'content_libraries.edit_library'
perms[CAN_EDIT_THIS_CONTENT_LIBRARY] = is_user_active & (
is_global_staff | has_explicit_author_permission_for_library
)
# Is the user allowed to view the users/permissions of the specified content library?
CAN_VIEW_THIS_CONTENT_LIBRARY_TEAM = 'content_libraries.view_library_team'
perms[CAN_VIEW_THIS_CONTENT_LIBRARY_TEAM] = perms[CAN_EDIT_THIS_CONTENT_LIBRARY]
# Is the user allowed to edit the users/permissions of the specified content library?
CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM = 'content_libraries.edit_library_team'
perms[CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM] = is_user_active & (
is_global_staff | has_explicit_admin_permission_for_library
)
# Is the user allowed to delete the specified content library?
CAN_DELETE_THIS_CONTENT_LIBRARY = 'content_libraries.delete_library'
perms[CAN_DELETE_THIS_CONTENT_LIBRARY] = is_user_active & (
is_global_staff | has_explicit_admin_permission_for_library
)

View File

@@ -2,11 +2,10 @@
Serializers for the content libraries REST API
"""
# pylint: disable=abstract-method
from django.core.validators import validate_unicode_slug
from rest_framework import serializers
from openedx.core.djangoapps.content_libraries.models import ContentLibraryPermission
from openedx.core.lib import blockstore_api
@@ -16,7 +15,9 @@ class ContentLibraryMetadataSerializer(serializers.Serializer):
"""
# We rename the primary key field to "id" in the REST API since API clients
# often implement magic functionality for fields with that name, and "key"
# is a reserved prop name in React
# is a reserved prop name in React. This 'id' field is a string that
# begins with 'lib:'. (The numeric ID of the ContentLibrary object in MySQL
# is not exposed via this API.)
id = serializers.CharField(source="key", read_only=True)
org = serializers.SlugField(source="key.org")
slug = serializers.CharField(source="key.slug", validators=(validate_unicode_slug, ))
@@ -25,6 +26,8 @@ class ContentLibraryMetadataSerializer(serializers.Serializer):
title = serializers.CharField()
description = serializers.CharField(allow_blank=True)
version = serializers.IntegerField(read_only=True)
allow_public_learning = serializers.BooleanField(default=False)
allow_public_read = serializers.BooleanField(default=False)
has_unpublished_changes = serializers.BooleanField(read_only=True)
has_unpublished_deletes = serializers.BooleanField(read_only=True)
@@ -36,6 +39,27 @@ class ContentLibraryUpdateSerializer(serializers.Serializer):
# These are the only fields that support changes:
title = serializers.CharField()
description = serializers.CharField()
allow_public_learning = serializers.BooleanField()
allow_public_read = serializers.BooleanField()
class ContentLibraryPermissionLevelSerializer(serializers.Serializer):
"""
Serializer for the "Access Level" of a ContentLibraryPermission object.
This is used when updating a user or group's permissions re some content
library.
"""
access_level = serializers.ChoiceField(choices=ContentLibraryPermission.ACCESS_LEVEL_CHOICES)
class ContentLibraryPermissionSerializer(ContentLibraryPermissionLevelSerializer):
"""
Serializer for a ContentLibraryPermission object, which grants either a user
or a group permission to view a content library.
"""
user_id = serializers.IntegerField(source="user.id", allow_null=True)
group_name = serializers.CharField(source="group.name", allow_null=True, allow_blank=False, default=None)
class LibraryXBlockMetadataSerializer(serializers.Serializer):

View File

@@ -2,12 +2,12 @@
"""
Tests for Blockstore-based Content Libraries
"""
from contextlib import contextmanager
import unittest
from django.conf import settings
from organizations.models import Organization
from rest_framework.test import APITestCase
from rest_framework.test import APITestCase, APIClient
import six
from student.tests.factories import UserFactory
@@ -22,6 +22,9 @@ URL_LIB_DETAIL = URL_PREFIX + '{lib_key}/' # Get data about a library, update o
URL_LIB_BLOCK_TYPES = URL_LIB_DETAIL + 'block_types/' # Get the list of XBlock types that can be added to this library
URL_LIB_COMMIT = URL_LIB_DETAIL + 'commit/' # Commit (POST) or revert (DELETE) all pending changes to this library
URL_LIB_BLOCKS = URL_LIB_DETAIL + 'blocks/' # Get the list of XBlocks in this library, or add a new one
URL_LIB_TEAM = URL_LIB_DETAIL + 'team/' # Get the list of users/groups authorized to use this library
URL_LIB_TEAM_USER = URL_LIB_TEAM + 'user/{user_id}/' # Add/edit/remove a user's permission to use this library
URL_LIB_TEAM_GROUP = URL_LIB_TEAM + 'group/{group_name}/' # Add/edit/remove a group's permission to use this library
URL_LIB_BLOCK = URL_PREFIX + 'blocks/{block_key}/' # Get data about a block, or delete it
URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specified XBlock
URL_LIB_BLOCK_ASSETS = URL_LIB_BLOCK + 'assets/' # List the static asset files of the specified XBlock
@@ -76,8 +79,21 @@ class ContentLibrariesRestApiTest(APITestCase):
def setUp(self):
super(ContentLibrariesRestApiTest, self).setUp()
self.clients_by_user = {}
self.client.login(username=self.user.username, password="edx")
# Assertions
def assertDictContainsEntries(self, big_dict, subset_dict):
"""
Assert that the first dict contains at least all of the same entries as
the second dict.
Like python 2's assertDictContainsSubset, but with the arguments in the
correct order.
"""
self.assertGreaterEqual(big_dict.items(), subset_dict.items())
# API helpers
def _api(self, method, url, data, expect_response):
@@ -91,6 +107,19 @@ class ContentLibrariesRestApiTest(APITestCase):
)
return response.data
@contextmanager
def as_user(self, user):
"""
Context manager to call the REST API as a user other than self.user
"""
old_client = self.client
if user not in self.clients_by_user:
client = self.clients_by_user[user] = APIClient()
client.force_authenticate(user=user)
self.client = self.clients_by_user[user] # pylint: disable=attribute-defined-outside-init
yield
self.client = old_client # pylint: disable=attribute-defined-outside-init
def _create_library(self, slug, title, description="", expect_response=200):
""" Create a library """
return self._api('post', URL_LIB_CREATE, {
@@ -105,25 +134,45 @@ class ContentLibrariesRestApiTest(APITestCase):
""" Get a library """
return self._api('get', URL_LIB_DETAIL.format(lib_key=lib_key), None, expect_response)
def _update_library(self, lib_key, **data):
def _update_library(self, lib_key, expect_response=200, **data):
""" Update an existing library """
return self._api('patch', URL_LIB_DETAIL.format(lib_key=lib_key), data=data, expect_response=200)
return self._api('patch', URL_LIB_DETAIL.format(lib_key=lib_key), data, expect_response)
def _delete_library(self, lib_key, expect_response=200):
""" Delete an existing library """
return self._api('delete', URL_LIB_DETAIL.format(lib_key=lib_key), None, expect_response)
def _commit_library_changes(self, lib_key):
def _commit_library_changes(self, lib_key, expect_response=200):
""" Commit changes to an existing library """
return self._api('post', URL_LIB_COMMIT.format(lib_key=lib_key), None, expect_response=200)
return self._api('post', URL_LIB_COMMIT.format(lib_key=lib_key), None, expect_response)
def _revert_library_changes(self, lib_key):
def _revert_library_changes(self, lib_key, expect_response=200):
""" Revert pending changes to an existing library """
return self._api('delete', URL_LIB_COMMIT.format(lib_key=lib_key), None, expect_response=200)
return self._api('delete', URL_LIB_COMMIT.format(lib_key=lib_key), None, expect_response)
def _get_library_blocks(self, lib_key):
def _get_library_team(self, lib_key, expect_response=200):
""" Get the list of users/groups authorized to use this library """
return self._api('get', URL_LIB_TEAM.format(lib_key=lib_key), None, expect_response)
def _set_user_access_level(self, lib_key, user_id, access_level, expect_response=200):
""" Change the specified user's access level """
url = URL_LIB_TEAM_USER.format(lib_key=lib_key, user_id=user_id)
if access_level is None:
return self._api('delete', url, None, expect_response)
else:
return self._api('put', url, {"access_level": access_level}, expect_response)
def _set_group_access_level(self, lib_key, group_name, access_level, expect_response=200):
""" Change the specified group's access level """
url = URL_LIB_TEAM_GROUP.format(lib_key=lib_key, group_name=group_name)
if access_level is None:
return self._api('delete', url, None, expect_response)
else:
return self._api('put', url, {"access_level": access_level}, expect_response)
def _get_library_blocks(self, lib_key, expect_response=200):
""" Get the list of XBlocks in the library """
return self._api('get', URL_LIB_BLOCKS.format(lib_key=lib_key), None, expect_response=200)
return self._api('get', URL_LIB_BLOCKS.format(lib_key=lib_key), None, expect_response)
def _add_block_to_library(self, lib_key, block_type, slug, parent_block=None, expect_response=200):
""" Add a new XBlock to the library """

View File

@@ -2,11 +2,13 @@
"""
Tests for Blockstore-based Content Libraries
"""
import unittest
from uuid import UUID
from django.contrib.auth.models import Group
from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest
from student.tests.factories import UserFactory
class ContentLibrariesTest(ContentLibrariesRestApiTest):
@@ -49,18 +51,18 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
"has_unpublished_changes": False,
"has_unpublished_deletes": False,
}
self.assertDictContainsSubset(expected_data, lib)
self.assertDictContainsEntries(lib, expected_data)
# Check that bundle_uuid looks like a valid UUID
UUID(lib["bundle_uuid"]) # will raise an exception if not valid
# Read:
lib2 = self._get_library(lib["id"])
self.assertDictContainsSubset(expected_data, lib2)
self.assertDictContainsEntries(lib2, expected_data)
# Update:
lib3 = self._update_library(lib["id"], title="New Title")
expected_data["title"] = "New Title"
self.assertDictContainsSubset(expected_data, lib3)
self.assertDictContainsEntries(lib3, expected_data)
# Delete:
self._delete_library(lib["id"])
@@ -94,12 +96,12 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
# Add a 'problem' XBlock to the library:
block_data = self._add_block_to_library(lib_id, "problem", "problem1")
self.assertDictContainsSubset({
self.assertDictContainsEntries(block_data, {
"id": "lb:CL-TEST:testlib1:problem:problem1",
"display_name": "Blank Advanced Problem",
"block_type": "problem",
"has_unpublished_changes": True,
}, block_data)
})
block_id = block_data["id"]
# Confirm that the result contains a definition key, but don't check its value,
# which for the purposes of these tests is an implementation detail.
@@ -114,7 +116,7 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
self.assertEqual(self._get_library(lib_id)["has_unpublished_changes"], False)
# And now the block information should also show that block has no unpublished changes:
block_data["has_unpublished_changes"] = False
self.assertDictContainsSubset(block_data, self._get_library_block(block_id))
self.assertDictContainsEntries(self._get_library_block(block_id), block_data)
self.assertEqual(self._get_library_blocks(lib_id), [block_data])
# Now update the block's OLX:
@@ -138,10 +140,10 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
# now reading it back, we should get that exact OLX (no change to whitespace etc.):
self.assertEqual(self._get_library_block_olx(block_id), new_olx)
# And the display name and "unpublished changes" status of the block should be updated:
self.assertDictContainsSubset({
self.assertDictContainsEntries(self._get_library_block(block_id), {
"display_name": "New Multi Choice Question",
"has_unpublished_changes": True,
}, self._get_library_block(block_id))
})
# Now view the XBlock's student_view (including draft changes):
fragment = self._render_block_view(block_id, "student_view")
@@ -209,3 +211,147 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
# We cannot add a duplicate ID to the library, either at the top level or as a child:
self._add_block_to_library(lib_id, "problem", "problem1", expect_response=400)
self._add_block_to_library(lib_id, "problem", "problem1", parent_block=unit_block["id"], expect_response=400)
# Test that permissions are enforced for content libraries
def test_library_permissions(self): # pylint: disable=too-many-statements
"""
Test that permissions are enforced for content libraries, and that
permissions can be read and manipulated using the REST API (which in
turn tests the python API).
This is a single giant test case, because that optimizes for the fastest
test run time, even though it can make debugging failures harder.
"""
# Create a few users to use for all of these tests:
admin = UserFactory.create(username="Admin", email="admin@example.com")
author = UserFactory.create(username="Author", email="author@example.com")
reader = UserFactory.create(username="Reader", email="reader@example.com")
group = Group.objects.create(name="group1")
author_group_member = UserFactory.create(username="GroupMember", email="groupmember@example.com")
author_group_member.groups.add(group)
random_user = UserFactory.create(username="Random", email="random@example.com")
# Library CRUD #########################################################
# Create a library, owned by "Admin"
with self.as_user(admin):
lib = self._create_library(slug="permtest", title="Permission Test Library", description="Testing")
lib_id = lib["id"]
# By default, "public learning" and public read access are disallowed.
self.assertEqual(lib["allow_public_learning"], False)
self.assertEqual(lib["allow_public_read"], False)
# By default, the creator of a new library is the only admin
data = self._get_library_team(lib_id)
self.assertEqual(len(data), 1)
self.assertDictContainsEntries(data[0], {"user_id": admin.pk, "group_name": None, "access_level": "admin"})
# Add the other users to the content library:
self._set_user_access_level(lib_id, author.pk, access_level="author")
self._set_user_access_level(lib_id, reader.pk, access_level="read")
self._set_group_access_level(lib_id, group.name, access_level="author")
team_response = self._get_library_team(lib_id)
self.assertEqual(len(team_response), 4)
# The response should also always be sorted in a specific order (by username and group name):
expected_response = [
{"user_id": None, "group_name": "group1", "access_level": "author"},
{"user_id": admin.pk, "group_name": None, "access_level": "admin"},
{"user_id": author.pk, "group_name": None, "access_level": "author"},
{"user_id": reader.pk, "group_name": None, "access_level": "read"},
]
for entry, expected in zip(team_response, expected_response):
self.assertDictContainsEntries(entry, expected)
# A random user cannot get the library nor its team:
with self.as_user(random_user):
self._get_library(lib_id, expect_response=403)
self._get_library_team(lib_id, expect_response=403)
# But every authorized user can:
for user in [admin, author, author_group_member]:
with self.as_user(user):
self._get_library(lib_id)
data = self._get_library_team(lib_id)
self.assertEqual(data, team_response)
# A user with only read permission can get data about the library but not the team:
with self.as_user(reader):
self._get_library(lib_id)
self._get_library_team(lib_id, expect_response=403)
# Users without admin access cannot delete the library nor change its team:
for user in [author, reader, author_group_member, random_user]:
with self.as_user(user):
self._delete_library(lib_id, expect_response=403)
self._set_user_access_level(lib_id, author.pk, access_level="admin", expect_response=403)
self._set_user_access_level(lib_id, admin.pk, access_level=None, expect_response=403)
self._set_user_access_level(lib_id, random_user.pk, access_level="read", expect_response=403)
# Users with author access (or higher) can edit the library's properties:
with self.as_user(author):
self._update_library(lib_id, description="Revised description")
with self.as_user(author_group_member):
self._update_library(lib_id, title="New Library Title")
# But other users cannot:
with self.as_user(reader):
self._update_library(lib_id, description="Prohibited description", expect_response=403)
with self.as_user(random_user):
self._update_library(lib_id, title="I can't set this title", expect_response=403)
# Verify the permitted changes were made:
with self.as_user(admin):
data = self._get_library(lib_id)
self.assertEqual(data["description"], "Revised description")
self.assertEqual(data["title"], "New Library Title")
# Library XBlock editing ###############################################
# users with read permission or less cannot add blocks:
for user in [reader, random_user]:
with self.as_user(user):
self._add_block_to_library(lib_id, "problem", "problem1", expect_response=403)
# But authors and admins can:
with self.as_user(admin):
self._add_block_to_library(lib_id, "problem", "problem1")
with self.as_user(author):
self._add_block_to_library(lib_id, "problem", "problem2")
with self.as_user(author_group_member):
block3_data = self._add_block_to_library(lib_id, "problem", "problem3")
block3_key = block3_data["id"]
# At this point, the library contains 3 draft problem XBlocks.
# 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_assets(block3_key, expect_response=403)
self._get_library_block_asset(block3_key, file_name="whatever.png", 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._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):
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_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)
self._revert_library_changes(lib_id, expect_response=403)
# But users with author permission can:
with self.as_user(author_group_member):
olx = self._get_library_block_olx(block3_key)
self._set_library_block_olx(block3_key, olx)
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")
self._delete_library_block(block3_key)
self._commit_library_changes(lib_id)
self._revert_library_changes(lib_id) # This is a no-op after the commit, but should still have 200 response

View File

@@ -2,7 +2,6 @@
"""
Test the Blockstore-based XBlock runtime and content libraries together.
"""
import json
from completion.test_utils import CompletionWaffleTestMixin
@@ -50,6 +49,8 @@ class ContentLibraryContentTestMixin(object):
slug=cls.__name__,
title=(cls.__name__ + " Test Lib"),
description="",
allow_public_learning=True,
allow_public_read=False,
)
@@ -81,6 +82,8 @@ class ContentLibraryRuntimeTest(ContentLibraryContentTestMixin, TestCase):
slug="idolx",
title=("Identical OLX Test Lib 2"),
description="",
allow_public_learning=True,
allow_public_read=False,
)
unit_block2_key = library_api.create_library_block(library2.key, "unit", "u1").usage_key
library_api.create_library_block_child(unit_block2_key, "problem", "p1")

View File

@@ -1,8 +1,6 @@
"""
URL configuration for Studio's Content Libraries REST API
"""
from django.conf.urls import include, url
from . import views
@@ -26,6 +24,12 @@ urlpatterns = [
url(r'^blocks/$', views.LibraryBlocksView.as_view()),
# Commit (POST) or revert (DELETE) all pending changes to this library:
url(r'^commit/$', views.LibraryCommitView.as_view()),
# Get the list of users/groups who have permission to view/edit/administer this library:
url(r'^team/$', views.LibraryTeamView.as_view()),
# Add/Edit (PUT) or remove (DELETE) a user's permission to use this library
url(r'^team/user/(?P<user_id>\d+)/$', views.LibraryTeamUserView.as_view()),
# Add/Edit (PUT) or remove (DELETE) a group's permission to use this library
url(r'^team/group/(?P<group_name>[^/]+)/$', views.LibraryTeamGroupView.as_view()),
])),
url(r'^blocks/(?P<usage_key_str>[^/]+)/', include([
# Get metadata about a specific XBlock in this library, or delete the block:

View File

@@ -1,23 +1,26 @@
"""
REST API for Blockstore-based content libraries
"""
from functools import wraps
import logging
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.shortcuts import get_object_or_404
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
from organizations.models import Organization
from rest_framework import status
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
from rest_framework.parsers import MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from openedx.core.lib.api.view_utils import view_auth_classes
from . import api
from .serializers import (
from openedx.core.djangoapps.content_libraries import api, permissions
from openedx.core.djangoapps.content_libraries.serializers import (
ContentLibraryMetadataSerializer,
ContentLibraryUpdateSerializer,
ContentLibraryPermissionLevelSerializer,
ContentLibraryPermissionSerializer,
LibraryXBlockCreationSerializer,
LibraryXBlockMetadataSerializer,
LibraryXBlockTypeSerializer,
@@ -25,7 +28,9 @@ from .serializers import (
LibraryXBlockStaticFileSerializer,
LibraryXBlockStaticFilesSerializer,
)
from openedx.core.lib.api.view_utils import view_auth_classes
User = get_user_model()
log = logging.getLogger(__name__)
@@ -62,16 +67,19 @@ class LibraryRootView(APIView):
def get(self, request):
"""
Return a list of all content libraries. This is a temporary view for
development.
Return a list of all content libraries that the user has permission to
view. This is a temporary view for development and returns at most 50
libraries.
"""
result = api.list_libraries()
result = api.list_libraries_for_user(request.user)
return Response(ContentLibraryMetadataSerializer(result, many=True).data)
def post(self, request):
"""
Create a new content library.
"""
if not request.user.has_perm(permissions.CAN_CREATE_CONTENT_LIBRARY):
raise PermissionDenied
serializer = ContentLibraryMetadataSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
@@ -103,6 +111,7 @@ class LibraryDetailsView(APIView):
Get a specific content library
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
result = api.get_library(key)
return Response(ContentLibraryMetadataSerializer(result).data)
@@ -112,6 +121,7 @@ class LibraryDetailsView(APIView):
Update a content library
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
serializer = ContentLibraryUpdateSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
api.update_library(key, **serializer.validated_data)
@@ -124,10 +134,97 @@ class LibraryDetailsView(APIView):
Delete a content library
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_DELETE_THIS_CONTENT_LIBRARY)
api.delete_library(key)
return Response({})
@view_auth_classes()
class LibraryTeamView(APIView):
"""
View to get the list of users/groups who can access and edit the content
library.
Note also the 'allow_public_' settings which can be edited by PATCHing the
library itself (LibraryDetailsView.patch).
"""
@convert_exceptions
def get(self, request, lib_key_str):
"""
Get the list of users and groups who have permissions to view and edit
this library.
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY_TEAM)
team = api.get_library_team(key)
return Response(ContentLibraryPermissionSerializer(team, many=True).data)
@view_auth_classes()
class LibraryTeamUserView(APIView):
"""
View to add/remove/edit an individual user's permissions for a content
library.
"""
@convert_exceptions
def put(self, request, lib_key_str, user_id):
"""
Add a user to this content library, with permissions specified in the
request body.
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM)
serializer = ContentLibraryPermissionLevelSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = get_object_or_404(User, pk=int(user_id))
api.set_library_user_permissions(key, user, access_level=serializer.validated_data["access_level"])
return Response({})
@convert_exceptions
def delete(self, request, lib_key_str, user_id):
"""
Remove the specified user's permission to access or edit this content
library.
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM)
user = get_object_or_404(User, pk=int(user_id))
api.set_library_user_permissions(key, user, access_level=None)
return Response({})
@view_auth_classes()
class LibraryTeamGroupView(APIView):
"""
View to add/remove/edit a group's permissions for a content library.
"""
@convert_exceptions
def put(self, request, lib_key_str, group_name):
"""
Add a group to this content library, with permissions specified in the
request body.
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM)
serializer = ContentLibraryPermissionLevelSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
group = get_object_or_404(Group, name=group_name)
api.set_library_group_permissions(key, group, access_level=serializer.validated_data["access_level"])
return Response({})
@convert_exceptions
def delete(self, request, lib_key_str, user_id):
"""
Remove the specified user's permission to access or edit this content
library.
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM)
group = get_object_or_404(Group, pk=int(user_id))
api.set_library_group_permissions(key, group, access_level=None)
return Response({})
@view_auth_classes()
class LibraryBlockTypesView(APIView):
"""
@@ -139,6 +236,7 @@ class LibraryBlockTypesView(APIView):
Get the list of XBlock types that can be added to this library
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
result = api.get_allowed_block_types(key)
return Response(LibraryXBlockTypeSerializer(result, many=True).data)
@@ -155,6 +253,7 @@ class LibraryCommitView(APIView):
descendants.
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
api.publish_changes(key)
return Response({})
@@ -165,6 +264,7 @@ class LibraryCommitView(APIView):
descendants. Restore it to the last published version
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
api.revert_changes(key)
return Response({})
@@ -180,6 +280,7 @@ class LibraryBlocksView(APIView):
Get the list of all top-level blocks in this content library
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
result = api.get_library_blocks(key)
return Response(LibraryXBlockMetadataSerializer(result, many=True).data)
@@ -189,6 +290,7 @@ class LibraryBlocksView(APIView):
Add a new XBlock to this content library
"""
library_key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
serializer = LibraryXBlockCreationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
parent_block_usage_str = serializer.validated_data.pop("parent_block", None)
@@ -215,6 +317,7 @@ class LibraryBlockView(APIView):
Get metadata about an existing XBlock in the content library
"""
key = LibraryUsageLocatorV2.from_string(usage_key_str)
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
result = api.get_library_block(key)
return Response(LibraryXBlockMetadataSerializer(result).data)
@@ -232,6 +335,7 @@ class LibraryBlockView(APIView):
be deleted but the link and the linked bundle will be unaffected.
"""
key = LibraryUsageLocatorV2.from_string(usage_key_str)
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
api.delete_library_block(key)
return Response({})
@@ -247,6 +351,7 @@ class LibraryBlockOlxView(APIView):
Get the block's OLX
"""
key = LibraryUsageLocatorV2.from_string(usage_key_str)
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
xml_str = api.get_library_block_olx(key)
return Response(LibraryXBlockOlxSerializer({"olx": xml_str}).data)
@@ -259,6 +364,7 @@ class LibraryBlockOlxView(APIView):
Very little validation is done.
"""
key = LibraryUsageLocatorV2.from_string(usage_key_str)
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
serializer = LibraryXBlockOlxSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_olx_str = serializer.validated_data["olx"]
@@ -280,6 +386,7 @@ class LibraryBlockAssetListView(APIView):
List the static asset files belonging to this block.
"""
key = LibraryUsageLocatorV2.from_string(usage_key_str)
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
files = api.get_library_block_static_asset_files(key)
return Response(LibraryXBlockStaticFilesSerializer({"files": files}).data)
@@ -297,6 +404,7 @@ class LibraryBlockAssetView(APIView):
Get a static asset file belonging to this block.
"""
key = LibraryUsageLocatorV2.from_string(usage_key_str)
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
files = api.get_library_block_static_asset_files(key)
for f in files:
if f.path == file_path:
@@ -309,6 +417,9 @@ class LibraryBlockAssetView(APIView):
Replace a static asset file belonging to this block.
"""
usage_key = LibraryUsageLocatorV2.from_string(usage_key_str)
api.require_permission_for_library_key(
usage_key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,
)
file_wrapper = request.data['content']
if file_wrapper.size > 20 * 1024 * 1024: # > 20 MiB
# In the future, we need a way to use file_wrapper.chunks() to read
@@ -323,11 +434,14 @@ class LibraryBlockAssetView(APIView):
return Response(LibraryXBlockStaticFileSerializer(result).data)
@convert_exceptions
def delete(self, request, usage_key_str, file_path): # pylint: disable=unused-argument
def delete(self, request, usage_key_str, file_path):
"""
Delete a static asset file belonging to this block.
"""
usage_key = LibraryUsageLocatorV2.from_string(usage_key_str)
api.require_permission_for_library_key(
usage_key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,
)
try:
api.delete_library_block_static_asset_file(usage_key, file_path)
except ValueError:

View File

@@ -62,14 +62,9 @@ def load_block(usage_key, user):
# Is this block part of a course, a library, or what?
# Get the Learning Context Implementation based on the usage key
context_impl = get_learning_context_impl(usage_key)
# Now, using the LearningContext and the Studio/LMS-specific logic, check if
# the block exists in this context and if the user has permission to render
# this XBlock view:
if get_xblock_app_config().require_edit_permission:
authorized = context_impl.can_edit_block(user, usage_key)
else:
authorized = context_impl.can_view_block(user, usage_key)
if not authorized:
# 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("XBlock {} does not exist, or you don't have permission to view it.".format(usage_key))

View File

@@ -17,10 +17,6 @@ class XBlockAppConfig(AppConfig):
verbose_name = 'New XBlock Runtime'
label = 'xblock_new' # The name 'xblock' is already taken by ORA2's 'openassessment.xblock' app :/
# If this is True, users must have 'edit' permission to be allowed even to
# view content. (It's only true in Studio)
require_edit_permission = False
def get_runtime_system_params(self):
"""
Get the XBlockRuntimeSystem parameters appropriate for viewing and/or
@@ -72,8 +68,6 @@ class StudioXBlockAppConfig(XBlockAppConfig):
"""
Studio-specific configuration of the XBlock Runtime django app.
"""
# In Studio, users must have 'edit' permission to be allowed even to view content
require_edit_permission = True
BLOCKSTORE_DRAFT_NAME = "studio_draft"

View File

@@ -26,7 +26,8 @@ class LearningContext(object):
def can_edit_block(self, user, usage_key): # pylint: disable=unused-argument
"""
Does the specified usage key exist in its context, and if so, does the
specified user have permission to edit it?
specified user have permission to edit it (make changes to the authored
data store)?
user: a Django User object (may be an AnonymousUser)

View File

@@ -120,6 +120,13 @@ class BlockstoreXBlockRuntime(XBlockRuntime):
"The Blockstore runtime does not support saving changes to blockstore without a draft. "
"Are you making changes to UserScope.NONE fields from the LMS rather than Studio?"
)
# Verify that the user has permission to write to authored data in this
# learning context:
if self.user is not None:
learning_context = get_learning_context_impl(block.scope_ids.usage_id)
if not learning_context.can_edit_block(self.user, block.scope_ids.usage_id):
log.warning("User %s does not have permission to edit %s", self.user.username, block.scope_ids.usage_id)
raise RuntimeError("You do not have permission to edit this XBlock")
olx_str, static_files = serialize_xblock(block)
# Write the OLX file to the bundle:
draft_uuid = blockstore_api.get_or_create_bundle_draft(