Skip to content

Commit f39a330

Browse files
feat: implement factory class as metaclass for subject
1 parent 3098228 commit f39a330

10 files changed

Lines changed: 468 additions & 155 deletions

File tree

openedx_authz/api/data.py

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
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-
63
from enum import Enum
74
from typing import ClassVar, Literal, Type
85

96
from 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

1214
class 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

openedx_authz/api/users.py

Lines changed: 11 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,9 @@
1111

1212
from openedx_authz.api.data import (
1313
ActionData,
14-
ContentLibraryData,
1514
RoleAssignmentData,
1615
RoleData,
1716
ScopeData,
18-
SubjectData,
1917
UserData,
2018
)
2119
from openedx_authz.api.permissions import has_permission
@@ -56,7 +54,7 @@ def assign_role_to_user_in_scope(
5654
assign_role_to_subject_in_scope(
5755
UserData(external_key=user_external_key),
5856
RoleData(external_key=role_external_key),
59-
ContentLibraryData(external_key=scope_external_key),
57+
ScopeData(external_key=scope_external_key),
6058
)
6159

6260

@@ -74,7 +72,7 @@ def batch_assign_role_to_users(
7472
batch_assign_role_to_subjects_in_scope(
7573
namespaced_users,
7674
RoleData(external_key=role_external_key),
77-
ContentLibraryData(external_key=scope_external_key),
75+
ScopeData(external_key=scope_external_key),
7876
)
7977

8078

@@ -91,7 +89,7 @@ def unassign_role_from_user(
9189
unassign_role_from_subject_in_scope(
9290
UserData(external_key=user_external_key),
9391
RoleData(external_key=role_external_key),
94-
ContentLibraryData(external_key=scope_external_key),
92+
ScopeData(external_key=scope_external_key),
9593
)
9694

9795

@@ -109,7 +107,7 @@ def batch_unassign_role_from_users(
109107
batch_unassign_role_from_subjects_in_scope(
110108
namespaced_users,
111109
RoleData(external_key=role_external_key),
112-
ContentLibraryData(external_key=scope_external_key),
110+
ScopeData(external_key=scope_external_key),
113111
)
114112

115113

@@ -139,7 +137,7 @@ def get_user_role_assignments_in_scope(
139137
"""
140138
return get_subject_role_assignments_in_scope(
141139
UserData(external_key=user_external_key),
142-
ContentLibraryData(external_key=scope_external_key),
140+
ScopeData(external_key=scope_external_key),
143141
)
144142

145143

@@ -157,23 +155,10 @@ def get_user_role_assignments_for_role_in_scope(
157155
"""
158156
# TODO: this SHOULD definitely be managed in a better way by using class inheritance and factories
159157
# But for now we'll keep it simple and explicit
160-
user_role_assignments = []
161-
162-
for role_assignment in get_subjects_role_assignments_for_role_in_scope(
158+
return get_subjects_role_assignments_for_role_in_scope(
163159
RoleData(external_key=role_external_key),
164-
ContentLibraryData(external_key=scope_external_key),
165-
):
166-
user_role_assignments.append(
167-
RoleAssignmentData(
168-
subject=UserData(
169-
namespaced_key=role_assignment.subject.namespaced_key
170-
), # TODO: this gets the username from the namespaced_key
171-
role=role_assignment.role,
172-
scope=role_assignment.scope,
173-
)
174-
)
175-
176-
return user_role_assignments
160+
ScopeData(external_key=scope_external_key),
161+
)
177162

178163

179164
def get_all_user_role_assignments_in_scope(
@@ -187,22 +172,10 @@ def get_all_user_role_assignments_in_scope(
187172
Returns:
188173
list[dict]: A list of user role assignments and all their metadata in the specified scope.
189174
"""
190-
user_role_assignments = []
191-
role_assignments = get_all_subject_role_assignments_in_scope(
192-
ContentLibraryData(external_key=scope_external_key)
175+
return get_all_subject_role_assignments_in_scope(
176+
ScopeData(external_key=scope_external_key)
193177
)
194178

195-
for role_assignment in role_assignments:
196-
user_role_assignments.append(
197-
RoleAssignmentData(
198-
subject=UserData(namespaced_key=role_assignment.subject.namespaced_key),
199-
role=role_assignment.role,
200-
scope=role_assignment.scope,
201-
)
202-
)
203-
204-
return user_role_assignments
205-
206179

207180
def user_has_permission(
208181
user_external_key: str,
@@ -222,5 +195,5 @@ def user_has_permission(
222195
return has_permission(
223196
UserData(external_key=user_external_key),
224197
ActionData(external_key=action_external_key),
225-
ContentLibraryData(external_key=scope_external_key),
198+
ScopeData(external_key=scope_external_key),
226199
)

openedx_authz/tests/api/test_data.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,9 @@ def test_content_library_data_with_external_key(self, external_key):
185185
"""
186186
library = ContentLibraryData(external_key=external_key)
187187
self.assertIsInstance(library, ContentLibraryData)
188-
expected_namespaced_key = f"{library.NAMESPACE}{library.SEPARATOR}{external_key}"
188+
expected_namespaced_key = (
189+
f"{library.NAMESPACE}{library.SEPARATOR}{external_key}"
190+
)
189191
self.assertEqual(library.external_key, external_key)
190192
self.assertEqual(library.namespaced_key, expected_namespaced_key)
191193

@@ -211,7 +213,9 @@ def test_scope_data_registration(self):
211213
("sc^generic_scope", ScopeData),
212214
)
213215
@unpack
214-
def test_dynamic_instantiation_via_namespaced_key(self, namespaced_key, expected_class):
216+
def test_dynamic_instantiation_via_namespaced_key(
217+
self, namespaced_key, expected_class
218+
):
215219
"""Test that ScopeData dynamically instantiates the correct subclass.
216220
217221
Expected Result:
@@ -242,7 +246,6 @@ def test_get_subclass_by_namespaced_key(self, namespaced_key, expected_class):
242246
@data(
243247
("lib:DemoX:CSPROB", ContentLibraryData),
244248
("lib:edX:Demo", ContentLibraryData),
245-
("sc:generic", ScopeData),
246249
("unknown:something", ScopeData),
247250
)
248251
@unpack

0 commit comments

Comments
 (0)