Skip to content

Commit f4eebe1

Browse files
BryanttVclaude
andcommitted
feat: introduce platform-level glob scopes
* refactor: rename glob_cls to org_glob_cls and add platform access check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f4962be commit f4eebe1

2 files changed

Lines changed: 253 additions & 25 deletions

File tree

openedx_authz/api/data.py

Lines changed: 241 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -100,18 +100,24 @@ class ScopeMeta(type):
100100
"""Metaclass for ScopeData to handle dynamic subclass instantiation based on namespace."""
101101

102102
scope_registry: ClassVar[dict[str, Type["ScopeData"]]] = {}
103-
glob_registry: ClassVar[dict[str, Type["ScopeData"]]] = {}
103+
org_glob_registry: ClassVar[dict[str, Type["ScopeData"]]] = {}
104+
platform_glob_registry: ClassVar[dict[str, Type["ScopeData"]]] = {}
104105

105106
def __init__(cls, name, bases, attrs):
106-
"""Initialize the metaclass and register subclasses."""
107107
super().__init__(name, bases, attrs)
108108
if not hasattr(cls, "scope_registry"):
109109
cls.scope_registry = {}
110-
if not hasattr(cls, "glob_registry"):
111-
cls.glob_registry = {}
110+
if not hasattr(cls, "org_glob_registry"):
111+
cls.org_glob_registry = {}
112+
if not hasattr(cls, "platform_glob_registry"):
113+
cls.platform_glob_registry = {}
114+
115+
cls.IS_GLOB = cls.IS_ORG_GLOB or cls.IS_PLATFORM_GLOB
112116

113-
if cls.IS_GLOB and cls.NAMESPACE:
114-
cls.glob_registry[cls.NAMESPACE] = cls
117+
if cls.IS_PLATFORM_GLOB and cls.NAMESPACE:
118+
cls.platform_glob_registry[cls.NAMESPACE] = cls
119+
elif cls.IS_ORG_GLOB and cls.NAMESPACE:
120+
cls.org_glob_registry[cls.NAMESPACE] = cls
115121
else:
116122
cls.scope_registry[cls.NAMESPACE] = cls
117123

@@ -170,6 +176,11 @@ def __call__(cls, *args, **kwargs):
170176

171177
return super().__call__(*args, **kwargs)
172178

