11"""Data classes and enums for representing roles, permissions, and policies."""
22
3- from opaque_keys .edx .locator import LibraryLocatorV2
4- from opaque_keys import InvalidKeyError
5-
63from enum import Enum
74from typing import ClassVar , Literal , Type
85
96from attrs import define
7+ from opaque_keys import InvalidKeyError
8+ from opaque_keys .edx .locator import LibraryLocatorV2
9+
10+
11+ AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^"
1012
1113
1214class GroupingPolicyIndex (Enum ):
@@ -36,7 +38,7 @@ class AuthzBaseClass:
3638 NAMESPACE: The namespace prefix for the data type (e.g., 'user', 'role').
3739 """
3840
39- SEPARATOR : ClassVar [str ] = "^"
41+ SEPARATOR : ClassVar [str ] = AUTHZ_POLICY_ATTRIBUTES_SEPARATOR
4042 NAMESPACE : ClassVar [str ] = None
4143
4244
@@ -88,13 +90,29 @@ def __call__(cls, *args, **kwargs):
8890 """Instantiate the appropriate subclass based on the namespace in namespaced_key.
8991
9092 There are two ways to instantiate:
91- 1. By providing external_key= and format for the external key determines the subclass (e.g., 'lib^any-library' = ContentLibraryData).
93+ 1. By providing external_key= and format for the external key determines the subclass
94+ (e.g., 'lib^any-library' = ContentLibraryData).
9295 2. By providing namespaced_key= and the class is determined from the namespace prefix
9396 in namespaced_key (e.g., 'lib@any-library' = ContentLibraryData).
97+
98+ The namespaced key is usually used when getting objects from the policy store,
99+ while the external key is usually used when initializing from user input or API calls. For example,
100+ when creating a role assignment for a content library, the API call would provide the library ID
101+ (external_key) and the system would need to determine the correct scope subclass based on the
102+ format of the library ID. While when retrieving role assignments from the policy store, the
103+ namespaced_key would be used to determine the subclass.
94104 """
95- if cls is ScopeData and "namespaced_key" in kwargs :
105+ if cls is not ScopeData :
106+ return super ().__call__ (* args , ** kwargs )
107+
108+ if "namespaced_key" in kwargs :
96109 scope_cls = cls .get_subclass_by_namespaced_key (kwargs ["namespaced_key" ])
97110 return super (ScopeMeta , scope_cls ).__call__ (* args , ** kwargs )
111+
112+ if "external_key" in kwargs :
113+ scope_cls = cls .get_subclass_by_external_key (kwargs ["external_key" ])
114+ return super (ScopeMeta , scope_cls ).__call__ (* args , ** kwargs )
115+
98116 return super ().__call__ (* args , ** kwargs )
99117
100118 def get_subclass_by_namespaced_key (cls , namespaced_key : str ) -> Type ["ScopeData" ]:
@@ -106,9 +124,8 @@ def get_subclass_by_namespaced_key(cls, namespaced_key: str) -> Type["ScopeData"
106124 Returns:
107125 The subclass of ScopeData corresponding to the namespace, or ScopeData if not found.
108126 """
109- # Use the SEPARATOR from ScopeData since the metaclass doesn't have it
110- separator = "^" # Default separator from AuthzBaseClass
111- namespace = namespaced_key .split (separator , 1 )[0 ]
127+ # TODO: Default separator, can't access directly from class so made it a constant
128+ namespace = namespaced_key .split (AUTHZ_POLICY_ATTRIBUTES_SEPARATOR , 1 )[0 ]
112129 return cls ._scope_registry .get (namespace , ScopeData )
113130
114131 def get_subclass_by_external_key (cls , external_key : str ) -> Type ["ScopeData" ]:
@@ -121,12 +138,17 @@ def get_subclass_by_external_key(cls, external_key: str) -> Type["ScopeData"]:
121138 The subclass of ScopeData corresponding to the namespace, or ScopeData if not found.
122139 """
123140 # Here we need to assume a couple of things:
124- # 1. The external_key is always in the format 'namespace:other things'.
141+ # 1. The external_key is always in the format 'namespace...:other things'. E.g., 'lib:any-library',
142+ # even 'course-v1:edX+DemoX+2021_T1'. This won't work for org scopes because they don't explicitly indicate
143+ # the namespace in the external key. TODO: We need to handle org scopes differently.
125144 # 2. The namespace is always the part before the first separator.
126145 # 3. If the namespace is not recognized, we return the base ScopeData class
127146 # 4. The subclass implements a validation method to validate the entire key
128147 namespace = external_key .split (":" , 1 )[0 ]
129- return cls ._scope_registry .get (namespace , ScopeData )
148+ scope_subclass = cls ._scope_registry .get (namespace )
149+ if not scope_subclass or not scope_subclass .validate_external_key (external_key ):
150+ return ScopeData # Fallback to base class if not found or invalid
151+ return scope_subclass
130152
131153 def validate_external_key (cls , external_key : str ) -> bool :
132154 """Validate the external_key format for the subclass.
@@ -163,7 +185,6 @@ class ContentLibraryData(ScopeData):
163185 """
164186
165187 NAMESPACE : ClassVar [str ] = "lib"
166- library_id : str = ""
167188
168189 @property
169190 def library_id (self ) -> str :
@@ -193,8 +214,51 @@ def validate_external_key(cls, external_key: str) -> bool:
193214 return False
194215
195216
217+ class SubjectMeta (type ):
218+ """Metaclass for SubjectData to handle dynamic subclass instantiation based on namespace."""
219+
220+ _subject_registry : ClassVar [dict [str , Type ["SubjectData" ]]] = {}
221+
222+ def __init__ (cls , name , bases , attrs ):
223+ """Initialize the metaclass and register subclasses."""
224+ super ().__init__ (name , bases , attrs )
225+ if not hasattr (cls , "_subject_registry" ):
226+ cls ._subject_registry = {}
227+ cls ._subject_registry [cls .NAMESPACE ] = cls
228+
229+ def __call__ (cls , * args , ** kwargs ):
230+ """Instantiate the appropriate subclass based on the namespace in namespaced_key.
231+
232+ There are two ways to instantiate:
233+ 1. By providing external_key= and format for the external key determines the subclass.
234+ 2. By providing namespaced_key= and the class is determined from the namespace prefix
235+ in namespaced_key (e.g., 'user^alice' = UserData).
236+
237+ TODO: we can't currently instantiate by external_key because we don't have a way to
238+ determine the subclass from the external_key format. A temporary solution is to
239+ use the users.py module to instantiate UserData directly when needed.
240+ """
241+ if cls is SubjectData and "namespaced_key" in kwargs :
242+ subject_cls = cls .get_subclass_by_namespaced_key (kwargs ["namespaced_key" ])
243+ return super (SubjectMeta , subject_cls ).__call__ (* args , ** kwargs )
244+
245+ return super ().__call__ (* args , ** kwargs )
246+
247+ def get_subclass_by_namespaced_key (cls , namespaced_key : str ) -> Type ["SubjectData" ]:
248+ """Get the appropriate subclass based on the namespace in namespaced_key.
249+
250+ Args:
251+ namespaced_key: The namespaced key (e.g., 'user^alice').
252+
253+ Returns:
254+ The subclass of SubjectData corresponding to the namespace, or SubjectData if not found.
255+ """
256+ namespace = namespaced_key .split (AUTHZ_POLICY_ATTRIBUTES_SEPARATOR , 1 )[0 ]
257+ return cls ._subject_registry .get (namespace , SubjectData )
258+
259+
196260@define
197- class SubjectData (AuthZData ):
261+ class SubjectData (AuthZData , metaclass = SubjectMeta ):
198262 """A subject is an entity that can be assigned roles and permissions.
199263
200264 Attributes:
@@ -321,6 +385,6 @@ class RoleAssignmentData(AuthZData):
321385 scope: The scope in which the role is assigned.
322386 """
323387
324- subject : SubjectData = None
388+ subject : SubjectData = None # Needs defaults to avoid value error from attrs
325389 role : RoleData = None
326390 scope : ScopeData = None
0 commit comments