[FC-0099] feat: assign library roles after successful library creation (#37532)

This commit is contained in:
Maria Grimaldi (Majo)
2025-10-29 20:29:02 +01:00
committed by GitHub
parent 6b0af90664
commit 31b1e6ecc4
10 changed files with 296 additions and 1 deletions

View File

@@ -70,6 +70,7 @@ from openedx_learning.api.authoring_models import Component, LearningPackage
from organizations.models import Organization
from user_tasks.models import UserTaskArtifact, UserTaskStatus
from xblock.core import XBlock
from openedx_authz.api import assign_role_to_user_in_scope
from openedx.core.types import User as UserType
@@ -107,6 +108,7 @@ __all__ = [
"publish_changes",
"revert_changes",
"get_backup_task_status",
"assign_library_role_to_user",
]
@@ -155,6 +157,12 @@ class AccessLevel:
NO_ACCESS = None
ACCESS_LEVEL_TO_LIBRARY_ROLE = {
AccessLevel.ADMIN_LEVEL: "library_admin",
AccessLevel.AUTHOR_LEVEL: "library_author",
}
@dataclass(frozen=True)
class ContentLibraryPermissionEntry:
"""
@@ -518,6 +526,30 @@ def set_library_user_permissions(library_key: LibraryLocatorV2, user: UserType,
)
def assign_library_role_to_user(library_key: LibraryLocatorV2, user: UserType, access_level: str):
"""Grant a role to the specified user for this library.
Args:
library_key (LibraryLocatorV2): The key of the content library.
user (UserType): The user to whom the role will be granted.
access_level (str | None): The access level to be granted. This access level maps to a specific role.
Raises:
TypeError: If the user is an instance of AnonymousUser.
"""
if isinstance(user, AnonymousUser):
raise TypeError("Invalid user type")
role = ACCESS_LEVEL_TO_LIBRARY_ROLE.get(access_level)
if role is None:
raise ValueError(f"Invalid access level: {access_level}")
if assign_role_to_user_in_scope(user.username, role, str(library_key)):
log.info(f"Assigned role '{role}' to user '{user.username}' for library '{library_key}'")
else:
log.warning(f"Failed to assign role '{role}' to user '{user.username}' for library '{library_key}'")
def set_library_group_permissions(library_key: LibraryLocatorV2, group, access_level: str):
"""
Change the specified group's level of access to this library.

View File

@@ -253,6 +253,12 @@ class LibraryRootView(GenericAPIView):
result = api.create_library(org=org, **data)
# Grant the current user admin permissions on the library:
api.set_library_user_permissions(result.key, request.user, api.AccessLevel.ADMIN_LEVEL)
# Grant the current user the library admin role for this library.
# Other role assignments are handled by openedx-authz and the Console MFE.
# This ensures the creator has access to new libraries. From the library views,
# users can then manage roles for others.
api.assign_library_role_to_user(result.key, request.user, api.AccessLevel.ADMIN_LEVEL)
except api.LibraryAlreadyExists:
raise ValidationError(detail={"slug": "A library with that ID already exists."}) # lint-amnesty, pylint: disable=raise-missing-from

View File

@@ -28,8 +28,10 @@ from openedx_events.content_authoring.signals import (
LIBRARY_COLLECTION_UPDATED,
LIBRARY_CONTAINER_UPDATED,
)
from openedx_authz.api.users import get_user_role_assignments_in_scope
from openedx_learning.api import authoring as authoring_api
from common.djangoapps.student.tests.factories import UserFactory
from .. import api
from ..models import ContentLibrary
from .base import ContentLibrariesRestApiTest
@@ -1479,3 +1481,126 @@ class ContentLibraryExportTest(ContentLibrariesRestApiTest):
assert status is not None
assert status['state'] == UserTaskStatus.FAILED
assert status['file'] is None
class ContentLibraryAuthZRoleAssignmentTest(ContentLibrariesRestApiTest):
"""
Tests for Content Library role assignment via the AuthZ Authorization Framework.
These tests verify that library roles are correctly assigned to users through
the openedx-authz (AuthZ) Authorization Framework when libraries are created or when
explicit role assignments are made.
See: https://github.com/openedx/openedx-authz/
"""
def setUp(self) -> None:
super().setUp()
# Create Content Libraries
self._create_library("test-lib-role-1", "Test Library Role 1")
# Fetch the created ContentLibrary objects so we can access their learning_package.id
self.lib1 = ContentLibrary.objects.get(slug="test-lib-role-1")
def test_assign_library_admin_role_to_user_via_authz(self) -> None:
"""
Test assigning a library admin role to a user via the AuthZ Authorization Framework.
This test verifies that the openedx-authz Authorization Framework correctly
assigns the library_admin role to a user when explicitly called.
"""
api.assign_library_role_to_user(self.lib1.library_key, self.user, api.AccessLevel.ADMIN_LEVEL)
roles = get_user_role_assignments_in_scope(self.user.username, str(self.lib1.library_key))
assert len(roles) == 1
assert "library_admin" in repr(roles[0].roles[0])
def test_assign_library_author_role_to_user_via_authz(self) -> None:
"""
Test assigning a library author role to a user via the AuthZ Authorization Framework.
This test verifies that the openedx-authz Authorization Framework correctly
assigns the library_author role to a user when explicitly called.
"""
# Create a new user to avoid conflicts with roles assigned during library creation
author_user = UserFactory.create(username="Author", email="author@example.com")
api.assign_library_role_to_user(self.lib1.library_key, author_user, api.AccessLevel.AUTHOR_LEVEL)
roles = get_user_role_assignments_in_scope(author_user.username, str(self.lib1.library_key))
assert len(roles) == 1
assert "library_author" in repr(roles[0].roles[0])
@mock.patch("openedx.core.djangoapps.content_libraries.api.libraries.assign_role_to_user_in_scope")
def test_library_creation_assigns_admin_role_via_authz(
self,
mock_assign_role
) -> None:
"""
Test that creating a library via REST API assigns admin role via AuthZ.
This test verifies that when a library is created via the REST API,
the creator is automatically assigned the library_admin role through
the openedx-authz Authorization Framework.
"""
mock_assign_role.return_value = True
# Create a new library (this should trigger role assignment in the REST API)
self._create_library("test-lib-role-2", "Test Library Role 2")
# Verify that assign_role_to_user_in_scope was called
mock_assign_role.assert_called_once()
call_args = mock_assign_role.call_args
assert call_args[0][0] == self.user.username # username
assert call_args[0][1] == "library_admin" # role
assert "test-lib-role-2" in call_args[0][2] # library_key (contains slug)
@mock.patch("openedx.core.djangoapps.content_libraries.api.libraries.assign_role_to_user_in_scope")
def test_library_creation_handles_authz_failure_gracefully(
self,
mock_assign_role
) -> None:
"""
Test that library creation succeeds even if AuthZ role assignment fails.
This test verifies that if the openedx-authz Authorization Framework fails to assign
a role (returns False), the library creation still succeeds. This ensures that
the system degrades gracefully and doesn't break library creation if there are
issues with the Authorization Framework.
"""
# Simulate openedx-authz failing to assign the role
mock_assign_role.return_value = False
# Library creation should still succeed
result = self._create_library("test-lib-role-3", "Test Library Role 3")
assert result is not None
assert result["slug"] == "test-lib-role-3"
# Verify that the library was created successfully
lib3 = ContentLibrary.objects.get(slug="test-lib-role-3")
assert lib3 is not None
assert lib3.slug == "test-lib-role-3"
@mock.patch("openedx.core.djangoapps.content_libraries.api.libraries.assign_role_to_user_in_scope")
def test_library_creation_handles_authz_exception(
self,
mock_assign_role
) -> None:
"""
Test that library creation succeeds even if AuthZ raises an exception.
This test verifies that if the openedx-authz Authorization Framework raises an
exception during role assignment, the library creation still succeeds. This ensures
robust error handling when the Authorization Framework is unavailable or misconfigured.
"""
# Simulate openedx-authz raising an exception for unknown issues
mock_assign_role.side_effect = Exception("AuthZ unavailable")
# Library creation should still succeed (the exception should be caught/handled)
# Note: Currently, the code doesn't catch this exception, so we expect it to propagate.
# This test documents the current behavior and can be updated if error handling is added.
with self.assertRaises(Exception) as context:
self._create_library("test-lib-role-4", "Test Library Role 4")
assert "AuthZ unavailable" in str(context.exception)

View File

@@ -22,3 +22,10 @@
# elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html
# See https://github.com/openedx/edx-platform/issues/35126 for more info
elasticsearch<7.14.0
# pip 25.3 is incompatible with pip-tools hence causing failures during the build process
# Make upgrade command and all requirements upgrade jobs are broken due to this.
# See issue https://github.com/openedx/public-engineering/issues/440 for details regarding the ongoing fix.
# The constraint can be removed once a release (pip-tools > 7.5.1) is available with support for pip 25.3
# Issue to track this dependency and unpin later on: https://github.com/openedx/edx-lint/issues/503
pip<25.3

View File

@@ -40,6 +40,7 @@ attrs==25.4.0
# edx-ace
# jsonschema
# lti-consumer-xblock
# openedx-authz
# openedx-events
# openedx-learning
# referencing
@@ -81,6 +82,8 @@ botocore==1.40.57
# boto3
# s3transfer
# snowflake-connector-python
bracex==2.6
# via wcmatch
bridgekeeper==0.9
# via -r requirements/edx/kernel.in
cachecontrol==0.14.3
@@ -91,6 +94,8 @@ cachetools==6.2.1
# google-auth
camel-converter[pydantic]==5.0.0
# via meilisearch
casbin-django-orm-adapter==1.7.0
# via openedx-authz
celery==5.5.3
# via
# -c requirements/constraints.txt
@@ -169,6 +174,7 @@ django==5.2.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/kernel.in
# casbin-django-orm-adapter
# django-appconf
# django-autocomplete-light
# django-celery-results
@@ -228,6 +234,7 @@ django==5.2.7
# help-tokens
# jsonfield
# lti-consumer-xblock
# openedx-authz
# openedx-django-pyfs
# openedx-django-wiki
# openedx-events
@@ -388,6 +395,7 @@ djangorestframework==3.16.1
# edx-organizations
# edx-proctoring
# edx-submissions
# openedx-authz
# openedx-forum
# openedx-learning
# ora2
@@ -412,6 +420,7 @@ edx-api-doc-tools==2.1.0
# via
# -r requirements/edx/kernel.in
# edx-name-affirmation
# openedx-authz
edx-auth-backends==4.6.2
# via -r requirements/edx/kernel.in
edx-bulk-grades==1.2.0
@@ -470,6 +479,7 @@ edx-drf-extensions==10.6.0
# edx-when
# edxval
# enterprise-integrated-channels
# openedx-authz
# openedx-learning
edx-enterprise==6.5.1
# via
@@ -502,6 +512,7 @@ edx-opaque-keys[django]==3.0.0
# edx-when
# enterprise-integrated-channels
# lti-consumer-xblock
# openedx-authz
# openedx-events
# openedx-filters
# ora2
@@ -812,7 +823,10 @@ openedx-atlas==0.7.0
# via
# -r requirements/edx/kernel.in
# enterprise-integrated-channels
# openedx-authz
# openedx-forum
openedx-authz==0.11.1
# via -r requirements/edx/kernel.in
openedx-calc==4.0.2
# via -r requirements/edx/kernel.in
openedx-django-pyfs==3.8.0
@@ -909,6 +923,10 @@ pyasn1==0.6.1
# rsa
pyasn1-modules==0.4.2
# via google-auth
pycasbin==2.4.0
# via
# casbin-django-orm-adapter
# openedx-authz
pycountry==24.6.1
# via -r requirements/edx/kernel.in
pycparser==2.23
@@ -1085,6 +1103,8 @@ semantic-version==2.10.0
# via edx-drf-extensions
shapely==2.1.2
# via -r requirements/edx/kernel.in
simpleeval==1.0.3
# via pycasbin
simplejson==3.20.2
# via
# -r requirements/edx/kernel.in
@@ -1216,6 +1236,8 @@ voluptuous==0.15.2
# via ora2
walrus==0.9.5
# via edx-event-bus-redis
wcmatch==10.1
# via pycasbin
wcwidth==0.2.14
# via prompt-toolkit
web-fragments==3.1.0

View File

@@ -89,6 +89,7 @@ attrs==25.4.0
# edx-ace
# jsonschema
# lti-consumer-xblock
# openedx-authz
# openedx-events
# openedx-learning
# referencing
@@ -151,6 +152,11 @@ botocore==1.40.57
# boto3
# s3transfer
# snowflake-connector-python
bracex==2.6
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# wcmatch
bridgekeeper==0.9
# via
# -r requirements/edx/doc.txt
@@ -176,6 +182,11 @@ camel-converter[pydantic]==5.0.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# meilisearch
casbin-django-orm-adapter==1.7.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# openedx-authz
celery==5.5.3
# via
# -c requirements/constraints.txt
@@ -333,6 +344,7 @@ django==5.2.7
# -c requirements/constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# casbin-django-orm-adapter
# django-appconf
# django-autocomplete-light
# django-celery-results
@@ -395,6 +407,7 @@ django==5.2.7
# help-tokens
# jsonfield
# lti-consumer-xblock
# openedx-authz
# openedx-django-pyfs
# openedx-django-wiki
# openedx-events
@@ -621,6 +634,7 @@ djangorestframework==3.16.1
# edx-organizations
# edx-proctoring
# edx-submissions
# openedx-authz
# openedx-forum
# openedx-learning
# ora2
@@ -671,6 +685,7 @@ edx-api-doc-tools==2.1.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-name-affirmation
# openedx-authz
edx-auth-backends==4.6.2
# via
# -r requirements/edx/doc.txt
@@ -743,6 +758,7 @@ edx-drf-extensions==10.6.0
# edx-when
# edxval
# enterprise-integrated-channels
# openedx-authz
# openedx-learning
edx-enterprise==6.5.1
# via
@@ -788,6 +804,7 @@ edx-opaque-keys[django]==3.0.0
# edx-when
# enterprise-integrated-channels
# lti-consumer-xblock
# openedx-authz
# openedx-events
# openedx-filters
# ora2
@@ -1352,7 +1369,12 @@ openedx-atlas==0.7.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# enterprise-integrated-channels
# openedx-authz
# openedx-forum
openedx-authz==0.11.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
openedx-calc==4.0.2
# via
# -r requirements/edx/doc.txt
@@ -1534,6 +1556,12 @@ pyasn1-modules==0.4.2
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# google-auth
pycasbin==2.4.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# casbin-django-orm-adapter
# openedx-authz
pycodestyle==2.8.0
# via
# -c requirements/constraints.txt
@@ -1885,6 +1913,11 @@ shapely==2.1.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
simpleeval==1.0.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# pycasbin
simplejson==3.20.2
# via
# -r requirements/edx/doc.txt
@@ -2190,6 +2223,11 @@ walrus==0.9.5
# edx-event-bus-redis
watchdog==6.0.0
# via -r requirements/edx/development.in
wcmatch==10.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# pycasbin
wcwidth==0.2.14
# via
# -r requirements/edx/doc.txt

View File

@@ -64,6 +64,7 @@ attrs==25.4.0
# edx-ace
# jsonschema
# lti-consumer-xblock
# openedx-authz
# openedx-events
# openedx-learning
# referencing
@@ -116,6 +117,10 @@ botocore==1.40.57
# boto3
# s3transfer
# snowflake-connector-python
bracex==2.6
# via
# -r requirements/edx/base.txt
# wcmatch
bridgekeeper==0.9
# via -r requirements/edx/base.txt
cachecontrol==0.14.3
@@ -131,6 +136,10 @@ camel-converter[pydantic]==5.0.0
# via
# -r requirements/edx/base.txt
# meilisearch
casbin-django-orm-adapter==1.7.0
# via
# -r requirements/edx/base.txt
# openedx-authz
celery==5.5.3
# via
# -c requirements/constraints.txt
@@ -227,6 +236,7 @@ django==5.2.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
# casbin-django-orm-adapter
# django-appconf
# django-autocomplete-light
# django-celery-results
@@ -286,6 +296,7 @@ django==5.2.7
# help-tokens
# jsonfield
# lti-consumer-xblock
# openedx-authz
# openedx-django-pyfs
# openedx-django-wiki
# openedx-events
@@ -460,6 +471,7 @@ djangorestframework==3.16.1
# edx-organizations
# edx-proctoring
# edx-submissions
# openedx-authz
# openedx-forum
# openedx-learning
# ora2
@@ -496,6 +508,7 @@ edx-api-doc-tools==2.1.0
# via
# -r requirements/edx/base.txt
# edx-name-affirmation
# openedx-authz
edx-auth-backends==4.6.2
# via -r requirements/edx/base.txt
edx-bulk-grades==1.2.0
@@ -554,6 +567,7 @@ edx-drf-extensions==10.6.0
# edx-when
# edxval
# enterprise-integrated-channels
# openedx-authz
# openedx-learning
edx-enterprise==6.5.1
# via
@@ -586,6 +600,7 @@ edx-opaque-keys[django]==3.0.0
# edx-when
# enterprise-integrated-channels
# lti-consumer-xblock
# openedx-authz
# openedx-events
# openedx-filters
# ora2
@@ -985,7 +1000,10 @@ openedx-atlas==0.7.0
# via
# -r requirements/edx/base.txt
# enterprise-integrated-channels
# openedx-authz
# openedx-forum
openedx-authz==0.11.1
# via -r requirements/edx/base.txt
openedx-calc==4.0.2
# via -r requirements/edx/base.txt
openedx-django-pyfs==3.8.0
@@ -1105,6 +1123,11 @@ pyasn1-modules==0.4.2
# via
# -r requirements/edx/base.txt
# google-auth
pycasbin==2.4.0
# via
# -r requirements/edx/base.txt
# casbin-django-orm-adapter
# openedx-authz
pycountry==24.6.1
# via -r requirements/edx/base.txt
pycparser==2.23
@@ -1329,6 +1352,10 @@ semantic-version==2.10.0
# edx-drf-extensions
shapely==2.1.2
# via -r requirements/edx/base.txt
simpleeval==1.0.3
# via
# -r requirements/edx/base.txt
# pycasbin
simplejson==3.20.2
# via
# -r requirements/edx/base.txt
@@ -1537,6 +1564,10 @@ walrus==0.9.5
# via
# -r requirements/edx/base.txt
# edx-event-bus-redis
wcmatch==10.1
# via
# -r requirements/edx/base.txt
# pycasbin
wcwidth==0.2.14
# via
# -r requirements/edx/base.txt

View File

@@ -161,3 +161,4 @@ wrapt # Better functools.wrapped. TODO: functools
XBlock[django] # Courseware component architecture
xss-utils # https://github.com/openedx/edx-platform/pull/20633 Fix XSS via Translations
unicodeit # Converts mathjax equation to plain text by using unicode symbols
openedx-authz # Authorization Framework for the Open edX Ecosystem

View File

@@ -63,6 +63,7 @@ attrs==25.4.0
# edx-ace
# jsonschema
# lti-consumer-xblock
# openedx-authz
# openedx-events
# openedx-learning
# referencing
@@ -113,6 +114,10 @@ botocore==1.40.57
# boto3
# s3transfer
# snowflake-connector-python
bracex==2.6
# via
# -r requirements/edx/base.txt
# wcmatch
bridgekeeper==0.9
# via -r requirements/edx/base.txt
cachecontrol==0.14.3
@@ -129,6 +134,10 @@ camel-converter[pydantic]==5.0.0
# via
# -r requirements/edx/base.txt
# meilisearch
casbin-django-orm-adapter==1.7.0
# via
# -r requirements/edx/base.txt
# openedx-authz
celery==5.5.3
# via
# -c requirements/constraints.txt
@@ -252,6 +261,7 @@ django==5.2.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
# casbin-django-orm-adapter
# django-appconf
# django-autocomplete-light
# django-celery-results
@@ -311,6 +321,7 @@ django==5.2.7
# help-tokens
# jsonfield
# lti-consumer-xblock
# openedx-authz
# openedx-django-pyfs
# openedx-django-wiki
# openedx-events
@@ -485,6 +496,7 @@ djangorestframework==3.16.1
# edx-organizations
# edx-proctoring
# edx-submissions
# openedx-authz
# openedx-forum
# openedx-learning
# ora2
@@ -516,6 +528,7 @@ edx-api-doc-tools==2.1.0
# via
# -r requirements/edx/base.txt
# edx-name-affirmation
# openedx-authz
edx-auth-backends==4.6.2
# via -r requirements/edx/base.txt
edx-bulk-grades==1.2.0
@@ -574,6 +587,7 @@ edx-drf-extensions==10.6.0
# edx-when
# edxval
# enterprise-integrated-channels
# openedx-authz
# openedx-learning
edx-enterprise==6.5.1
# via
@@ -608,6 +622,7 @@ edx-opaque-keys[django]==3.0.0
# edx-when
# enterprise-integrated-channels
# lti-consumer-xblock
# openedx-authz
# openedx-events
# openedx-filters
# ora2
@@ -1029,7 +1044,10 @@ openedx-atlas==0.7.0
# via
# -r requirements/edx/base.txt
# enterprise-integrated-channels
# openedx-authz
# openedx-forum
openedx-authz==0.11.1
# via -r requirements/edx/base.txt
openedx-calc==4.0.2
# via -r requirements/edx/base.txt
openedx-django-pyfs==3.8.0
@@ -1167,6 +1185,11 @@ pyasn1-modules==0.4.2
# via
# -r requirements/edx/base.txt
# google-auth
pycasbin==2.4.0
# via
# -r requirements/edx/base.txt
# casbin-django-orm-adapter
# openedx-authz
pycodestyle==2.8.0
# via
# -c requirements/constraints.txt
@@ -1437,6 +1460,10 @@ semantic-version==2.10.0
# edx-drf-extensions
shapely==2.1.2
# via -r requirements/edx/base.txt
simpleeval==1.0.3
# via
# -r requirements/edx/base.txt
# pycasbin
simplejson==3.20.2
# via
# -r requirements/edx/base.txt
@@ -1621,6 +1648,10 @@ walrus==0.9.5
# via
# -r requirements/edx/base.txt
# edx-event-bus-redis
wcmatch==10.1
# via
# -r requirements/edx/base.txt
# pycasbin
wcwidth==0.2.14
# via
# -r requirements/edx/base.txt

View File

@@ -9,6 +9,8 @@ wheel==0.45.1
# The following packages are considered to be unsafe in a requirements file:
pip==25.2
# via -r requirements/pip.in
# via
# -c requirements/common_constraints.txt
# -r requirements/pip.in
setuptools==80.9.0
# via -r requirements/pip.in