179+
@staticmethod
180+
def _is_platform_glob(external_key: str, namespace: str) -> bool:
181+
"""Validate if the external key is a platform glob."""
182+
return external_key == f"{namespace}{EXTERNAL_KEY_SEPARATOR}{GLOBAL_SCOPE_WILDCARD}"
183+
173184
@classmethod
174185
def get_subclass_by_namespaced_key(mcs, namespaced_key: str) -> Type["ScopeData"]:
175186
"""Get the appropriate ScopeData subclass from the namespaced key.
@@ -205,10 +216,13 @@ def get_subclass_by_namespaced_key(mcs, namespaced_key: str) -> Type["ScopeData"
205216
is_glob = GLOBAL_SCOPE_WILDCARD in external_key
206217

207218
if is_glob:
208-
# Try to get glob-specific class first
209-
return mcs.glob_registry.get(namespace, ScopeData)
219+
# Check if this is a platform glob pattern first
220+
if mcs._is_platform_glob(external_key, namespace):
221+
return mcs.platform_glob_registry.get(namespace, ScopeData)
222+
# If not a platform glob, check if it's an org glob
223+
return mcs.org_glob_registry.get(namespace, ScopeData)
210224

211-
# Fall back to standard scope class
225+
# If not a glob, return the standard scope class
212226
return mcs.scope_registry.get(namespace, ScopeData)
213227

214228
@classmethod
@@ -258,11 +272,20 @@ def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]:
258272
is_glob = GLOBAL_SCOPE_WILDCARD in external_key
259273

260274
if is_glob:
261-
glob_subclass = mcs.glob_registry.get(namespace)
275+
if mcs._is_platform_glob(external_key, namespace):
276+
platform_subclass = mcs.platform_glob_registry.get(namespace)
277+
278+
if not platform_subclass:
279+
raise ValueError(f"Unknown platform glob scope: {namespace} for external_key: {external_key}")
280+
if not platform_subclass.validate_external_key(external_key):
281+
raise ValueError(f"Invalid external_key format for platform glob scope: {external_key}")
282+
283+
return platform_subclass
284+
285+
glob_subclass = mcs.org_glob_registry.get(namespace)
262286

263287
if not glob_subclass:
264288
raise ValueError(f"Unknown glob scope: {namespace} for external_key: {external_key}")
265-
266289
if not glob_subclass.validate_external_key(external_key):
267290
raise ValueError(f"Invalid external_key format for glob scope: {external_key}")
268291

@@ -272,7 +295,6 @@ def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]:
272295

273296
if not scope_subclass:
274297
raise ValueError(f"Unknown scope: {namespace} for external_key: {external_key}")
275-
276298
if not scope_subclass.validate_external_key(external_key):
277299
raise ValueError(f"Invalid external_key format: {external_key}")
278300

@@ -283,15 +305,66 @@ def get_all_namespaces(mcs) -> dict[str, Type["ScopeData"]]:
283305
"""Get all registered scope namespaces.
284306
285307
Returns:
286-
dict[str, Type["ScopeData"]]: A dictionary of all namespace prefixes registered in the scope registry.
287-
Each namespace corresponds to a ScopeData subclass (e.g., 'lib', 'global').
308+
dict[str, Type["ScopeData"]]: A dictionary of all namespace prefixes
309+
registered in the scope registry. Each namespace corresponds to
310+
a ScopeData subclass (e.g., 'lib', 'global').
288311
289312
Examples:
290313
>>> ScopeMeta.get_all_namespaces()
291314
{'global': ScopeData, 'lib': ContentLibraryData, 'org': OrganizationData}
292315
"""
293316
return mcs.scope_registry
294317

318+
@classmethod
319+
def get_all_org_glob_namespaces(mcs) -> dict[str, Type["ScopeData"]]:
320+
"""Get all registered organization-level glob scope namespaces.
321+
322+
Returns:
323+
dict[str, Type["ScopeData"]]: A dictionary of all namespace prefixes
324+
registered in the organization glob registry. Each namespace corresponds
325+
to an org-level glob ScopeData subclass (e.g., 'course-v1', 'lib').
326+
327+
Examples:
328+
>>> ScopeMeta.get_all_org_glob_namespaces()
329+
{'course-v1': OrgCourseOverviewGlobData, 'lib': OrgContentLibraryGlobData}
330+
"""
331+
return mcs.org_glob_registry
332+
333+
@classmethod
334+
def get_all_platform_glob_namespaces(mcs) -> dict[str, Type["ScopeData"]]:
335+
"""Get all registered platform-level glob scope namespaces.
336+
337+
Returns:
338+
dict[str, Type["ScopeData"]]: A dictionary of all namespace prefixes
339+
registered in the platform glob registry. Each namespace corresponds
340+
to a platform-level glob ScopeData subclass (e.g., 'course-v1', 'lib').
341+
342+
Examples:
343+
>>> ScopeMeta.get_all_platform_glob_namespaces()
344+
{'course-v1': PlatformCourseOverviewGlobData, 'lib': PlatformContentLibraryGlobData}
345+
"""
346+
return mcs.platform_glob_registry
347+
348+
@classmethod
349+
def get_all_registered_scopes(mcs) -> list[Type["ScopeData"]]:
350+
"""Get all registered scope subclasses across all registries.
351+
352+
Returns:
353+
list[Type["ScopeData"]]: A unique list of all ScopeData subclasses registered in the standard,
354+
organization glob, and platform glob registries.
355+
356+
Examples:
357+
>>> ScopeMeta.get_all_registered_scopes()
358+
[ScopeData, ContentLibraryData, OrgCourseOverviewGlobData, PlatformCourseOverviewGlobData]
359+
"""
360+
return list(
361+
{
362+
*mcs.scope_registry.values(),
363+
*mcs.org_glob_registry.values(),
364+
*mcs.platform_glob_registry.values(),
365+
}
366+
)
367+
295368
@classmethod
296369
def validate_external_key(mcs, external_key: str) -> bool:
297370
"""Validate the external_key format for the subclass.
@@ -330,7 +403,8 @@ class ScopeData(AuthZData, metaclass=ScopeMeta):
330403
# 2. Custom global scopes that don't map to specific domain objects (e.g., 'global:some_scope')
331404
# Subclasses like ContentLibraryData ('lib') represent concrete resource types with their own namespaces.
332405
NAMESPACE: ClassVar[str] = "global"
333-
IS_GLOB: ClassVar[bool] = False
406+
IS_ORG_GLOB: ClassVar[bool] = False
407+
IS_PLATFORM_GLOB: ClassVar[bool] = False
334408

