Skip to content

Commit d564d04

Browse files
committed
feat: Add support for global wildcard scope assignation
1 parent b884db3 commit d564d04

7 files changed

Lines changed: 364 additions & 27 deletions

File tree

openedx_authz/api/data.py

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"AuthzBaseClass",
3333
"ContentLibraryData",
3434
"CourseOverviewData",
35+
"GlobalWildcardScopeData",
3536
"GroupingPolicyIndex",
3637
"OrgCourseOverviewGlobData",
3738
"OrgGlobData",
@@ -154,11 +155,11 @@ def __call__(cls, *args, **kwargs):
154155

155156
# When working with global scopes, we can't determine subclass with an external_key since
156157
# a global scope it's not attached to a specific resource type. So we only use * as
157-
# an external_key to mean generic scope which maps to base ScopeData class.
158+
# an external_key to mean the global wildcard scope which maps to GlobalWildcardScopeData.
158159
# The only remaining issue is that internally the namespace key used in policies will be
159160
# The global scope namespace (global^*), so we need to handle that case here.
160161
if kwargs.get("external_key") == GLOBAL_SCOPE_WILDCARD:
161-
return super().__call__(*args, **kwargs)
162+
return super(ScopeMeta, GlobalWildcardScopeData).__call__(*args, **kwargs)
162163

163164
if "namespaced_key" in kwargs:
164165
scope_cls = cls.get_subclass_by_namespaced_key(kwargs["namespaced_key"])
@@ -249,6 +250,11 @@ def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]:
249250
- This won't work for org scopes that don't have explicit namespace prefixes.
250251
TODO: Handle org scopes differently.
251252
"""
253+
254+
# Special case: handle the global wildcard scope:
255+
if external_key == "*":
256+
return mcs.glob_registry.get("global")
257+
252258
if EXTERNAL_KEY_SEPARATOR not in external_key:
253259
raise ValueError(f"Invalid external_key format: {external_key}")
254260

@@ -288,7 +294,7 @@ def get_all_namespaces(mcs) -> dict[str, Type["ScopeData"]]:
288294
289295
Examples:
290296
>>> ScopeMeta.get_all_namespaces()
291-
{'global': ScopeData, 'lib': ContentLibraryData, 'org': OrganizationData}
297+
{'global': GlobalWildcardScopeData, 'lib': ContentLibraryData, 'org': OrganizationData}
292298
"""
293299
return mcs.scope_registry
294300

@@ -326,8 +332,7 @@ class ScopeData(AuthZData, metaclass=ScopeMeta):
326332

327333
# The 'global' namespace is used for scopes that aren't tied to a specific resource type.
328334
# This base class supports:
329-
# 1. Global wildcard scopes (external_key='*') that apply across all resource types
330-
# 2. Custom global scopes that don't map to specific domain objects (e.g., 'global:some_scope')
335+
# - Custom scopes that don't map to specific domain objects (e.g., 'global:some_scope')
331336
# Subclasses like ContentLibraryData ('lib') represent concrete resource types with their own namespaces.
332337
NAMESPACE: ClassVar[str] = "global"
333338
IS_GLOB: ClassVar[bool] = False
@@ -400,6 +405,93 @@ def exists(self) -> bool:
400405
raise NotImplementedError("Subclasses must implement exists method.")
401406

402407

