From 14899bf5fe3f75f3370e95f99abe0c5c86b0d036 Mon Sep 17 00:00:00 2001 From: Fox Danger Piacenti Date: Wed, 2 Sep 2026 17:48:31 -0500 Subject: [PATCH 1/2] feat: libraries v2 support for studio perms --- cms/djangoapps/contentstore/utils.py | 24 +--- cms/djangoapps/contentstore/views/preview.py | 3 +- .../core/djangoapps/authz/tests/fixtures.py | 26 +++++ openedx/core/djangoapps/authz/tests/mixins.py | 27 +---- openedx/core/djangoapps/content/services.py | 39 +++++++ .../core/djangoapps/content/tests/__init__.py | 0 .../djangoapps/content/tests/test_services.py | 103 ++++++++++++++++++ .../content_libraries/api/libraries.py | 2 +- .../core/djangoapps/xblock/runtime/runtime.py | 7 ++ 9 files changed, 182 insertions(+), 49 deletions(-) create mode 100644 openedx/core/djangoapps/authz/tests/fixtures.py create mode 100644 openedx/core/djangoapps/content/services.py create mode 100644 openedx/core/djangoapps/content/tests/__init__.py create mode 100644 openedx/core/djangoapps/content/tests/test_services.py diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 143f01cde32a..99a628a148f1 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -53,7 +53,7 @@ from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.edxmako.services import MakoService from common.djangoapps.student import auth -from common.djangoapps.student.auth import STUDIO_EDIT_ROLES, has_studio_read_access, has_studio_write_access +from common.djangoapps.student.auth import STUDIO_EDIT_ROLES, has_studio_write_access from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff from common.djangoapps.track import contexts @@ -71,6 +71,7 @@ from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService from openedx.core import toggles as core_toggles from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.content.services import StudioPermissionsService from openedx.core.djangoapps.content_libraries.api import get_container from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled from openedx.core.djangoapps.credit.api import get_credit_requirements, is_credit_course @@ -2243,27 +2244,6 @@ def get_group_configurations_context(course, store): return context -class StudioPermissionsService: - """ - Service that can provide information about a user's permissions. - - Deprecated. To be replaced by a more general authorization service. - - Only used by LegacyLibraryContentBlock (and library_tools.py). - """ - - def __init__(self, user): - self._user = user - - def can_read(self, course_key): - """ Does the user have read access to the given course/library? """ - return has_studio_read_access(self._user, course_key) - - def can_write(self, course_key): - """ Does the user have read access to the given course/library? """ - return has_studio_write_access(self._user, course_key) - - def track_course_update_event(course_key, user, course_update_content=None): """ Track course update event diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 85b073b2734d..18cf525cb7db 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -29,6 +29,7 @@ from common.djangoapps.student.models import anonymous_id_for_user from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService from lms.djangoapps.lms_xblock.field_data import LmsFieldData +from openedx.core.djangoapps.content.services import StudioPermissionsService from openedx.core.djangoapps.discussions.services import DiscussionConfigService from openedx.core.djangoapps.video_config.services import VideoConfigService from openedx.core.lib.cache_utils import CacheService @@ -44,7 +45,7 @@ from xmodule.util.sandboxing import SandboxService from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, XModuleMixin -from ..utils import StudioPermissionsService, get_visibility_partition_info +from ..utils import get_visibility_partition_info from .access import get_user_role from .session_kv_store import SessionKeyValueStore diff --git a/openedx/core/djangoapps/authz/tests/fixtures.py b/openedx/core/djangoapps/authz/tests/fixtures.py new file mode 100644 index 000000000000..80831246bd61 --- /dev/null +++ b/openedx/core/djangoapps/authz/tests/fixtures.py @@ -0,0 +1,26 @@ +""" Fixtures for AuthZ-aware tests """ +import casbin +import pkg_resources +from openedx_authz.engine.enforcer import AuthzEnforcer +from openedx_authz.engine.utils import migrate_policy_between_enforcers + + +def seed_policies(): + """Seed the database with AuthZ policies.""" + global_enforcer = AuthzEnforcer.get_enforcer() + global_enforcer.load_policy() + + model_path = pkg_resources.resource_filename( + "openedx_authz.engine", + "config/model.conf", + ) + + policy_path = pkg_resources.resource_filename( + "openedx_authz.engine", + "config/authz.policy", + ) + + migrate_policy_between_enforcers( + source_enforcer=casbin.Enforcer(model_path, policy_path), + target_enforcer=global_enforcer, + ) diff --git a/openedx/core/djangoapps/authz/tests/mixins.py b/openedx/core/djangoapps/authz/tests/mixins.py index c6385115693f..2c2f227875ae 100644 --- a/openedx/core/djangoapps/authz/tests/mixins.py +++ b/openedx/core/djangoapps/authz/tests/mixins.py @@ -2,16 +2,14 @@ from unittest.mock import patch -import casbin -import pkg_resources from openedx_authz.api.users import assign_role_to_user_in_scope from openedx_authz.constants.roles import COURSE_STAFF from openedx_authz.engine.enforcer import AuthzEnforcer -from openedx_authz.engine.utils import migrate_policy_between_enforcers from rest_framework.test import APIClient from common.djangoapps.student.tests.factories import UserFactory from openedx.core import toggles as core_toggles +from openedx.core.djangoapps.authz.tests.fixtures import seed_policies class CourseAuthoringAuthzTestMixin: @@ -44,7 +42,7 @@ def tearDownClass(cls): def setUp(self): super().setUp() - self._seed_policies() + seed_policies() self.authorized_user = UserFactory(password=self.password) self.unauthorized_user = UserFactory(password=self.password) @@ -76,27 +74,6 @@ def add_user_to_role_in_course(self, user, role, course_key): ) AuthzEnforcer.get_enforcer().load_policy() - @classmethod - def _seed_policies(cls): - """Seed the database with AuthZ policies.""" - global_enforcer = AuthzEnforcer.get_enforcer() - global_enforcer.load_policy() - - model_path = pkg_resources.resource_filename( - "openedx_authz.engine", - "config/model.conf", - ) - - policy_path = pkg_resources.resource_filename( - "openedx_authz.engine", - "config/authz.policy", - ) - - migrate_policy_between_enforcers( - source_enforcer=casbin.Enforcer(model_path, policy_path), - target_enforcer=global_enforcer, - ) - class CourseAuthzTestMixin(CourseAuthoringAuthzTestMixin): """ diff --git a/openedx/core/djangoapps/content/services.py b/openedx/core/djangoapps/content/services.py new file mode 100644 index 000000000000..3100008a19c1 --- /dev/null +++ b/openedx/core/djangoapps/content/services.py @@ -0,0 +1,39 @@ +""" +Services for learning content +""" +from __future__ import annotations + +from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_authz import api as authz_api +from openedx_authz.constants.permissions import EDIT_LIBRARY_CONTENT, VIEW_LIBRARY + +from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access + + +class StudioPermissionsService: + """ + Service that can provide information about a user's permissions. + """ + + def __init__(self, user): + self._user = user + + def can_read(self, context_key): + """ Does the user have read access to the given course/library? """ + if isinstance(context_key, LibraryLocatorV2): + return self._user.is_active and authz_api.is_user_allowed( + self._user, + VIEW_LIBRARY.identifier, + str(context_key), + ) + return has_studio_read_access(self._user, context_key) + + def can_write(self, context_key): + """ Does the user have write access to the given course/library? """ + if isinstance(context_key, LibraryLocatorV2): + return self._user.is_active and authz_api.is_user_allowed( + self._user, + EDIT_LIBRARY_CONTENT.identifier, + str(context_key), + ) + return has_studio_write_access(self._user, context_key) diff --git a/openedx/core/djangoapps/content/tests/__init__.py b/openedx/core/djangoapps/content/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content/tests/test_services.py b/openedx/core/djangoapps/content/tests/test_services.py new file mode 100644 index 000000000000..66a3ec05fe7c --- /dev/null +++ b/openedx/core/djangoapps/content/tests/test_services.py @@ -0,0 +1,103 @@ +""" +Tests for content XBlock Services +""" +from django.contrib.auth import get_user_model +from django.test import TransactionTestCase +from opaque_keys.edx.locator import LibraryLocatorV2 +from organizations.models import Organization + +from common.djangoapps.student.auth import update_org_role +from common.djangoapps.student.roles import OrgStaffRole +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.fixtures import seed_policies +from openedx.core.djangoapps.content.services import StudioPermissionsService +from openedx.core.djangoapps.content_libraries.api import ( + AccessLevel, + ContentLibraryMetadata, + assign_library_role_to_user, + create_library, +) +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +User = get_user_model() + + +class StudioPermissionsServiceTestCase(ModuleStoreTestCase, TransactionTestCase): + """ + Test the studio permissions service. + """ + + def setUp(self) -> None: + super().setUp() + seed_policies() + self.org = Organization.objects.create(name="Organization A", short_name="orgA") + self.staff = UserFactory.create( + is_staff=True, + ) + + def _create_privileged_org_user(self) -> User: + user = UserFactory.create() + update_org_role(self.staff, OrgStaffRole, user, [self.org.short_name]) + return user + + def test_user_can_read_course(self) -> None: + course = CourseFactory.create(org=self.org.short_name) + user = self._create_privileged_org_user() + service = StudioPermissionsService(user=user) + assert service.can_read(course.location) + + def test_user_can_write_course(self) -> None: + course = CourseFactory.create(org=self.org.short_name) + user = self._create_privileged_org_user() + service = StudioPermissionsService(user=user) + assert service.can_write(course.location) + + def test_user_cannot_read_course(self) -> None: + course = CourseFactory.create(org=self.org.short_name) + user = UserFactory.create() + service = StudioPermissionsService(user=user) + assert not service.can_read(course.location) + + def test_user_cannot_write_course(self) -> None: + course = CourseFactory.create(org=self.org.short_name) + user = UserFactory.create() + service = StudioPermissionsService(user=user) + assert not service.can_write(course.location) + + def _create_library(self) -> ContentLibraryMetadata: + return create_library( + org=self.org, + slug="lib", + title="Library Org", + description="This is a library from Org", + ) + + def _create_privileged_library_user(self, library_key: LibraryLocatorV2) -> User: + user = UserFactory.create() + assign_library_role_to_user(library_key, user, AccessLevel.ADMIN_LEVEL) + return user + + def test_user_can_read_library(self) -> None: + library = self._create_library() + user = self._create_privileged_library_user(library.key) + service = StudioPermissionsService(user=user) + assert service.can_read(library.key) + + def test_user_can_write_library(self) -> None: + library = self._create_library() + user = self._create_privileged_library_user(library.key) + service = StudioPermissionsService(user=user) + assert service.can_write(library.key) + + def test_user_cannot_read_library(self) -> None: + library = self._create_library() + user = UserFactory.create() + service = StudioPermissionsService(user=user) + assert not service.can_read(library.key) + + def test_user_cannot_write_library(self) -> None: + library = self._create_library() + user = UserFactory.create() + service = StudioPermissionsService(user=user) + assert not service.can_write(library.key) diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index f8573eadd473..f4fea2c5912c 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -415,7 +415,7 @@ def get_library(library_key: LibraryLocatorV2) -> ContentLibraryMetadata: def create_library( - org: str, + org: Organization, slug: str, title: str, description: str = "", diff --git a/openedx/core/djangoapps/xblock/runtime/runtime.py b/openedx/core/djangoapps/xblock/runtime/runtime.py index f3544a6102d8..dc5d0696ee62 100644 --- a/openedx/core/djangoapps/xblock/runtime/runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/runtime.py @@ -355,6 +355,13 @@ def service(self, block: XBlock, service_name: str): return DiscussionConfigService() elif service_name == 'xqueue': return XQueueService(block) + elif service_name == 'studio_user_permissions': + from openedx.core.djangoapps.content.services import StudioPermissionsService + if self.user is None: + raise RuntimeError( + "Cannot access studio permissions service when there is no user bound to the XBlock." + ) + return StudioPermissionsService(self.user) # Otherwise, fall back to the base implementation which loads services # defined in the constructor: From 18d2f1f09dcdffd7f041eae05d14531e0796a4d7 Mon Sep 17 00:00:00 2001 From: Fox Danger Piacenti Date: Fri, 18 Sep 2026 12:40:52 -0500 Subject: [PATCH 2/2] feat: more comprehensive permissions in studio perms service --- openedx/core/djangoapps/content/services.py | 153 +++++++++++++++++--- 1 file changed, 136 insertions(+), 17 deletions(-) diff --git a/openedx/core/djangoapps/content/services.py b/openedx/core/djangoapps/content/services.py index 3100008a19c1..80af757dc40f 100644 --- a/openedx/core/djangoapps/content/services.py +++ b/openedx/core/djangoapps/content/services.py @@ -3,37 +3,156 @@ """ from __future__ import annotations -from opaque_keys.edx.locator import LibraryLocatorV2 +import warnings +from typing import Literal + +from opaque_keys.edx.keys import LearningContextKey +from opaque_keys.edx.locator import CourseLocator, LibraryLocatorV2 from openedx_authz import api as authz_api -from openedx_authz.constants.permissions import EDIT_LIBRARY_CONTENT, VIEW_LIBRARY +from openedx_authz.constants.permissions import ( + COURSES_CREATE_FILES, + COURSES_EDIT_COURSE_CONTENT, + COURSES_EDIT_FILES, + COURSES_MANAGE_COURSE_UPDATES, + COURSES_VIEW_COURSE, + COURSES_VIEW_FILES, + EDIT_LIBRARY_CONTENT, + REUSE_LIBRARY_CONTENT, + VIEW_LIBRARY, +) +from openedx_authz.data import PermissionData from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access +COMMON_PERM_KEYS = Literal[ + "read_content", + "edit_content", + "read_files", + "edit_files", + "create_files", + "reuse_content", + "manage_updates", +] +CONTEXT_TYPES = Literal["course", "library"] + + +# Maps conceptual permissions to how they're applied to a particular learning context. +# Note, for instance, that courses need a special file permission that libraries don't need, +# since library files are constrained to the blocks whereas course files are course-wide. +# +# This can be expanded with more permissions from the AuthZ constants if they're needed later +# on following the patterns in this file. +PERMISSION_MAP: dict[COMMON_PERM_KEYS, dict[CONTEXT_TYPES, PermissionData]] = { + "read_content": { + "course": COURSES_VIEW_COURSE, + "library": VIEW_LIBRARY, + }, + "edit_content": { + "course": COURSES_EDIT_COURSE_CONTENT, + "library": EDIT_LIBRARY_CONTENT, + }, + "read_files": { + "course": COURSES_VIEW_FILES, + "library": VIEW_LIBRARY, + }, + "create_files": { + "course": COURSES_CREATE_FILES, + "library": EDIT_LIBRARY_CONTENT, + }, + "edit_files": { + "course": COURSES_EDIT_FILES, + "library": EDIT_LIBRARY_CONTENT, + }, + "reuse_content": { + "library": REUSE_LIBRARY_CONTENT, + }, + "manage_updates": { + "course": COURSES_MANAGE_COURSE_UPDATES, + } +} + + +def perm_for_key(perm_label: COMMON_PERM_KEYS, context_key: LearningContextKey) -> PermissionData: + """Returns the appropriate permission data for a context key.""" + if perm_label not in PERMISSION_MAP: + raise ValueError(f"Unsupported permission category, {perm_label}.") + match context_key: + case LibraryLocatorV2(): + try: + return PERMISSION_MAP[perm_label]["library"] + except KeyError as err: + raise TypeError(f"Permission category not applicable to libraries: {perm_label}") from err + case CourseLocator(): + try: + return PERMISSION_MAP[perm_label]["course"] + except KeyError as err: + raise TypeError(f"Permission category not applicable to courses: {perm_label}") from err + raise TypeError(f"{context_key} is not a recognized LearningContextKey.") + class StudioPermissionsService: """ Service that can provide information about a user's permissions. """ - def __init__(self, user): + def __init__(self, user) -> None: self._user = user - def can_read(self, context_key): + def _check_permission(self, perm_label: COMMON_PERM_KEYS, context_key: LearningContextKey) -> bool: + """ Verify that a user has a specific permission for this context. """ + return self._user.is_active and authz_api.is_user_allowed( + self._user, + perm_for_key(perm_label, context_key).identifier, + str(context_key), + ) + + def can_read_content(self, context_key: LearningContextKey) -> bool: + """ Can the user read the content of this course/library? """ + return self._check_permission("read_content", context_key) + + def can_edit_content(self, context_key: LearningContextKey) -> bool: + """ Can the user edit the content of this course/library? """ + return self._check_permission("edit_content", context_key) + + def can_read_files(self, context_key: LearningContextKey) -> bool: + """ Can the user read files for this course/library? """ + return self._check_permission("read_files", context_key) + + def can_create_files(self, context_key: LearningContextKey) -> bool: + """ Can the user create files for this course/library? """ + return self._check_permission("create_files", context_key) + + def can_edit_files(self, context_key: LearningContextKey) -> bool: + """ Can the user write files for this course/library? """ + return self._check_permission("edit_files", context_key) + + def can_read(self, context_key: LearningContextKey) -> bool: """ Does the user have read access to the given course/library? """ - if isinstance(context_key, LibraryLocatorV2): - return self._user.is_active and authz_api.is_user_allowed( - self._user, - VIEW_LIBRARY.identifier, - str(context_key), - ) + warnings.warn( + "can_read is deprecated. " + "Use a more specific permission, like can_read_content or can_read_files instead. " + "See https://github.com/openedx/openedx-platform/issues/37409.", + DeprecationWarning, + stacklevel=2, + ) return has_studio_read_access(self._user, context_key) - def can_write(self, context_key): + def can_write(self, context_key: LearningContextKey) -> bool: """ Does the user have write access to the given course/library? """ - if isinstance(context_key, LibraryLocatorV2): - return self._user.is_active and authz_api.is_user_allowed( - self._user, - EDIT_LIBRARY_CONTENT.identifier, - str(context_key), - ) + warnings.warn( + "can_write is deprecated. " + "Use a more specific permission, like can_write_content, can_create_files, " + "or can_edit_files instead. " + "See https://github.com/openedx/openedx-platform/issues/37409.", + DeprecationWarning, + stacklevel=2, + ) return has_studio_write_access(self._user, context_key) + + def can_reuse_content(self, context_key: LibraryLocatorV2) -> bool: + """ Does the user have the ability to reuse content from this library? """ + return self._check_permission("reuse_content", context_key) + + def can_manage_updates(self, context_key: CourseLocator) -> bool: + """ Does the user have the ability to manage updates for this course? """ + return self._check_permission("manage_updates", context_key)