335409
@classmethod
336410
def validate_external_key(cls, _: str) -> bool:
@@ -680,7 +754,8 @@ class OrgGlobData(ScopeData):
680754
namespace-specific (subclasses must define it to match their key format).
681755
682756
Attributes:
683-
IS_GLOB (bool): Always True for organization-level glob patterns.
757+
NAMESPACE (str): The namespace prefix for organization-level glob patterns (e.g., ``org``).
758+
IS_ORG_GLOB (bool): Always True for organization-level glob patterns.
684759
ID_SEPARATOR (str): Separator used right before the wildcard (e.g., ``:`` or ``+``).
685760
ORG_NAME_VALID_PATTERN (re.Pattern | str): Regex used to validate the organization
686761
identifier extracted from :attr:`external_key`.
@@ -690,7 +765,8 @@ class OrgGlobData(ScopeData):
690765
- ``course-v1:DemoX+*`` (all courses in org ``DemoX``)
691766
"""
692767

693-
IS_GLOB: ClassVar[bool] = True
768+
NAMESPACE: ClassVar[str] = "org"
769+
IS_ORG_GLOB: ClassVar[bool] = True
694770
ID_SEPARATOR: ClassVar[str]
695771
ORG_NAME_VALID_PATTERN: ClassVar[re.Pattern] = r"^[a-zA-Z0-9._-]*$"
696772

@@ -824,7 +900,7 @@ class OrgContentLibraryGlobData(OrgGlobData):
824900
Attributes:
825901
NAMESPACE (str): 'lib' for content library scopes.
826902
ID_SEPARATOR (str): ':' for content library scopes.
827-
IS_GLOB (bool): True for scope data that represents a glob pattern.
903+
IS_ORG_GLOB (bool): True for scope data that represents an organization-level glob pattern.
828904
external_key (str): The glob pattern (e.g., ``lib:DemoX:*``).
829905
namespaced_key (str): The pattern with namespace (e.g., ``lib^lib:DemoX:*``).
830906
@@ -882,7 +958,7 @@ class OrgCourseOverviewGlobData(OrgGlobData):
882958
Attributes:
883959
NAMESPACE (str): 'course-v1' for course scopes.
884960
ID_SEPARATOR (str): '+' for course scopes.
885-
IS_GLOB (bool): True for scope data that represents a glob pattern.
961+
IS_ORG_GLOB (bool): True for scope data that represents an organization-level glob pattern.
886962
external_key (str): The glob pattern (e.g., 'course-v1:OpenedX+*').
887963
namespaced_key (str): The pattern with namespace (e.g., 'course-v1^course-v1:OpenedX+*').
888964
@@ -927,6 +1003,152 @@ def get_admin_manage_permission(cls) -> PermissionData:
9271003
return COURSES_MANAGE_COURSE_TEAM
9281004

9291005

1006+
@define
1007+
class PlatformGlobData(ScopeData):
1008+
"""Base class for platform-level glob scope keys.
1009+
1010+
This represents a platform-wide pattern: it matches "all resources in the platform"
1011+
for a given namespace, rather than being limited to a specific organization or concrete
1012+
object. The pattern is stored in :attr:`external_key` and **must** consist only of
1013+
the namespace followed by the global wildcard (``*``).
1014+
1015+
The expected shape is::
1016+
1017+
{NAMESPACE}{EXTERNAL_KEY_SEPARATOR}*
1018+
1019+
where ``{NAMESPACE}`` is the resource type namespace (e.g., ``course-v1`` or ``lib``).
1020+
1021+
Attributes:
1022+
IS_PLATFORM_GLOB (bool): Always True for platform-level glob patterns.
1023+
NAMESPACE (str): Must be defined by subclasses (e.g., 'course-v1', 'lib').
1024+
1025+
Examples:
1026+
- ``course-v1:*`` (all courses in the platform)
1027+
- ``lib:*`` (all libraries in the platform)
1028+
1029+
Note:
1030+
Subclasses must override NAMESPACE and implement the required abstract methods.
1031+
"""
1032+
1033+
NAMESPACE: ClassVar[str] = "platform"
1034+
IS_PLATFORM_GLOB: ClassVar[bool] = True
1035+
1036+
@classmethod
1037+
def validate_external_key(cls, external_key: str) -> bool:
1038+
"""Validate the external_key format for platform-level glob patterns.
1039+
1040+
Args:
1041+
external_key (str): The external key to validate (e.g., ``course-v1:*`` or ``lib:*``).
1042+
1043+
Returns:
1044+
bool: True if the format is valid, False otherwise.
1045+
"""
1046+
return cls.build_external_key() == external_key
1047+
1048+
@classmethod
1049+
@abstractmethod
1050+
def get_admin_view_permission(cls) -> PermissionData:
1051+
"""Get the permission required to view this scope.
1052+
1053+
Returns:
1054+
PermissionData: The permission required to view this scope in the admin console.
1055+
"""
1056+
raise NotImplementedError("Subclasses must implement get_admin_view_permission method.")
1057+
1058+
@classmethod
1059+
@abstractmethod
1060+
def get_admin_manage_permission(cls) -> PermissionData:
1061+
"""Get the permission required to manage this scope.
1062+
1063+
Returns:
1064+
PermissionData: The permission required to manage this scope in the admin console.
1065+
"""
1066+
raise NotImplementedError("Subclasses must implement get_admin_manage_permission method.")
1067+
1068+
@classmethod
1069+
def build_external_key(cls) -> str:
1070+
"""Build the external key for all resources in the platform.
1071+
1072+
Returns:
1073+
str: The external key for the platform-level glob (e.g., ``course-v1:*``).
1074+
1075+
Examples:
1076+
>>> PlatformCourseOverviewGlobData.build_external_key()
1077+
'course-v1:*'
1078+
"""
1079+
return f"{cls.NAMESPACE}{EXTERNAL_KEY_SEPARATOR}{GLOBAL_SCOPE_WILDCARD}"
1080+
1081+
def get_object(self) -> None:
1082+
"""Platform-level glob scopes do not map to a concrete domain object.
1083+
1084+
Returns:
1085+
None: Always returns None.
1086+
"""
1087+
return None
1088+
1089+
def exists(self) -> bool:
1090+
"""Platform-level glob scopes always exist.
1091+
1092+
Returns:
1093+
bool: Always True.
1094+
"""
1095+
return True
1096+
1097+
1098+
@define
1099+
class PlatformCourseOverviewGlobData(PlatformGlobData):
1100+
"""Platform-level glob pattern for courses.
1101+
1102+
This class represents glob patterns that match all courses in the platform,
1103+
Format: ``course-v1:*``
1104+
1105+
The glob pattern allows granting permissions to all courses across the entire
1106+
platform without needing to specify organizations or individual courses.
1107+
1108+
Attributes:
1109+
NAMESPACE (str): 'course-v1' for course scopes.
1110+
IS_PLATFORM_GLOB (bool): True for scope data that represents a platform-level glob pattern.
1111+
external_key (str): The glob pattern (always ``course-v1:*``).
1112+
namespaced_key (str): The pattern with namespace (``course-v1^course-v1:*``).
1113+
1114+
Validation Rules:
1115+
- Must be exactly ``course-v1:*``
1116+
- Applies to all existing and future courses in the platform
1117+
- Does not grant access to other resource types
1118+
1119+
Examples:
1120+
>>> glob = PlatformCourseOverviewGlobData(external_key='course-v1:*')
1121+
>>> glob.exists()
1122+
True
1123+
>>> glob.namespaced_key
1124+
'course-v1^course-v1:*'
1125+
1126+
Note:
1127+
This class is automatically instantiated by the ScopeMeta metaclass when
1128+
a course scope with the platform wildcard is created.
1129+
"""
1130+
1131+
NAMESPACE: ClassVar[str] = "course-v1"
1132+
1133+
@classmethod
1134+
def get_admin_view_permission(cls) -> PermissionData:
1135+
"""Get the permission required to view this scope.
1136+
1137+
Returns:
1138+
PermissionData: The permission required to view this scope in the admin console.
1139+
"""
1140+
return COURSES_VIEW_COURSE_TEAM
1141+
1142+
@classmethod
1143+
def get_admin_manage_permission(cls) -> PermissionData:
1144+
"""Get the permission required to manage this scope.
1145+
1146+
Returns:
1147+
PermissionData: The permission required to manage this scope in the admin console.
1148+
"""
1149+
return COURSES_MANAGE_COURSE_TEAM
1150+
1151+
9301152
class CCXCourseOverviewData(CourseOverviewData):
9311153
"""CCX course scope for authorization in the Open edX platform.
9321154

0 commit comments

Comments
 (0)