Skip to content

Commit 31b1e6e

Browse files
[FC-0099] feat: assign library roles after successful library creation (#37532)
1 parent 6b0af90 commit 31b1e6e

File tree

10 files changed

+296
-1
lines changed

10 files changed

+296
-1
lines changed

openedx/core/djangoapps/content_libraries/api/libraries.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
from organizations.models import Organization
7171
from user_tasks.models import UserTaskArtifact, UserTaskStatus
7272
from xblock.core import XBlock
73+
from openedx_authz.api import assign_role_to_user_in_scope
7374

7475
from openedx.core.types import User as UserType
7576

@@ -107,6 +108,7 @@
107108
"publish_changes",
108109
"revert_changes",
109110
"get_backup_task_status",
111+
"assign_library_role_to_user",
110112
]
111113

112114

@@ -155,6 +157,12 @@ class AccessLevel:
155157
NO_ACCESS = None
156158

157159

160+
ACCESS_LEVEL_TO_LIBRARY_ROLE = {
161+
AccessLevel.ADMIN_LEVEL: "library_admin",
162+
AccessLevel.AUTHOR_LEVEL: "library_author",
163+
}
164+
165+
158166
@dataclass(frozen=True)
159167
class ContentLibraryPermissionEntry:
160168
"""
@@ -518,6 +526,30 @@ def set_library_user_permissions(library_key: LibraryLocatorV2, user: UserType,
518526
)
519527

520528

529+
def assign_library_role_to_user(library_key: LibraryLocatorV2, user: UserType, access_level: str):
530+
"""Grant a role to the specified user for this library.
531+
532+
Args:
533+
library_key (LibraryLocatorV2): The key of the content library.
534+
user (UserType): The user to whom the role will be granted.
535+
access_level (str | None): The access level to be granted. This access level maps to a specific role.
536+
537+
Raises:
538+
TypeError: If the user is an instance of AnonymousUser.
539+
"""
540+
if isinstance(user, AnonymousUser):
541+
raise TypeError("Invalid user type")
542+
543+
role = ACCESS_LEVEL_TO_LIBRARY_ROLE.get(access_level)
544+
if role is None:
545+
raise ValueError(f"Invalid access level: {access_level}")
546+
547+
if assign_role_to_user_in_scope(user.username, role, str(library_key)):
548+
log.info(f"Assigned role '{role}' to user '{user.username}' for library '{library_key}'")
549+
else:
550+
log.warning(f"Failed to assign role '{role}' to user '{user.username}' for library '{library_key}'")
551+
552+
521553
def set_library_group_permissions(library_key: LibraryLocatorV2, group, access_level: str):
522554
"""
523555
Change the specified group's level of access to this library.

openedx/core/djangoapps/content_libraries/rest_api/libraries.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,12 @@ def post(self, request):
253253
result = api.create_library(org=org, **data)
254254
# Grant the current user admin permissions on the library:
255255
api.set_library_user_permissions(result.key, request.user, api.AccessLevel.ADMIN_LEVEL)
256+
257+
# Grant the current user the library admin role for this library.
258+
# Other role assignments are handled by openedx-authz and the Console MFE.
259+
# This ensures the creator has access to new libraries. From the library views,
260+
# users can then manage roles for others.
261+
api.assign_library_role_to_user(result.key, request.user, api.AccessLevel.ADMIN_LEVEL)
256262
except api.LibraryAlreadyExists:
257263
raise ValidationError(detail={"slug": "A library with that ID already exists."}) # lint-amnesty, pylint: disable=raise-missing-from
258264

openedx/core/djangoapps/content_libraries/tests/test_api.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@
2828
LIBRARY_COLLECTION_UPDATED,
2929
LIBRARY_CONTAINER_UPDATED,
3030
)
31+
from openedx_authz.api.users import get_user_role_assignments_in_scope
3132
from openedx_learning.api import authoring as authoring_api
3233

