Skip to content

Commit b9414ed

Browse files
[FC-0099] refactor!: consider global scope as wildcard managed by ScopeData (openedx#132)
1 parent e374fbf commit b9414ed

8 files changed

Lines changed: 83 additions & 41 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+
0.13.0 - 2025-11-05
18+
********************
19+
20+
Added
21+
=====
22+
23+
* Add support for global scopes instead of generic `sc` scope to support instance-level permissions.
24+
1725
0.12.0 - 2025-10-30
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__ = "0.12.0"
7+
__version__ = "0.13.0"
88

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

openedx_authz/api/data.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232
AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^"
3333
EXTERNAL_KEY_SEPARATOR = ":"
34-
GENERIC_SCOPE_WILDCARD = "*"
34+
GLOBAL_SCOPE_WILDCARD = "*"
3535
NAMESPACED_KEY_PATTERN = rf"^.+{re.escape(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR)}.+$"
3636

3737

@@ -181,6 +181,14 @@ def __call__(cls, *args, **kwargs):
181181
if cls is not ScopeData:
182182
return super().__call__(*args, **kwargs)
183183

184+
# When working with global scopes, we can't determine subclass with an external_key since
185+
# a global scope it's not attached to a specific resource type. So we only use * as
186+
# an external_key to mean generic scope which maps to base ScopeData class.
187+
# The only remaining issue is that internally the namespace key used in policies will be
188+
# The global scope namespace (global^*), so we need to handle that case here.
189+
if kwargs.get("external_key") == GLOBAL_SCOPE_WILDCARD:
190+
return super().__call__(*args, **kwargs)
191+
184192
if "namespaced_key" in kwargs:
185193
scope_cls = cls.get_subclass_by_namespaced_key(kwargs["namespaced_key"])
186194
return super(ScopeMeta, scope_cls).__call__(*args, **kwargs)
@@ -198,15 +206,15 @@ def get_subclass_by_namespaced_key(mcs, namespaced_key: str) -> Type["ScopeData"
198206
Extracts the namespace prefix (before '^') and returns the registered subclass.
199207
200208
Args:
201-
namespaced_key: The namespaced key (e.g., 'lib^lib:DemoX:CSPROB', 'sc^generic').
209+
namespaced_key: The namespaced key (e.g., 'lib^lib:DemoX:CSPROB', 'global^generic').
202210
203211
Returns:
204212
The ScopeData subclass for the namespace, or ScopeData if namespace not recognized.
205213
206214
Examples:
207215
>>> ScopeMeta.get_subclass_by_namespaced_key('lib^lib:DemoX:CSPROB')
208216
<class 'ContentLibraryData'>
209-
>>> ScopeMeta.get_subclass_by_namespaced_key('sc^generic')
217+
>>> ScopeMeta.get_subclass_by_namespaced_key('global^generic')
210218
<class 'ScopeData'>
211219
"""
212220
# TODO: Default separator, can't access directly from class so made it a constant
@@ -224,7 +232,7 @@ def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]:
224232
the key format using the subclass's validate_external_key method.
225233
226234
Args:
227-
external_key: The external key (e.g., 'lib:DemoX:CSPROB', 'sc:generic').
235+
external_key: The external key (e.g., 'lib:DemoX:CSPROB', 'global:generic').
228236
229237
Returns:
230238
The ScopeData subclass corresponding to the namespace.
@@ -263,11 +271,11 @@ def get_all_namespaces(mcs) -> dict[str, Type["ScopeData"]]:
263271
264272
Returns:
265273
dict[str, Type["ScopeData"]]: A dictionary of all namespace prefixes registered in the scope registry.
266-
Each namespace corresponds to a ScopeData subclass (e.g., 'lib', 'sc').
274+
Each namespace corresponds to a ScopeData subclass (e.g., 'lib', 'global').
267275
268276
Examples:
269277
>>> ScopeMeta.get_all_namespaces()
270-
{'sc': ScopeData, 'lib': ContentLibraryData, 'org': OrganizationData}
278+
{'global': ScopeData, 'lib': ContentLibraryData, 'org': OrganizationData}
271279
"""
272280
return mcs.scope_registry
273281

@@ -293,17 +301,22 @@ class ScopeData(AuthZData, metaclass=ScopeMeta):
293301
and not tied to any specific scope type, holding attributes common to all scopes.
294302
295303
Attributes:
296-
NAMESPACE: 'sc' for generic scopes.
304+
NAMESPACE: 'global' for generic scopes.
297305
external_key: The scope identifier without namespace (e.g., 'generic_scope').
298-
namespaced_key: The scope identifier with namespace (e.g., 'sc^generic_scope').
306+
namespaced_key: The scope identifier with namespace (e.g., 'global^generic_scope').
299307
300308
Examples:
301309
>>> scope = ScopeData(external_key='generic_scope')
302310
>>> scope.namespaced_key
303-
'sc^generic_scope'
311+
'global^generic_scope'
304312
"""
305313

306-
NAMESPACE: ClassVar[str] = "sc"
314+
# The 'global' namespace is used for scopes that aren't tied to a specific resource type.
315+
# This base class supports:
316+
# 1. Global wildcard scopes (external_key='*') that apply across all resource types
317+
# 2. Custom global scopes that don't map to specific domain objects (e.g., 'global:some_scope')
318+
# Subclasses like ContentLibraryData ('lib') represent concrete resource types with their own namespaces.
319+
NAMESPACE: ClassVar[str] = "global"
307320

308321
@classmethod
309322
def validate_external_key(cls, _: str) -> bool:

openedx_authz/rest_api/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django.contrib.auth import get_user_model
44
from django.db.models import Q
55

6-
from openedx_authz.api.data import GENERIC_SCOPE_WILDCARD, ScopeData
6+
from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, ScopeData
77
from openedx_authz.rest_api.data import SearchField, SortField, SortOrder
88

99
User = get_user_model()
@@ -28,7 +28,7 @@ def get_generic_scope(scope: ScopeData) -> ScopeData:
2828
>>> get_generic_scope(scope)
2929
ScopeData(namespaced_key="lib^*")
3030
"""
31-
return ScopeData(namespaced_key=f"{scope.NAMESPACE}{ScopeData.SEPARATOR}{GENERIC_SCOPE_WILDCARD}")
31+
return ScopeData(namespaced_key=f"{scope.NAMESPACE}{ScopeData.SEPARATOR}{GLOBAL_SCOPE_WILDCARD}")
3232

3333

3434
def get_user_map(usernames: list[str]) -> dict[str, User]:

openedx_authz/rest_api/v1/permissions.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def get_permission_class(mcs, namespace: str) -> type["BaseScopePermission"]:
3030
"""Retrieve the permission class for the given namespace.
3131
3232
Args:
33-
namespace: The namespace identifier (e.g., 'lib', 'sc').
33+
namespace: The namespace identifier (e.g., 'lib', 'global').
3434
3535
Returns:
3636
type["BaseScopePermission"]: The permission class for the namespace,
@@ -54,8 +54,8 @@ class BaseScopePermission(BasePermission, metaclass=PermissionMeta):
5454
specific authorization logic for their scope types.
5555
"""
5656

57-
NAMESPACE: ClassVar[str] = "sc"
58-
"""The namespace identifier for this permission class. Default ``sc`` for generic scopes."""
57+
NAMESPACE: ClassVar[str] = "global"
58+
"""The namespace identifier for this permission class. Default ``global`` for generic scopes."""
5959

6060
def get_scope_value(self, request) -> str | None:
6161
"""Extract the scope value from the request.
@@ -78,15 +78,15 @@ def get_scope_namespace(self, request) -> str:
7878
request: The Django REST framework request object.
7979
8080
Returns:
81-
str: The scope namespace (e.g., 'lib', 'sc').
81+
str: The scope namespace (e.g., 'lib', 'global').
8282
8383
Examples:
8484
>>> request.data = {"scope": "lib:DemoX:CSPROB"}
8585
>>> permission.get_scope_namespace(request)
8686
'lib'
8787
>>> request.data = {}
8888
>>> permission.get_scope_namespace(request)
89-
'sc'
89+
'global'
9090
"""
9191
scope_value = self.get_scope_value(request)
9292
if not scope_value:
@@ -137,7 +137,7 @@ class DynamicScopePermission(BaseScopePermission):
137137
>>> request.data = {"scope": "lib:DemoX:CSPROB"}
138138
>>> ContentLibraryPermission.has_permission(request, view)
139139
>>> # For a generic scope request, this will delegate to BaseScopePermission
140-
>>> request.data = {"scope": "sc:generic"}
140+
>>> request.data = {"scope": "global:generic"}
141141
>>> BaseScopePermission.has_permission(request, view)
142142
143143
Note:

openedx_authz/rest_api/v1/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def validate_scope(self, value: str) -> api.ScopeData:
135135
returns an instance of the appropriate ScopeData subclass.
136136
137137
Args:
138-
value: The scope string to validate (e.g., 'lib', 'sc', 'org').
138+
value: The scope string to validate (e.g., 'lib', 'global', 'org').
139139
140140
Returns:
141141
ScopeData: An instance of the appropriate ScopeData subclass for the scope.

openedx_authz/tests/api/test_data.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def test_scope_data_direct_instantiation_with_namespaced_key(self):
155155
"""Test that ScopeData can be instantiated with namespaced_key.
156156
157157
Expected Result:
158-
- ScopeData(namespaced_key='sc^generic') creates ScopeData instance
158+
- ScopeData(namespaced_key='global^generic') creates ScopeData instance
159159
"""
160160
namespaced_key = f"{ScopeData.NAMESPACE}{ScopeData.SEPARATOR}generic"
161161

@@ -222,25 +222,25 @@ def test_scope_data_registration(self):
222222
"""Test that ScopeData and its subclasses are registered correctly.
223223
224224
Expected Result:
225-
- 'sc' namespace maps to ScopeData class
225+
- 'global' namespace maps to ScopeData class
226226
- 'lib' namespace maps to ContentLibraryData class
227227
"""
228-
self.assertIn("sc", ScopeData.scope_registry)
229-
self.assertIs(ScopeData.scope_registry["sc"], ScopeData)
228+
self.assertIn("global", ScopeData.scope_registry)
229+
self.assertIs(ScopeData.scope_registry["global"], ScopeData)
230230
self.assertIn("lib", ScopeData.scope_registry)
231231
self.assertIs(ScopeData.scope_registry["lib"], ContentLibraryData)
232232

233233
@data(
234234
("lib^lib:DemoX:CSPROB", ContentLibraryData),
235-
("sc^generic_scope", ScopeData),
235+
("global^generic_scope", ScopeData),
236236
)
237237
@unpack
238238
def test_dynamic_instantiation_via_namespaced_key(self, namespaced_key, expected_class):
239239
"""Test that ScopeData dynamically instantiates the correct subclass.
240240
241241
Expected Result:
242242
- ScopeData(namespaced_key='lib^...') returns ContentLibraryData instance
243-
- ScopeData(namespaced_key='sc^...') returns ScopeData instance
243+
- ScopeData(namespaced_key='global^...') returns ScopeData instance
244244
"""
245245
instance = ScopeData(namespaced_key=namespaced_key)
246246

@@ -249,7 +249,7 @@ def test_dynamic_instantiation_via_namespaced_key(self, namespaced_key, expected
249249

250250
@data(
251251
("lib^lib:DemoX:CSPROB", ContentLibraryData),
252-
("sc^generic", ScopeData),
252+
("global^generic", ScopeData),
253253
("unknown^something", ScopeData),
254254
)
255255
@unpack
@@ -258,7 +258,7 @@ def test_get_subclass_by_namespaced_key(self, namespaced_key, expected_class):
258258
259259
Expected Result:
260260
- 'lib^...' returns ContentLibraryData
261-
- 'sc^...' returns ScopeData
261+
- 'global^...' returns ScopeData
262262
- 'unknown^...' returns ScopeData (fallback)
263263
"""
264264
subclass = ScopeMeta.get_subclass_by_namespaced_key(namespaced_key)
@@ -268,15 +268,15 @@ def test_get_subclass_by_namespaced_key(self, namespaced_key, expected_class):
268268
@data(
269269
("lib:DemoX:CSPROB", ContentLibraryData),
270270
("lib:edX:Demo", ContentLibraryData),
271-
("sc:generic_scope", ScopeData),
271+
("global:generic_scope", ScopeData),
272272
)
273273
@unpack
274274
def test_get_subclass_by_external_key(self, external_key, expected_class):
275275
"""Test get_subclass_by_external_key returns correct subclass.
276276
277277
Expected Result:
278278
- 'lib:...' returns ContentLibraryData
279-
- 'sc:...' returns ScopeData
279+
- 'global:...' returns ScopeData
280280
"""
281281
subclass = ScopeMeta.get_subclass_by_external_key(external_key)
282282

@@ -319,12 +319,12 @@ def test_base_scope_data_with_external_key(self):
319319
- ScopeData(external_key='...') creates ScopeData instance
320320
- No dynamic subclass selection occurs
321321
"""
322-
scope = ScopeData(external_key="sc:generic_scope")
322+
scope = ScopeData(external_key="global:generic_scope")
323323

324-
expected_namespaced = f"{ScopeData.NAMESPACE}{ScopeData.SEPARATOR}sc:generic_scope"
324+
expected_namespaced = f"{ScopeData.NAMESPACE}{ScopeData.SEPARATOR}global:generic_scope"
325325

326326
self.assertIsInstance(scope, ScopeData)
327-
self.assertEqual(scope.external_key, "sc:generic_scope")
327+
self.assertEqual(scope.external_key, "global:generic_scope")
328328
self.assertEqual(scope.namespaced_key, expected_namespaced)
329329

330330
def test_empty_namespaced_key_raises_value_error(self):
@@ -345,6 +345,27 @@ def test_empty_external_key_raises_value_error(self):
345345
with self.assertRaises(ValueError):
346346
SubjectData(external_key="")
347347

348+
def test_scope_data_with_wildcard_external_key(self):
349+
"""Test that ScopeData instantiated with wildcard (*) returns base ScopeData.
350+
351+
When using the global scope wildcard '*', the metaclass should return a base
352+
ScopeData instance rather than attempting subclass determination.
353+
354+
Expected Result:
355+
- ScopeData(external_key='*') creates base ScopeData instance
356+
- namespaced_key is 'global^*'
357+
- No subclass determination occurs
358+
"""
359+
scope = ScopeData(external_key="*")
360+
361+
expected_namespaced = f"{ScopeData.NAMESPACE}{ScopeData.SEPARATOR}*"
362+
363+
self.assertIsInstance(scope, ScopeData)
364+
# Ensure it's exactly ScopeData, not a subclass
365+
self.assertEqual(type(scope), ScopeData)
366+
self.assertEqual(scope.external_key, "*")
367+
self.assertEqual(scope.namespaced_key, expected_namespaced)
368+
348369

349370
@ddt
350371
class TestDataRepresentation(TestCase):

openedx_authz/tests/api/test_roles.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -500,9 +500,9 @@ def test_get_all_role_assignments_scopes(self, subject_name, expected_roles):
500500
(roles.LIBRARY_AUTHOR.external_key, "lib:Org6:project_beta", 1),
501501
(roles.LIBRARY_CONTRIBUTOR.external_key, "lib:Org6:project_gamma", 1),
502502
(roles.LIBRARY_USER.external_key, "lib:Org6:project_delta", 1),
503-
("non_existent_role", "sc:any_library", 0),
504-
(roles.LIBRARY_ADMIN.external_key, "sc:non_existent_scope", 0),
505-
("non_existent_role", "sc:non_existent_scope", 0),
503+
("non_existent_role", "global:any_library", 0),
504+
(roles.LIBRARY_ADMIN.external_key, "global:non_existent_scope", 0),
505+
("non_existent_role", "global:non_existent_scope", 0),
506506
)
507507
@unpack
508508
def test_get_role_assignments_in_scope(self, role_name, scope_name, expected_count):
@@ -625,8 +625,8 @@ def test_get_scopes_for_subject_and_permission(self, subject_name, action_name,
625625
(roles.LIBRARY_AUTHOR.external_key, "lib:Org4:art_201", {"liam"}),
626626
(roles.LIBRARY_AUTHOR.external_key, "lib:Org4:art_301", {"liam"}),
627627
("non_existent_role", "lib:Org4:art_101", set()),
628-
(roles.LIBRARY_AUTHOR.external_key, "sc:non_existent_scope", set()),
629-
("non_existent_role", "sc:non_existent_scope", set()),
628+
(roles.LIBRARY_AUTHOR.external_key, "global:non_existent_scope", set()),
629+
("non_existent_role", "global:non_existent_scope", set()),
630630
)
631631
@unpack
632632
def test_get_subjects_for_role_in_scope(self, role_name: str, scope_name: str, expected_subjects: set[str]):
@@ -654,7 +654,7 @@ class TestRoleAssignmentAPI(RolesTestSetupMixin):
654654
"""
655655

656656
@ddt_data(
657-
(["mary", "john"], roles.LIBRARY_USER.external_key, "sc:batch_test", True),
657+
(["mary", "john"], roles.LIBRARY_USER.external_key, "global:batch_test", True),
658658
(
659659
["paul", "diana", "lila"],
660660
roles.LIBRARY_CONTRIBUTOR.external_key,
@@ -712,7 +712,7 @@ def test_batch_assign_role_to_subjects_in_scope(self, subject_names, role, scope
712712
self.assertIn(role, role_names)
713713

714714
@ddt_data(
715-
(["mary", "john"], roles.LIBRARY_USER.external_key, "sc:batch_test", True),
715+
(["mary", "john"], roles.LIBRARY_USER.external_key, "global:batch_test", True),
716716
(
717717
["paul", "diana", "lila"],
718718
roles.LIBRARY_CONTRIBUTOR.external_key,
@@ -827,7 +827,7 @@ def test_unassign_role_from_subject_in_scope(self, subject_names, role, scope_na
827827
)
828828
],
829829
),
830-
("sc:non_existent_scope", []),
830+
("global:non_existent_scope", []),
831831
)
832832
@unpack
833833
def test_get_all_role_assignments_in_scope(self, scope_name, expected_assignments):

0 commit comments

Comments
 (0)