Skip to content

Commit 0041ac3

Browse files
BryanttVclaude
andauthored
feat: add platform-level glob scope for content libraries (#333)
* feat: add PlatformContentLibraryGlobData scope and related tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: run make format * chore: bump version to 1.18.0 * test: enhance library role assignment tests --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ffb1122 commit 0041ac3

9 files changed

Lines changed: 344 additions & 21 deletions

File tree

CHANGELOG.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
1.18.0 - 2026-06-09
18+
*******************
19+
20+
Added
21+
=====
22+
23+
* Add platform glob scope for content libraries.
24+
1725
1.17.1 - 2026-06-05
1826
*******************
1927

openedx_authz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import os
66

7-
__version__ = "1.17.1"
7+
__version__ = "1.18.0"
88

99
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

openedx_authz/api/data.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"OrgGlobData",
3838
"OrgContentLibraryGlobData",
3939
"PermissionData",
40+
"PlatformContentLibraryGlobData",
4041
"PlatformCourseOverviewGlobData",
4142
"PlatformGlobData",
4243
"PolicyIndex",
@@ -414,7 +415,7 @@ def get_all_platform_glob_namespaces(mcs) -> dict[str, Type["ScopeData"]]:
414415
415416
Examples:
416417
>>> ScopeMeta.get_all_platform_glob_namespaces()
417-
{'course-v1': PlatformCourseOverviewGlobData}
418+
{'course-v1': PlatformCourseOverviewGlobData, 'lib': PlatformContentLibraryGlobData}
418419
"""
419420
return mcs.platform_glob_registry
420421

@@ -428,7 +429,13 @@ def get_all_registered_scopes(mcs) -> list[Type["ScopeData"]]:
428429
429430
Examples:
430431
>>> ScopeMeta.get_all_registered_scopes()
431-
[ScopeData, ContentLibraryData, OrgCourseOverviewGlobData, PlatformCourseOverviewGlobData]
432+
[
433+
ScopeData,
434+
ContentLibraryData,
435+
OrgCourseOverviewGlobData,
436+
PlatformContentLibraryGlobData,
437+
PlatformCourseOverviewGlobData,
438+
]
432439
"""
433440
return list(
434441
{
@@ -1227,6 +1234,60 @@ def get_admin_manage_permission(cls) -> PermissionData:
12271234
return COURSES_MANAGE_COURSE_TEAM
12281235

12291236

1237+
@define
1238+
class PlatformContentLibraryGlobData(PlatformGlobData):
1239+
"""Platform-level glob pattern for content libraries.
1240+
1241+
This class represents glob patterns that match all content libraries in the platform.
1242+
Format: ``lib:*``
1243+
1244+
The glob pattern allows granting permissions to all libraries across the entire
1245+
platform without needing to specify organizations or individual libraries.
1246+
1247+
Attributes:
1248+
NAMESPACE (str): 'lib' for content library scopes.
1249+
IS_PLATFORM_GLOB (bool): True for scope data that represents a platform-level glob pattern.
1250+
external_key (str): The glob pattern (always ``lib:*``).
1251+
namespaced_key (str): The pattern with namespace (``lib^lib:*``).
1252+
1253+
Validation Rules:
1254+
- Must be exactly ``lib:*``
1255+
- Applies to all existing and future libraries in the platform
1256+
- Does not grant access to other resource types
1257+
1258+
Examples:
1259+
>>> glob = PlatformContentLibraryGlobData(external_key='lib:*')
1260+
>>> glob.exists()
1261+
True
1262+
>>> glob.namespaced_key
1263+
'lib^lib:*'
1264+
1265+
Note:
1266+
This class is automatically instantiated by the ScopeMeta metaclass when
1267+
a library scope with the platform wildcard is created.
1268+
"""
1269+
1270+
NAMESPACE: ClassVar[str] = "lib"
1271+
1272+
@classmethod
1273+
def get_admin_view_permission(cls) -> PermissionData:
1274+
"""Get the permission required to view this scope.
1275+
1276+
Returns:
1277+
PermissionData: The permission required to view this scope in the admin console.
1278+
"""
1279+
return VIEW_LIBRARY_TEAM
1280+
1281+
@classmethod
1282+
def get_admin_manage_permission(cls) -> PermissionData:
1283+
"""Get the permission required to manage this scope.
1284+
1285+
Returns:
1286+
PermissionData: The permission required to manage this scope in the admin console.
1287+
"""
1288+
return MANAGE_LIBRARY_TEAM
1289+
1290+
12301291
class CCXCourseOverviewData(CourseOverviewData):
12311292
"""CCX course scope for authorization in the Open edX platform.
12321293

openedx_authz/api/users.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -331,9 +331,7 @@ def _filter_allowed_assignments(
331331
if is_user_staff_or_superuser(user_external_key):
332332
return assignments
333333

334-
return filter_role_assignments_visible_to_subject(
335-
UserData(external_key=user_external_key), assignments
336-
)
334+
return filter_role_assignments_visible_to_subject(UserData(external_key=user_external_key), assignments)
337335

338336

339337
def get_visible_role_assignments_for_user(

openedx_authz/engine/matcher.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
CourseOverviewData,
66
OrgContentLibraryGlobData,
77
OrgCourseOverviewGlobData,
8+
PlatformContentLibraryGlobData,
89
PlatformCourseOverviewGlobData,
910
ScopeData,
1011
UserData,
@@ -16,6 +17,7 @@
1617
(CourseOverviewData.NAMESPACE, CourseOverviewData),
1718
(OrgContentLibraryGlobData.NAMESPACE, OrgContentLibraryGlobData),
1819
(OrgCourseOverviewGlobData.NAMESPACE, OrgCourseOverviewGlobData),
20+
(PlatformContentLibraryGlobData.NAMESPACE, PlatformContentLibraryGlobData),
1921
(PlatformCourseOverviewGlobData.NAMESPACE, PlatformCourseOverviewGlobData),
2022
}
2123

openedx_authz/tests/api/test_data.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
OrgContentLibraryGlobData,
1515
OrgCourseOverviewGlobData,
1616
PermissionData,
17+
PlatformContentLibraryGlobData,
1718
PlatformCourseOverviewGlobData,
1819
RoleAssignmentData,
1920
RoleData,
@@ -271,12 +272,15 @@ def test_scope_data_registration(self):
271272
# Glob registries for platform-level scopes
272273
self.assertIn("course-v1", ScopeMeta.platform_glob_registry)
273274
self.assertIs(ScopeMeta.platform_glob_registry["course-v1"], PlatformCourseOverviewGlobData)
275+
self.assertIn("lib", ScopeMeta.platform_glob_registry)
276+
self.assertIs(ScopeMeta.platform_glob_registry["lib"], PlatformContentLibraryGlobData)
274277

275278
@data(
276279
("ccx-v1^ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1", CCXCourseOverviewData),
277280
("course-v1^course-v1:WGU+CS002+2025_T1", CourseOverviewData),
278281
("lib^lib:DemoX:CSPROB", ContentLibraryData),
279282
("lib^lib:DemoX*", OrgContentLibraryGlobData),
283+
("lib^lib:*", PlatformContentLibraryGlobData),
280284
("course-v1^course-v1:OpenedX*", OrgCourseOverviewGlobData),
281285
("course-v1^course-v1:*", PlatformCourseOverviewGlobData),
282286
("global^generic_scope", ScopeData),
@@ -299,6 +303,7 @@ def test_dynamic_instantiation_via_namespaced_key(self, namespaced_key, expected
299303
("course-v1^course-v1:WGU+CS002+2025_T1", CourseOverviewData),
300304
("lib^lib:DemoX:CSPROB", ContentLibraryData),
301305
("lib^lib:DemoX:*", OrgContentLibraryGlobData),
306+
("lib^lib:*", PlatformContentLibraryGlobData),
302307
("course-v1^course-v1:OpenedX+*", OrgCourseOverviewGlobData),
303308
("course-v1^course-v1:*", PlatformCourseOverviewGlobData),
304309
("lib^*", ScopeData),
@@ -458,6 +463,7 @@ def test_get_subclass_by_namespaced_key_invalid_glob_pattern_raises(self, namesp
458463
("course-v1:WGU+CS002+2025_T1", CourseOverviewData),
459464
("lib:DemoX:CSPROB", ContentLibraryData),
460465
("lib:DemoX:*", OrgContentLibraryGlobData),
466+
("lib:*", PlatformContentLibraryGlobData),
461467
("course-v1:OpenedX+*", OrgCourseOverviewGlobData),
462468
("course-v1:*", PlatformCourseOverviewGlobData),
463469
("lib:edX:Demo", ContentLibraryData),
@@ -537,8 +543,11 @@ def test_is_platform_glob(self, namespace, external_key, expected):
537543
def test_platform_glob_registration_does_not_override_scope_registry(self):
538544
"""Platform globs register separately; concrete scopes keep scope_registry entries."""
539545
self.assertIs(ScopeData.scope_registry["course-v1"], CourseOverviewData)
546+
self.assertIs(ScopeData.scope_registry["lib"], ContentLibraryData)
540547
self.assertIs(ScopeMeta.platform_glob_registry["course-v1"], PlatformCourseOverviewGlobData)
548+
self.assertIs(ScopeMeta.platform_glob_registry["lib"], PlatformContentLibraryGlobData)
541549
self.assertNotIn(PlatformCourseOverviewGlobData, ScopeData.scope_registry.values())
550+
self.assertNotIn(PlatformContentLibraryGlobData, ScopeData.scope_registry.values())
542551

543552
def test_platform_glob_resolves_before_org_glob_for_course_namespace(self):
544553
"""course-v1:* is a platform glob; course-v1:Org+* remains an org glob."""
@@ -557,16 +566,32 @@ def test_dynamic_instantiation_via_external_key_for_platform_glob(self):
557566
self.assertEqual(scope.external_key, "course-v1:*")
558567
self.assertEqual(scope.namespaced_key, "course-v1^course-v1:*")
559568

569+
def test_platform_glob_resolves_before_org_glob_for_lib_namespace(self):
570+
"""lib:* is a platform glob; lib:Org:* remains an org glob."""
571+
self.assertIs(ScopeMeta.get_subclass_by_external_key("lib:*"), PlatformContentLibraryGlobData)
572+
self.assertIs(ScopeMeta.get_subclass_by_external_key("lib:DemoX:*"), OrgContentLibraryGlobData)
573+
self.assertIs(ScopeMeta.get_subclass_by_namespaced_key("lib^lib:*"), PlatformContentLibraryGlobData)
574+
self.assertIs(ScopeMeta.get_subclass_by_namespaced_key("lib^lib:DemoX:*"), OrgContentLibraryGlobData)
575+
576+
def test_dynamic_instantiation_via_external_key_for_lib_platform_glob(self):
577+
"""ScopeData(external_key='lib:*') instantiates PlatformContentLibraryGlobData."""
578+
scope = ScopeData(external_key="lib:*")
579+
580+
self.assertIsInstance(scope, PlatformContentLibraryGlobData)
581+
self.assertEqual(scope.external_key, "lib:*")
582+
self.assertEqual(scope.namespaced_key, "lib^lib:*")
583+
560584
def test_get_subclass_by_external_key_unknown_platform_glob_raises_value_error(self):
561585
"""Namespace:* without a registered platform glob subclass raises ValueError."""
562586
with self.assertRaisesRegex(ValueError, "Unknown platform glob scope"):
563-
ScopeMeta.get_subclass_by_external_key("lib:*")
587+
ScopeMeta.get_subclass_by_external_key("ccx-v1:*")
564588

565589
def test_get_all_registered_scopes_includes_platform_glob(self):
566590
"""get_all_registered_scopes returns platform glob subclasses."""
567591
registered = ScopeMeta.get_all_registered_scopes()
568592

569593
self.assertIn(PlatformCourseOverviewGlobData, registered)
594+
self.assertIn(PlatformContentLibraryGlobData, registered)
570595
self.assertIn(OrgCourseOverviewGlobData, registered)
571596
self.assertIn(CourseOverviewData, registered)
572597

@@ -1157,3 +1182,77 @@ def test_get_all_platform_glob_namespaces(self):
11571182

11581183
self.assertIn("course-v1", platform_globs)
11591184
self.assertIs(platform_globs["course-v1"], PlatformCourseOverviewGlobData)
1185+
1186+
1187+
@ddt
1188+
class TestPlatformContentLibraryGlobData(TestCase):
1189+
"""Tests for the PlatformContentLibraryGlobData scope."""
1190+
1191+
PLATFORM_GLOB_EXTERNAL_KEY = "lib:*"
1192+
PLATFORM_GLOB_NAMESPACED_KEY = "lib^lib:*"
1193+
1194+
def test_build_external_key(self):
1195+
"""build_external_key returns the platform-wide library glob pattern."""
1196+
self.assertEqual(PlatformContentLibraryGlobData.build_external_key(), self.PLATFORM_GLOB_EXTERNAL_KEY)
1197+
1198+
@data(
1199+
("lib:*", True),
1200+
("lib:DemoX:*", False),
1201+
("lib:DemoX*", False),
1202+
("lib:DemoX", False),
1203+
("lib:*:*", False),
1204+
("other:*", False),
1205+
("course-v1:*", False),
1206+
)
1207+
@unpack
1208+
def test_validate_external_key(self, external_key, expected_valid):
1209+
"""Validate platform-level library glob external keys."""
1210+
self.assertEqual(PlatformContentLibraryGlobData.validate_external_key(external_key), expected_valid)
1211+
1212+
def test_exists_always_true(self):
1213+
"""exists() returns True without checking the database."""
1214+
scope = PlatformContentLibraryGlobData(external_key=self.PLATFORM_GLOB_EXTERNAL_KEY)
1215+
1216+
self.assertTrue(scope.exists())
1217+
1218+
def test_get_object_returns_none(self):
1219+
"""Platform glob scopes do not map to a concrete domain object."""
1220+
scope = PlatformContentLibraryGlobData(external_key=self.PLATFORM_GLOB_EXTERNAL_KEY)
1221+
1222+
self.assertIsNone(scope.get_object())
1223+
1224+
def test_namespaced_key(self):
1225+
"""namespaced_key includes namespace prefix and external key."""
1226+
scope = PlatformContentLibraryGlobData(external_key=self.PLATFORM_GLOB_EXTERNAL_KEY)
1227+
1228+
self.assertEqual(scope.namespaced_key, self.PLATFORM_GLOB_NAMESPACED_KEY)
1229+
1230+
def test_dynamic_instantiation_via_scope_data(self):
1231+
"""ScopeData resolves lib:* to PlatformContentLibraryGlobData."""
1232+
scope = ScopeData(external_key=self.PLATFORM_GLOB_EXTERNAL_KEY)
1233+
1234+
self.assertIsInstance(scope, PlatformContentLibraryGlobData)
1235+
self.assertEqual(scope.external_key, self.PLATFORM_GLOB_EXTERNAL_KEY)
1236+
1237+
def test_get_admin_view_permission(self):
1238+
"""View permission matches library team view permission."""
1239+
self.assertEqual(PlatformContentLibraryGlobData.get_admin_view_permission(), permissions.VIEW_LIBRARY_TEAM)
1240+
1241+
def test_get_admin_manage_permission(self):
1242+
"""Manage permission matches library team manage permission."""
1243+
self.assertEqual(
1244+
PlatformContentLibraryGlobData.get_admin_manage_permission(),
1245+
permissions.MANAGE_LIBRARY_TEAM,
1246+
)
1247+
1248+
def test_is_platform_glob(self):
1249+
"""Platform library glob is flagged as a platform-level glob scope."""
1250+
self.assertTrue(PlatformContentLibraryGlobData.IS_PLATFORM_GLOB)
1251+
self.assertFalse(PlatformContentLibraryGlobData.IS_ORG_GLOB)
1252+
1253+
def test_get_all_platform_glob_namespaces(self):
1254+
"""Platform glob namespace is registered in ScopeMeta."""
1255+
platform_globs = ScopeMeta.get_all_platform_glob_namespaces()
1256+
1257+
self.assertIn("lib", platform_globs)
1258+
self.assertIs(platform_globs["lib"], PlatformContentLibraryGlobData)

openedx_authz/tests/api/test_roles.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,9 +1536,7 @@ def test_subject_sees_assignments_in_accessible_scope(self):
15361536
"""
15371537
all_assignments = get_all_subject_role_assignments()
15381538

1539-
visible = filter_role_assignments_visible_to_subject(
1540-
SubjectData(external_key="alice"), all_assignments
1541-
)
1539+
visible = filter_role_assignments_visible_to_subject(SubjectData(external_key="alice"), all_assignments)
15421540

15431541
visible_scopes = {a.scope.external_key for a in visible}
15441542
self.assertIn("lib:Org1:math_101", visible_scopes)
@@ -1551,9 +1549,7 @@ def test_subject_cannot_see_assignments_outside_accessible_scopes(self):
15511549
"""
15521550
all_assignments = get_all_subject_role_assignments()
15531551

1554-
visible = filter_role_assignments_visible_to_subject(
1555-
SubjectData(external_key="alice"), all_assignments
1556-
)
1552+
visible = filter_role_assignments_visible_to_subject(SubjectData(external_key="alice"), all_assignments)
15571553

15581554
visible_scopes = {a.scope.external_key for a in visible}
15591555
self.assertNotIn("lib:Org2:physics_401", visible_scopes)
@@ -1567,9 +1563,7 @@ def test_subject_with_no_role_assignments_sees_nothing(self):
15671563
"""
15681564
all_assignments = get_all_subject_role_assignments()
15691565

1570-
visible = filter_role_assignments_visible_to_subject(
1571-
SubjectData(external_key="unknown_user"), all_assignments
1572-
)
1566+
visible = filter_role_assignments_visible_to_subject(SubjectData(external_key="unknown_user"), all_assignments)
15731567

15741568
self.assertEqual(visible, [])
15751569

@@ -1579,8 +1573,6 @@ def test_empty_candidate_list_returns_empty(self):
15791573
Expected result:
15801574
- Result is empty regardless of the subject's accessible scopes.
15811575
"""
1582-
visible = filter_role_assignments_visible_to_subject(
1583-
SubjectData(external_key="alice"), []
1584-
)
1576+
visible = filter_role_assignments_visible_to_subject(SubjectData(external_key="alice"), [])
15851577

15861578
self.assertEqual(visible, [])

0 commit comments

Comments
 (0)