Repository navigation
feat: libraries v2 support for studio perms XBlock service #39074
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
bradenmacdonald
merged 1 commit into
openedx:master
from
open-craft:fox/studio-permissions-universality
Oct 15, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,184 @@ | ||
| """ | ||
| Services for learning content | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import warnings | ||
| from collections.abc import Callable | ||
| from typing import Literal | ||
|
|
||
| from opaque_keys.edx.keys import LearningContextKey | ||
| from opaque_keys.edx.locator import CourseLocator, LibraryLocatorV2 | ||
| from openedx_authz.api import is_user_allowed | ||
| 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 | ||
| from common.djangoapps.student.roles import enable_authz_course_authoring | ||
| from openedx.core.djangoapps.content_tagging.rules import UserType | ||
|
|
||
| COMMON_PERM_KEY = 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_KEY, 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_KEY, 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.") | ||
|
|
||
|
|
||
| def _course_fallback_check(fallback_function: Callable[[UserType, CourseLocator], bool]): | ||
| """ | ||
| AuthZ isn't universal yet. When dealing with courses, fall back to the old permissions | ||
| system if authz is disabled for a course. Remove this decorator when the old system is | ||
| fully removed and AuthZ is the only way we do checks. | ||
| """ | ||
| def wrapper(function): | ||
| def wrapped(self: StudioPermissionsService, context_key: LearningContextKey): | ||
| if isinstance(context_key, CourseLocator): | ||
| if enable_authz_course_authoring(context_key): | ||
| return function(self, context_key) | ||
| return fallback_function(self._user, context_key) # pylint: disable=protected-access | ||
| return function(self, context_key) | ||
| return wrapped | ||
| return wrapper | ||
|
|
||
|
|
||
| class StudioPermissionsService: | ||
| """ | ||
| Service that can provide information about a user's permissions. | ||
| """ | ||
|
|
||
| def __init__(self, user) -> None: | ||
| self._user = user | ||
|
|
||
| def _check_permission(self, perm_label: COMMON_PERM_KEY, context_key: LearningContextKey) -> bool: | ||
| """ Verify that a user has a specific permission for this context. """ | ||
| return self._user.is_active and is_user_allowed( | ||
| self._user, | ||
| perm_for_key(perm_label, context_key).identifier, | ||
| str(context_key), | ||
| ) | ||
|
|
||
| @_course_fallback_check(has_studio_read_access) | ||
| 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) | ||
|
|
||
| @_course_fallback_check(has_studio_write_access) | ||
| 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) | ||
|
|
||
| @_course_fallback_check(has_studio_read_access) | ||
| 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) | ||
|
|
||
| @_course_fallback_check(has_studio_write_access) | ||
| 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) | ||
|
|
||
| @_course_fallback_check(has_studio_write_access) | ||
| def can_edit_files(self, context_key: LearningContextKey) -> bool: | ||
| """ Can the user edit 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? """ | ||
| 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: LearningContextKey) -> bool: | ||
| """ Does the user have write access to the given course/library? """ | ||
| warnings.warn( | ||
| "can_write is deprecated. " | ||
| "Use a more specific permission, like can_edit_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) | ||
|
|
||
| @_course_fallback_check(has_studio_write_access) | ||
| 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) |
Empty file.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"Hmm, this StudioPermissionsService is marked as Deprecated, and now this PR is un-deprecating it. I wonder who marked it as deprecated and what the replacement is."
(looks at git blame)
Oh.