34+
from common.djangoapps.student.tests.factories import UserFactory
3335
from .. import api
3436
from ..models import ContentLibrary
3537
from .base import ContentLibrariesRestApiTest
@@ -1479,3 +1481,126 @@ def test_get_backup_task_status_failed(self) -> None:
14791481
assert status is not None
14801482
assert status['state'] == UserTaskStatus.FAILED
14811483
assert status['file'] is None
1484+
1485+
1486+
class ContentLibraryAuthZRoleAssignmentTest(ContentLibrariesRestApiTest):
1487+
"""
1488+
Tests for Content Library role assignment via the AuthZ Authorization Framework.
1489+
1490+
These tests verify that library roles are correctly assigned to users through
1491+
the openedx-authz (AuthZ) Authorization Framework when libraries are created or when
1492+
explicit role assignments are made.
1493+
1494+
See: https://github.com/openedx/openedx-authz/
1495+
"""
1496+
1497+
def setUp(self) -> None:
1498+
super().setUp()
1499+
1500+
# Create Content Libraries
1501+
self._create_library("test-lib-role-1", "Test Library Role 1")
1502+
1503+
# Fetch the created ContentLibrary objects so we can access their learning_package.id
1504+
self.lib1 = ContentLibrary.objects.get(slug="test-lib-role-1")
1505+
1506+
def test_assign_library_admin_role_to_user_via_authz(self) -> None:
1507+
"""
1508+
Test assigning a library admin role to a user via the AuthZ Authorization Framework.
1509+
1510+
This test verifies that the openedx-authz Authorization Framework correctly
1511+
assigns the library_admin role to a user when explicitly called.
1512+
"""
1513+
api.assign_library_role_to_user(self.lib1.library_key, self.user, api.AccessLevel.ADMIN_LEVEL)
1514+
1515+
roles = get_user_role_assignments_in_scope(self.user.username, str(self.lib1.library_key))
1516+
assert len(roles) == 1
1517+
assert "library_admin" in repr(roles[0].roles[0])
1518+
1519+
def test_assign_library_author_role_to_user_via_authz(self) -> None:
1520+
"""
1521+
Test assigning a library author role to a user via the AuthZ Authorization Framework.
1522+
1523+
This test verifies that the openedx-authz Authorization Framework correctly
1524+
assigns the library_author role to a user when explicitly called.
1525+
"""
1526+
# Create a new user to avoid conflicts with roles assigned during library creation
1527+
author_user = UserFactory.create(username="Author", email="[email protected]")
1528+
1529+
api.assign_library_role_to_user(self.lib1.library_key, author_user, api.AccessLevel.AUTHOR_LEVEL)
1530+
1531+
roles = get_user_role_assignments_in_scope(author_user.username, str(self.lib1.library_key))
1532+
assert len(roles) == 1
1533+
assert "library_author" in repr(roles[0].roles[0])
1534+
1535+
@mock.patch("openedx.core.djangoapps.content_libraries.api.libraries.assign_role_to_user_in_scope")
1536+
def test_library_creation_assigns_admin_role_via_authz(
1537+
self,
1538+
mock_assign_role
1539+
) -> None:
1540+
"""
1541+
Test that creating a library via REST API assigns admin role via AuthZ.
1542+
1543+
This test verifies that when a library is created via the REST API,
1544+
the creator is automatically assigned the library_admin role through
1545+
the openedx-authz Authorization Framework.
1546+
"""
1547+
mock_assign_role.return_value = True
1548+
1549+
# Create a new library (this should trigger role assignment in the REST API)
1550+
self._create_library("test-lib-role-2", "Test Library Role 2")
1551+
1552+
# Verify that assign_role_to_user_in_scope was called
1553+
mock_assign_role.assert_called_once()
1554+
call_args = mock_assign_role.call_args
1555+
assert call_args[0][0] == self.user.username # username
1556+
assert call_args[0][1] == "library_admin" # role
1557+
assert "test-lib-role-2" in call_args[0][2] # library_key (contains slug)
1558+
1559+
@mock.patch("openedx.core.djangoapps.content_libraries.api.libraries.assign_role_to_user_in_scope")
1560+
def test_library_creation_handles_authz_failure_gracefully(
1561+
self,
1562+
mock_assign_role
1563+
) -> None:
1564+
"""
1565+
Test that library creation succeeds even if AuthZ role assignment fails.
1566+
1567+
This test verifies that if the openedx-authz Authorization Framework fails to assign
1568+
a role (returns False), the library creation still succeeds. This ensures that
1569+
the system degrades gracefully and doesn't break library creation if there are
1570+
issues with the Authorization Framework.
1571+
"""
1572+
# Simulate openedx-authz failing to assign the role
1573+
mock_assign_role.return_value = False
1574+
1575+
# Library creation should still succeed
1576+
result = self._create_library("test-lib-role-3", "Test Library Role 3")
1577+
assert result is not None
1578+
assert result["slug"] == "test-lib-role-3"
1579+
1580+
# Verify that the library was created successfully
1581+
lib3 = ContentLibrary.objects.get(slug="test-lib-role-3")
1582+
assert lib3 is not None
1583+
assert lib3.slug == "test-lib-role-3"
1584+
1585+
@mock.patch("openedx.core.djangoapps.content_libraries.api.libraries.assign_role_to_user_in_scope")
1586+
def test_library_creation_handles_authz_exception(
1587+
self,
1588+
mock_assign_role
1589+
) -> None:
1590+
"""
1591+
Test that library creation succeeds even if AuthZ raises an exception.
1592+
1593+
This test verifies that if the openedx-authz Authorization Framework raises an
1594+
exception during role assignment, the library creation still succeeds. This ensures
1595+
robust error handling when the Authorization Framework is unavailable or misconfigured.
1596+
"""
1597+
# Simulate openedx-authz raising an exception for unknown issues
1598+
mock_assign_role.side_effect = Exception("AuthZ unavailable")
1599+
1600+
# Library creation should still succeed (the exception should be caught/handled)
1601+
# Note: Currently, the code doesn't catch this exception, so we expect it to propagate.
1602+
# This test documents the current behavior and can be updated if error handling is added.
1603+
with self.assertRaises(Exception) as context:
1604+
self._create_library("test-lib-role-4", "Test Library Role 4")
1605+
1606+
assert "AuthZ unavailable" in str(context.exception)