408+
@define
409+
class GlobalWildcardScopeData(ScopeData):
410+
"""The global wildcard scope representing access across all scopes.
411+
412+
This scope is used when a role assignment should apply globally (i.e., not
413+
tied to any specific resource). It corresponds to the ``global^*`` namespaced
414+
key in Casbin policies.
415+
416+
The global wildcard scope always exists and does not map to a concrete domain
417+
object. It is automatically instantiated by the ``ScopeMeta`` metaclass when
418+
``ScopeData(external_key='*')`` is called.
419+
420+
Attributes:
421+
NAMESPACE (str): 'global'.
422+
external_key (str): Always ``'*'``.
423+
namespaced_key (str): Always ``'global^*'``.
424+
425+
Examples:
426+
>>> scope = ScopeData(external_key='*')
427+
>>> isinstance(scope, GlobalWildcardScopeData)
428+
True
429+
>>> scope.exists()
430+
True
431+
>>> scope.namespaced_key
432+
'global^*'
433+
"""
434+
435+
# The 'global' namespace is used for scopes that aren't tied to a specific resource type.
436+
# This class supports Global wildcard scopes (external_key='*') that apply across all resource types
437+
NAMESPACE: ClassVar[str] = "global"
438+
IS_GLOB: ClassVar[bool] = True
439+
440+
@classmethod
441+
def validate_external_key(cls, external_key: str) -> bool:
442+
"""Validate the external_key format for GlobalWildcardScopeData.
443+
444+
Only the wildcard ``'*'`` is accepted.
445+
446+
Args:
447+
external_key: The external key to validate.
448+
449+
Returns:
450+
bool: True if the key is ``'*'``, False otherwise.
451+
"""
452+
return external_key == GLOBAL_SCOPE_WILDCARD
453+
454+
@classmethod
455+
def get_admin_view_permission(cls) -> PermissionData:
456+
"""No admin view permission for the global scope.
457+
458+
Global scope assignments are managed exclusively by superadmins
459+
at the REST API layer, so no granular permission is needed.
460+
461+
Returns:
462+
None
463+
"""
464+
return VIEW_LIBRARY_TEAM
465+
466+
@classmethod
467+
def get_admin_manage_permission(cls) -> PermissionData:
468+
"""No admin manage permission for the global scope.
469+
470+
Global scope assignments are managed exclusively by superadmins
471+
at the REST API layer, so no granular permission is needed.
472+
473+
Returns:
474+
None
475+
"""
476+
return None
477+
478+
def get_object(self) -> None:
479+
"""The global wildcard scope does not map to a concrete domain object.
480+
481+
Returns:
482+
None: Always returns None.
483+
"""
484+
return None
485+
486+
def exists(self) -> bool:
487+
"""The global wildcard scope always exists.
488+
489+
Returns:
490+
bool: Always True.
491+
"""
492+
return True
493+
494+
403495
@define
404496
class ContentLibraryData(ScopeData):
405497
"""A content library scope for authorization in the Open edX platform.
@@ -690,6 +782,9 @@ class OrgGlobData(ScopeData):
690782
- ``course-v1:DemoX+*`` (all courses in org ``DemoX``)
691783
"""
692784

785+
# This NAMESPACE should be overriden by specific scope subclasses,
786+
# Setting this here so it doesn't conflict with GlobalWildcardScopeData's 'global' namespace
787+
NAMESPACE: ClassVar[str] = "orgglob"
693788
IS_GLOB: ClassVar[bool] = True
694789
ID_SEPARATOR: ClassVar[str]
695790
ORG_NAME_VALID_PATTERN: ClassVar[re.Pattern] = r"^[a-zA-Z0-9._-]*$"

openedx_authz/engine/matcher.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from openedx_authz.api.data import (
77
ContentLibraryData,
88
CourseOverviewData,
9+
GlobalWildcardScopeData,
910
OrgContentLibraryGlobData,
1011
OrgCourseOverviewGlobData,
1112
ScopeData,
@@ -21,6 +22,7 @@
2122
(CourseOverviewData.NAMESPACE, CourseOverviewData),
2223
(OrgContentLibraryGlobData.NAMESPACE, OrgContentLibraryGlobData),
2324
(OrgCourseOverviewGlobData.NAMESPACE, OrgCourseOverviewGlobData),
25+
(GlobalWildcardScopeData.NAMESPACE, GlobalWildcardScopeData),
2426
}
2527

2628

openedx_authz/rest_api/v1/permissions.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,28 @@ def has_permission(self, request, view) -> bool:
282282
return any(api.get_scopes_for_user_and_permission(request.user.username, permission) for permission in required)
283283

284284

