Skip to content

Commit 476dcc8

Browse files
BryanttVclaude
andauthored
feat: add platform-level glob scope (#289)
* feat: introduce platform-level glob scopes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: enhance permission tests for platform-level roles * chore: update release date for version 1.17.0 in CHANGELOG * refactor: remove unused PLATFORM_COURSE_GLOB constant from test case --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 874c734 commit 476dcc8

14 files changed

Lines changed: 1026 additions & 303 deletions

File tree

CHANGELOG.rst

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,23 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
1.17.0 - 2026-06-03
18+
*******************
19+
20+
Added
21+
=====
22+
23+
* Add support for platform glob scopes.
24+
1725
1.16.0 - 2026-05-21
1826
********************
1927

2028
Changed
2129
=======
2230

23-
* (Patched onto newer changes as well as 0.20.1) Removed checks for libraries v2 when the enforcer is loaded. This was
24-
originally add to improve performance, but a circular import on
25-
openedx-platform caused it to always default to true. This ensures that the
31+
* (Patched onto newer changes as well as 0.20.1) Removed checks for libraries v2 when
32+
the enforcer is loaded. This was originally add to improve performance, but a circular
33+
import on openedx-platform caused it to always default to true. This ensures that the
2634
enforcer continues to work even if the circular import is resolved.
2735

2836
1.15.0 - 2026-04-30

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__ = "1.16.0"
7+
__version__ = "1.17.0"
88

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

openedx_authz/api/data.py

Lines changed: 340 additions & 40 deletions
Large diffs are not rendered by default.

openedx_authz/api/users.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
(e.g., 'user^john_doe').
1010
"""
1111

12+
import logging
13+
1214
from django.contrib.auth import get_user_model
1315
from django.db.models import Q
1416

@@ -41,6 +43,8 @@
4143
from openedx_authz.api.utils import filter_user_assignments, get_user_assignment_map
4244
from openedx_authz.utils import get_user_by_username_or_email
4345

46+
log = logging.getLogger(__name__)
47+
4448
User = get_user_model()
4549

4650

@@ -272,16 +276,20 @@ def _filter_allowed_assignments(
272276
) -> list[RoleAssignmentData]:
273277
"""
274278
Filter the given role assignments to only include those that the user has permission to view.
279+
280+
Assignments whose scope does not implement ``get_admin_view_permission``
281+
are skipped with a warning
275282
"""
276283
if not user_external_key:
277284
# If no user is specified, return all assignments
278285
return assignments
279286
allowed_assignments: list[RoleAssignmentData] = []
280287
for assignment in assignments:
281-
permission = None
282-
283-
# Get the permission needed to view the specific scope in the admin console
284-
permission = assignment.scope.get_admin_view_permission().identifier
288+
try:
289+
permission = assignment.scope.get_admin_view_permission().identifier
290+
except NotImplementedError:
291+
log.warning("Skipping assignment with unsupported scope %r", assignment.scope.external_key)
292+
continue
285293

286294
if permission and is_user_allowed(
287295
user_external_key=user_external_key,

openedx_authz/engine/matcher.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
CourseOverviewData,
99
OrgContentLibraryGlobData,
1010
OrgCourseOverviewGlobData,
11+
PlatformCourseOverviewGlobData,
1112
ScopeData,
1213
UserData,
1314
)
@@ -21,6 +22,7 @@
2122
(CourseOverviewData.NAMESPACE, CourseOverviewData),
2223
(OrgContentLibraryGlobData.NAMESPACE, OrgContentLibraryGlobData),
2324
(OrgCourseOverviewGlobData.NAMESPACE, OrgCourseOverviewGlobData),
25+
(PlatformCourseOverviewGlobData.NAMESPACE, PlatformCourseOverviewGlobData),
2426
}
2527

2628

openedx_authz/migrations/0009_roleassignmentaudit.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55

66
class Migration(migrations.Migration):
7-
87
dependencies = [
98
("openedx_authz", "0008_authzcourseauthoringmigrationrun"),
109
]

openedx_authz/rest_api/v1/permissions.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,7 @@ def get_scope_namespace(self, request) -> str:
100100
scopes_list = request.data.get("scopes")
101101
if scopes_list and isinstance(scopes_list, list):
102102
if not self._scopes_have_homogeneous_namespaces(scopes_list):
103-
raise ValueError(
104-
f"Mixed scope namespaces in bulk request are not allowed: {scopes_list}"
105-
)
103+
raise ValueError(f"Mixed scope namespaces in bulk request are not allowed: {scopes_list}")
106104
scope_value = self.get_scope_value(request)
107105
if not scope_value:
108106
return self.NAMESPACE

openedx_authz/rest_api/v1/serializers.py

Lines changed: 12 additions & 1 deletion
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,
@@ -87,6 +87,11 @@ def _validate_scope_and_role(self, scope_value: str, role_value: str) -> None:
8787
serializers.ValidationError: If the scope is not registered, doesn't exist,
8888
or if the role is not defined in the scope.
8989
"""
90+
if scope_value == GLOBAL_SCOPE_WILDCARD:
91+
raise serializers.ValidationError(
92+
{"scope": "Global wildcard scope '*' is not accepted via the API. Use a specific scope key."}
93+
)
94+
9095
try:
9196
scope = api.ScopeData(external_key=scope_value)
9297
except ValueError as exc:
@@ -215,6 +220,10 @@ def validate_scope(self, value: str) -> api.ScopeData:
215220
>>> validate_scope('lib:DemoX:CSPROB')
216221
ContentLibraryData(external_key='lib:DemoX:CSPROB')
217222
"""
223+
if value == GLOBAL_SCOPE_WILDCARD:
224+
raise serializers.ValidationError(
225+
"Global wildcard scope '*' is not accepted via the API. Use a specific scope key."
226+
)
218227
try:
219228
return api.ScopeData(external_key=value)
220229
except ValueError as exc:
@@ -398,6 +407,8 @@ def get_org(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) ->
398407
case api.SuperAdminAssignmentData():
399408
return "*"
400409
case api.RoleAssignmentData():
410+
if obj.scope.IS_PLATFORM_GLOB:
411+
return "*"
401412
return getattr(obj.scope, "org", "")
402413

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

openedx_authz/rest_api/v1/views.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
CourseOverviewData,
2929
OrgContentLibraryGlobData,
3030
OrgCourseOverviewGlobData,
31+
PlatformGlobData,
3132
RoleAssignmentData,
3233
SuperAdminAssignmentData,
3334
UserAssignmentData,
@@ -256,7 +257,9 @@ class RoleUserAPIView(APIView):
256257
**Authentication and Permissions**
257258
258259
- Requires authenticated user.
259-
- Requires ``manage_library_team`` permission for the scope.
260+
261+
- GET: Requires ``view_library_team`` or ``view_course_team`` permission according to the scope.
262+
- PUT and DELETE: Requires ``manage_library_team`` or ``manage_course_team`` permission according to the scope.
260263
261264
**Example Request**
262265
@@ -784,7 +787,7 @@ def _get_allowed_scope_queryset(
784787
*,
785788
username: str,
786789
scope_cls: type,
787-
glob_cls: type,
790+
org_glob_cls: type,
788791
get_permission: callable,
789792
queryset_builder: callable,
790793
extract_ids: callable,
@@ -801,7 +804,7 @@ def _get_allowed_scope_queryset(
801804
Args:
802805
username: The username to check permissions for.
803806
scope_cls: The concrete scope data class (e.g., CourseOverviewData).
804-
glob_cls: The org-level glob class (e.g., OrgCourseOverviewGlobData).
807+
org_glob_cls: The org-level glob class (e.g., OrgCourseOverviewGlobData).
805808
get_permission: Callable that returns the permission for a scope class.
806809
queryset_builder: Callable that builds the filtered queryset (e.g., _get_courses_queryset).
807810
extract_ids: Callable that extracts specific IDs from non-glob scopes.
@@ -812,9 +815,14 @@ def _get_allowed_scope_queryset(
812815
QuerySet: The filtered queryset projected to the unified scope shape.
813816
"""
814817
allowed_scopes = get_scopes_for_user_and_permission(username, get_permission(scope_cls).identifier)
815-
specific_scopes = [s for s in allowed_scopes if not isinstance(s, glob_cls)]
818+
819+
has_platform_access = any(isinstance(s, PlatformGlobData) for s in allowed_scopes)
820+
if has_platform_access:
821+
return queryset_builder(allowed_ids=None, allowed_orgs=None, search=search, orgs=orgs)
822+
823+
specific_scopes = [s for s in allowed_scopes if not s.IS_GLOB]
816824
allowed_ids = extract_ids(specific_scopes)
817-
allowed_orgs = {s.org for s in allowed_scopes if isinstance(s, glob_cls)}
825+
allowed_orgs = {s.org for s in allowed_scopes if isinstance(s, org_glob_cls)}
818826
return queryset_builder(allowed_ids, allowed_orgs, search=search, orgs=orgs)
819827

820828
def _build_queryset(self, courses_qs: QuerySet | None, libraries_qs: QuerySet | None) -> QuerySet:
@@ -873,7 +881,7 @@ def get_permission(scope_cls):
873881
courses_qs = self._get_allowed_scope_queryset(
874882
username=user.username,
875883
scope_cls=CourseOverviewData,
876-
glob_cls=OrgCourseOverviewGlobData,
884+
org_glob_cls=OrgCourseOverviewGlobData,
877885
get_permission=get_permission,
878886
queryset_builder=self._get_courses_queryset,
879887
extract_ids=lambda scopes: {s.external_key for s in scopes},
@@ -886,7 +894,7 @@ def get_permission(scope_cls):
886894
libraries_qs = self._get_allowed_scope_queryset(
887895
username=user.username,
888896
scope_cls=ContentLibraryData,
889-
glob_cls=OrgContentLibraryGlobData,
897+
org_glob_cls=OrgContentLibraryGlobData,
890898
get_permission=get_permission,
891899
queryset_builder=self._get_libraries_queryset,
892900
extract_ids=lambda scopes: {

0 commit comments

Comments
 (0)