Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions openedx/core/djangoapps/content_libraries/api/libraries.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
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

Expand Down Expand Up @@ -102,6 +103,7 @@
"publish_changes",
"revert_changes",
"get_backup_task_status",
"assign_library_role_to_user",
]


Expand Down Expand Up @@ -150,6 +152,12 @@ class AccessLevel:
NO_ACCESS = None


ACCESS_LEVEL_TO_LIBRARY_ROLE = {
Copy link
Member Author

@mariajgrimaldi mariajgrimaldi Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AccessLevel.ADMIN_LEVEL: "library_admin",
AccessLevel.AUTHOR_LEVEL: "library_author",
}


@dataclass(frozen=True)
class ContentLibraryPermissionEntry:
"""
Expand Down Expand Up @@ -512,6 +520,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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ def post(self, request):
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

Expand Down
125 changes: 125 additions & 0 deletions openedx/core/djangoapps/content_libraries/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@
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
Expand Down Expand Up @@ -1479,3 +1481,126 @@ def test_get_backup_task_status_failed(self) -> None:
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)
7 changes: 7 additions & 0 deletions requirements/common_constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions requirements/edx/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ attrs==25.4.0
# edx-ace
# jsonschema
# lti-consumer-xblock
# openedx-authz
# openedx-events
# openedx-learning
# referencing
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -170,6 +175,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
Expand Down Expand Up @@ -229,6 +235,7 @@ django==5.2.7
# help-tokens
# jsonfield
# lti-consumer-xblock
# openedx-authz
# openedx-django-pyfs
# openedx-django-wiki
# openedx-events
Expand Down Expand Up @@ -389,6 +396,7 @@ djangorestframework==3.16.1
# edx-organizations
# edx-proctoring
# edx-submissions
# openedx-authz
# openedx-forum
# openedx-learning
# ora2
Expand All @@ -413,6 +421,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
Expand Down Expand Up @@ -471,6 +480,7 @@ edx-drf-extensions==10.6.0
# edx-when
# edxval
# enterprise-integrated-channels
# openedx-authz
# openedx-learning
edx-enterprise==6.5.1
# via
Expand Down Expand Up @@ -503,6 +513,7 @@ edx-opaque-keys[django]==3.0.0
# edx-when
# enterprise-integrated-channels
# lti-consumer-xblock
# openedx-authz
# openedx-events
# openedx-filters
# ora2
Expand Down Expand Up @@ -813,7 +824,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
Expand Down Expand Up @@ -910,6 +924,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
Expand Down Expand Up @@ -1086,6 +1104,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
Expand Down Expand Up @@ -1218,6 +1238,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
Expand Down
Loading
Loading