285+
class GlobalScopePermission(BaseScopePermission):
286+
"""Permission handler for the global wildcard scope.
287+
288+
Only superadmins (``is_superuser`` or ``is_staff``) are allowed to assign roles to the
289+
global scope (``*``). Staff members without superuser status are denied.
290+
291+
This class is automatically selected by ``DynamicScopePermission`` when
292+
the request scope resolves to the ``global`` namespace.
293+
"""
294+
295+
NAMESPACE: ClassVar[str] = "global"
296+
"""``global`` for global wildcard scopes."""
297+
298+
def has_permission(self, request, view) -> bool:
299+
"""Allow only superusers to operate on the global scope.
300+
301+
Returns:
302+
bool: True if the user is a superadmin, False otherwise.
303+
"""
304+
return request.user.is_superuser or request.user.is_staff
305+
306+
285307
class ContentLibraryPermission(MethodPermissionMixin, BaseScopePermission):
286308
"""Permission handler for content library scopes.
287309

openedx_authz/rest_api/v1/serializers.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from rest_framework import serializers
77

88
from openedx_authz import api
9-
from openedx_authz.api.data import UserAssignments
9+
from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, UserAssignments
1010
from openedx_authz.rest_api.data import (
1111
AssignmentSortField,
1212
ScopesTypeField,
@@ -95,6 +95,10 @@ def _validate_scope_and_role(self, scope_value: str, role_value: str) -> None:
9595
if not scope.exists():
9696
raise serializers.ValidationError({"scope": f"Scope '{scope_value}' does not exist"})
9797

98+
# Special case for global wildcard
99+
if scope_value == GLOBAL_SCOPE_WILDCARD:
100+
return
101+
98102
role = api.RoleData(external_key=role_value)
99103
generic_scope = get_generic_scope(scope)
100104
role_definitions = api.get_role_definitions_in_scope(generic_scope)
@@ -160,15 +164,11 @@ def validate(self, attrs) -> dict:
160164
role_value = validated_data["role"]
161165

162166
if scope and scopes is not None:
163-
raise serializers.ValidationError(
164-
"Provide either 'scope' or 'scopes', not both."
165-
)
167+
raise serializers.ValidationError("Provide either 'scope' or 'scopes', not both.")
166168

167169
scopes_list = scopes if scopes is not None else ([scope] if scope else None)
168170
if not scopes_list:
169-
raise serializers.ValidationError(
170-
"Either 'scope' or 'scopes' must be provided."
171-
)
171+
raise serializers.ValidationError("Either 'scope' or 'scopes' must be provided.")
172172

173173
for scope_value in scopes_list:
174174
self._validate_scope_and_role(scope_value, role_value)
@@ -401,6 +401,9 @@ def get_org(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) ->
401401
case api.SuperAdminAssignmentData():
402402
return "*"
403403
case api.RoleAssignmentData():
404+
if obj.scope.external_key == GLOBAL_SCOPE_WILDCARD:
405+
# Special case for global wildcard scope
406+
return "*"
404407
return getattr(obj.scope, "org", "")
405408

406409
def get_scope(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> str:

openedx_authz/tests/api/test_data.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
CCXCourseOverviewData,
1212
ContentLibraryData,
1313
CourseOverviewData,
14+
GlobalWildcardScopeData,
1415
OrgContentLibraryGlobData,
1516
OrgCourseOverviewGlobData,
1617
PermissionData,
@@ -249,7 +250,8 @@ def test_scope_data_registration(self):
249250
"""Test that ScopeData and its subclasses are registered correctly.
250251
251252
Expected Result:
252-
- 'global' namespace maps to ScopeData class
253+
- 'global' namespace maps to ScopeData class in scope_registry
254+
- 'global' namespace maps to GlobalWildcardScopeData in glob_registry
253255
- 'lib' namespace maps to ContentLibraryData class
254256
"""
255257
self.assertIn("global", ScopeData.scope_registry)
@@ -261,7 +263,9 @@ def test_scope_data_registration(self):
261263
self.assertIn("ccx-v1", ScopeData.scope_registry)
262264
self.assertIs(ScopeData.scope_registry["ccx-v1"], CCXCourseOverviewData)
263265

264-
# Glob registries for organization-level scopes
266+
# Glob registries for organization-level scopes and global wildcard
267+
self.assertIn("global", ScopeMeta.glob_registry)
268+
self.assertIs(ScopeMeta.glob_registry["global"], GlobalWildcardScopeData)
265269
self.assertIn("lib", ScopeMeta.glob_registry)
266270
self.assertIs(ScopeMeta.glob_registry["lib"], OrgContentLibraryGlobData)
267271
self.assertIn("course-v1", ScopeMeta.glob_registry)
@@ -320,6 +324,7 @@ def test_get_subclass_by_namespaced_key(self, namespaced_key, expected_class):
320324
("course-v1:OpenedX+*", OrgCourseOverviewGlobData),
321325
("lib:edX:Demo", ContentLibraryData),
322326
("global:generic_scope", ScopeData),
327+
("*", GlobalWildcardScopeData),
323328
)
324329
@unpack
325330
def test_get_subclass_by_external_key(self, external_key, expected_class):
@@ -361,11 +366,11 @@ def test_scope_validate_external_key(self, external_key, expected_valid, expecte
361366
self.assertEqual(result, expected_valid)
362367

363368
@data(
364-
"unknown:DemoX",
365-
"unknown:DemoX:*",
369+
"undefined:DemoX",
370+
"undefined:DemoX:*",
366371
)
367-
def test_get_subclass_by_external_key_unknown_scope_raises_value_error(self, external_key):
368-
"""Unknown namespace should raise ValueError, including wildcard keys."""
372+
def test_get_subclass_by_external_key_undefined_scope_raises_value_error(self, external_key):
373+
"""Undefined namespace should raise ValueError, including wildcard keys."""
369374
with self.assertRaises(ValueError):
370375
ScopeMeta.get_subclass_by_external_key(external_key)
371376

@@ -464,25 +469,28 @@ def test_empty_external_key_raises_value_error(self):
464469
SubjectData(external_key="")
465470

466471
def test_scope_data_with_wildcard_external_key(self):
467-
"""Test that ScopeData instantiated with wildcard (*) returns base ScopeData.
472+
"""Test that ScopeData instantiated with wildcard (*) returns GlobalWildcardScopeData.
468473
469-
When using the global scope wildcard '*', the metaclass should return a base
470-
ScopeData instance rather than attempting subclass determination.
474+
When using the global scope wildcard '*', the metaclass should return a
475+
GlobalWildcardScopeData instance rather than attempting subclass determination
476+
from the external_key format.
471477
472478
Expected Result:
473-
- ScopeData(external_key='*') creates base ScopeData instance
479+
- ScopeData(external_key='*') creates GlobalWildcardScopeData instance
474480
- namespaced_key is 'global^*'
475-
- No subclass determination occurs
481+
- exists() returns True
482+
- get_object() returns None
476483
"""
477484
scope = ScopeData(external_key="*")
478485