requirements/common_constraints.txt

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

requirements/edx/base.txt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ attrs==25.4.0
4040
# edx-ace
4141
# jsonschema
4242
# lti-consumer-xblock
43+
# openedx-authz
4344
# openedx-events
4445
# openedx-learning
4546
# referencing
@@ -81,6 +82,8 @@ botocore==1.40.57
8182
# boto3
8283
# s3transfer
8384
# snowflake-connector-python
85+
bracex==2.6
86+
# via wcmatch
8487
bridgekeeper==0.9
8588
# via -r requirements/edx/kernel.in
8689
cachecontrol==0.14.3
@@ -91,6 +94,8 @@ cachetools==6.2.1
9194
# google-auth
9295
camel-converter[pydantic]==5.0.0
9396
# via meilisearch
97+
casbin-django-orm-adapter==1.7.0
98+
# via openedx-authz
9499
celery==5.5.3
95100
# via
96101
# -c requirements/constraints.txt
@@ -169,6 +174,7 @@ django==5.2.7
169174
# via
170175
# -c requirements/constraints.txt
171176
# -r requirements/edx/kernel.in
177+
# casbin-django-orm-adapter
172178
# django-appconf
173179
# django-autocomplete-light
174180
# django-celery-results
@@ -228,6 +234,7 @@ django==5.2.7
228234
# help-tokens
229235
# jsonfield
230236
# lti-consumer-xblock
237+
# openedx-authz
231238
# openedx-django-pyfs
232239
# openedx-django-wiki
233240
# openedx-events
@@ -388,6 +395,7 @@ djangorestframework==3.16.1
388395
# edx-organizations
389396
# edx-proctoring
390397
# edx-submissions
398+
# openedx-authz
391399
# openedx-forum
392400
# openedx-learning
393401
# ora2
@@ -412,6 +420,7 @@ edx-api-doc-tools==2.1.0
412420
# via
413421
# -r requirements/edx/kernel.in
414422
# edx-name-affirmation
423+
# openedx-authz
415424
edx-auth-backends==4.6.2
416425
# via -r requirements/edx/kernel.in
417426
edx-bulk-grades==1.2.0
@@ -470,6 +479,7 @@ edx-drf-extensions==10.6.0
470479
# edx-when
471480
# edxval
472481
# enterprise-integrated-channels
482+
# openedx-authz
473483
# openedx-learning
474484
edx-enterprise==6.5.1
475485
# via
@@ -502,6 +512,7 @@ edx-opaque-keys[django]==3.0.0
502512
# edx-when
503513
# enterprise-integrated-channels
504514
# lti-consumer-xblock
515+
# openedx-authz
505516
# openedx-events
506517
# openedx-filters
507518
# ora2
@@ -812,7 +823,10 @@ openedx-atlas==0.7.0
812823
# via
813824
# -r requirements/edx/kernel.in
814825
# enterprise-integrated-channels
826+
# openedx-authz
815827
# openedx-forum
828+
openedx-authz==0.11.1
829+
# via -r requirements/edx/kernel.in
816830
openedx-calc==4.0.2
817831
# via -r requirements/edx/kernel.in
818832
openedx-django-pyfs==3.8.0
@@ -909,6 +923,10 @@ pyasn1==0.6.1
909923
# rsa
910924
pyasn1-modules==0.4.2
911925
# via google-auth
926+
pycasbin==2.4.0
927+
# via
928+
# casbin-django-orm-adapter
929+
# openedx-authz
912930
pycountry==24.6.1
913931
# via -r requirements/edx/kernel.in
914932
pycparser==2.23
@@ -1085,6 +1103,8 @@ semantic-version==2.10.0
10851103
# via edx-drf-extensions
10861104
shapely==2.1.2
10871105
# via -r requirements/edx/kernel.in
1106+
simpleeval==1.0.3
1107+
# via pycasbin
10881108
simplejson==3.20.2
10891109
# via
10901110
# -r requirements/edx/kernel.in
@@ -1216,6 +1236,8 @@ voluptuous==0.15.2
12161236
# via ora2
12171237
walrus==0.9.5
12181238
# via edx-event-bus-redis
1239+
wcmatch==10.1
1240+
# via pycasbin
12191241
wcwidth==0.2.14
12201242
# via prompt-toolkit
12211243
web-fragments==3.1.0

0 commit comments

Comments
 (0)