479-
expected_namespaced = f"{ScopeData.NAMESPACE}{ScopeData.SEPARATOR}*"
486+
expected_namespaced = f"{GlobalWildcardScopeData.NAMESPACE}{GlobalWildcardScopeData.SEPARATOR}*"
480487

481488
self.assertIsInstance(scope, ScopeData)
482-
# Ensure it's exactly ScopeData, not a subclass
483-
self.assertEqual(type(scope), ScopeData)
489+
self.assertIsInstance(scope, GlobalWildcardScopeData)
484490
self.assertEqual(scope.external_key, "*")
485491
self.assertEqual(scope.namespaced_key, expected_namespaced)
492+
self.assertTrue(scope.exists())
493+
self.assertIsNone(scope.get_object())
486494

487495

488496
@ddt

openedx_authz/tests/api/test_roles.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,7 @@ class TestRoleAssignmentAPI(RolesTestSetupMixin):
922922
"""
923923

924924
@ddt_data(
925-
(["mary", "john"], roles.LIBRARY_USER.external_key, "global:batch_test", True),
925+
(["mary", "john"], roles.LIBRARY_USER.external_key, "*", True),
926926
(
927927
["paul", "diana", "lila"],
928928
roles.LIBRARY_CONTRIBUTOR.external_key,

0 commit comments

Comments
 (0)