Skip to content

Commit b1e9b3d

Browse files
authored
feat: add support for course permission in authz rest api (#274)
* feat: add support for course permission in authz rest api * test: add tests * test: add unit test for authz permissions * feat: add validation of list of scopes must have homogeneous namespaces * docs: update CoursePermission docs * test: add more test for mixed scope * refactor: _has_bulk_permission * docs: bumpversion to v1.15.0
1 parent f4962be commit b1e9b3d

6 files changed

Lines changed: 665 additions & 21 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+
1.15.0 - 2026-04-30
18+
*******************
19+
20+
Added
21+
=====
22+
23+
* Add support for course permission in Authz REST APIs (#274)
24+
1725
1.14.0 - 2026-04-22
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__ = "1.14.0"
7+
__version__ = "1.15.0"
88

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

openedx_authz/rest_api/v1/permissions.py

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,23 @@ class BaseScopePermission(BasePermission, metaclass=PermissionMeta):
5959
def get_scope_value(self, request) -> str | None:
6060
"""Extract the scope value from the request.
6161
62+
When a ``scopes`` list is provided, returns only the first element.
63+
This is intentional: bulk requests are expected to be homogeneous
64+
(all scopes must share the same namespace). Actual per-scope permission
65+
validation for bulk requests is handled in ``DynamicScopePermission``.
66+
6267
Args:
6368
request: The Django REST framework request object.
6469
6570
Returns:
6671
str | None: The scope value if found (e.g., 'lib:DemoX:CSPROB'), or None if not present.
6772
"""
68-
return request.data.get("scope") or request.query_params.get("scope")
73+
scope = request.data.get("scope") or request.query_params.get("scope")
74+
if not scope:
75+
scopes = request.data.get("scopes")
76+
if scopes and isinstance(scopes, list):
77+
scope = scopes[0]
78+
return scope
6979

7080
def get_scope_namespace(self, request) -> str:
7181
"""Derive the namespace from the request scope value.
@@ -87,6 +97,12 @@ def get_scope_namespace(self, request) -> str:
8797
>>> permission.get_scope_namespace(request)
8898
'global'
8999
"""
100+
scopes_list = request.data.get("scopes")
101+
if scopes_list and isinstance(scopes_list, list):
102+
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+
)
90106
scope_value = self.get_scope_value(request)
91107
if not scope_value:
92108
return self.NAMESPACE
@@ -95,6 +111,22 @@ def get_scope_namespace(self, request) -> str:
95111
except ValueError:
96112
return self.NAMESPACE
97113

114+
def _scopes_have_homogeneous_namespaces(self, scopes_list: list[str]) -> bool:
115+
"""Check that all scopes in the list share the same namespace.
116+
117+
Args:
118+
scopes_list: List of scope values to check.
119+
Returns:
120+
bool: True if all scopes share the same namespace, False otherwise.
121+
"""
122+
namespaces = set()
123+
for scope in scopes_list:
124+
try:
125+
namespaces.add(api.ScopeData(external_key=scope).NAMESPACE)
126+
except ValueError:
127+
pass
128+
return len(namespaces) <= 1
129+
98130
def has_permission(self, request, view) -> bool:
99131
"""Fallback permission check (deny by default).
100132
@@ -141,6 +173,9 @@ class DynamicScopePermission(BaseScopePermission):
141173
142174
Note:
143175
Superusers and staff members always have permission regardless of scope.
176+
Bulk requests (``scopes`` list) must be homogeneous — all scopes must share
177+
the same namespace (e.g., all ``course-v1:`` or all ``lib:``). Mixed namespaces
178+
will raise a ``ValueError`` during namespace resolution.
144179
"""
145180

146181
NAMESPACE: ClassVar[None] = None
@@ -168,20 +203,57 @@ def _get_permission_instance(self, request) -> BaseScopePermission:
168203
perm_class = PermissionMeta.get_permission_class(scope_namespace)
169204
return perm_class()
170205

206+
def _has_bulk_permission(self, request, view, scopes_list: list[str]) -> bool:
207+
"""Check permissions for a bulk request carrying multiple scopes.
208+
209+
Bulk operations are only supported for endpoints decorated with
210+
``@authz_permissions``. A handler that does not use the decorator (i.e. does
211+
not mix in ``MethodPermissionMixin``) has no declared permissions to evaluate
212+
per-scope, so bulk access is denied outright.
213+
214+
Every scope in ``scopes_list`` must pass at least one of the required
215+
permissions declared by the decorator (OR logic per permission, AND logic
216+
across scopes).
217+
218+
Args:
219+
request: The Django REST framework request object.
220+
view: The view being accessed.
221+
scopes_list: The list of scope values from ``request.data["scopes"]``.
222+
223+
Returns:
224+
bool: True only if every scope passes at least one required permission.
225+
"""
226+
perm_instance = self._get_permission_instance(request) # namespace resolved from scopes[0]
227+
# Bulk without @authz_permissions decorator is not supported: there are no
228+
# per-method permissions to iterate over, so we cannot safely grant access.
229+
if not isinstance(perm_instance, MethodPermissionMixin):
230+
return False
231+
required = perm_instance.get_required_permissions(request, view)
232+
if not required:
233+
return False
234+
return all(perm_instance.validate_permissions(request, required, sv) for sv in scopes_list)
235+
171236
def has_permission(self, request, view) -> bool:
172237
"""Delegate permission check to the appropriate scope-specific permission class.
173238
174239
Superusers and staff members are automatically granted permission. For other
175240
users, the permission check is delegated to the permission class registered
176241
for the request's scope namespace.
177242
243+
For bulk requests that carry a ``scopes`` list, delegates to
244+
``_has_bulk_permission``: every scope must pass at least one of the required
245+
permissions (OR logic per permission, AND logic across scopes).
246+
178247
Examples:
179248
>>> # Regular user gets scope-specific check
180249
>>> request.data = {"scope": "lib:DemoX:CSPROB"}
181250
>>> permission.has_permission(request, view) # Delegates to ContentLibraryPermission
182251
"""
183252
if request.user.is_superuser or request.user.is_staff:
184253
return True
254+
scopes_list = request.data.get("scopes")
255+
if scopes_list and isinstance(scopes_list, list):
256+
return self._has_bulk_permission(request, view, scopes_list)
185257
return self._get_permission_instance(request).has_permission(request, view)
186258

187259
def has_object_permission(self, request, view, obj) -> bool:
@@ -240,23 +312,19 @@ def get_required_permissions(self, request, view) -> list[str]:
240312
return []
241313

242314
def validate_permissions(self, request, permissions: list[str], scope_value: str) -> bool:
243-
"""Validate that the user has all required permissions for the scope.
315+
"""Validate that the user has at least one of the required permissions for the scope.
244316
245317
Args:
246318
request: The Django REST framework request object.
247-
permissions: List of permission identifiers to check.
319+
permissions: List of permission identifiers to check (OR logic — any one suffices).
248320
scope_value: The scope to check permissions against.
249321
250322
Returns:
251-
bool: True if user has all required permissions, False otherwise.
323+
bool: True if user has at least one required permission, False otherwise.
252324
"""
253325
if not permissions:
254326
return False
255-
256-
for permission in permissions:
257-
if not api.is_user_allowed(request.user.username, permission, scope_value):
258-
return False
259-
return True
327+
return any(api.is_user_allowed(request.user.username, permission, scope_value) for permission in permissions)
260328

261329

262330
class AnyScopePermission(MethodPermissionMixin, BasePermission):
@@ -282,6 +350,37 @@ def has_permission(self, request, view) -> bool:
282350
return any(api.get_scopes_for_user_and_permission(request.user.username, permission) for permission in required)
283351

284352

353+
class CoursePermission(MethodPermissionMixin, BaseScopePermission):
354+
"""Permission handler for course scopes.
355+
356+
This class implements permission checks specific to course operations.
357+
It uses the authz API to verify whether a user has the necessary permissions
358+
to perform actions on course team members or course resources.
359+
"""
360+
361+
NAMESPACE: ClassVar[str] = "course-v1"
362+
"""``course-v1`` for course scopes."""
363+
364+
def has_permission(self, request, view) -> bool:
365+
"""Check if the user has permission to perform the requested action.
366+
367+
First checks if the view method has @authz_permissions decorator.
368+
If present, validates all required permissions. If not present,
369+
allows access by default.
370+
371+
Returns:
372+
bool: True if the user has the required permission, False otherwise.
373+
Also returns False if no scope value is provided in the request.
374+
"""
375+
scope_value = self.get_scope_value(request)
376+
if not scope_value:
377+
return False
378+
permissions = self.get_required_permissions(request, view)
379+
if permissions:
380+
return self.validate_permissions(request, permissions, scope_value)
381+
return True
382+
383+
285384
class ContentLibraryPermission(MethodPermissionMixin, BaseScopePermission):
286385
"""Permission handler for content library scopes.
287386

openedx_authz/rest_api/v1/views.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ class RoleUserAPIView(APIView):
292292
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions",
293293
},
294294
)
295-
@authz_permissions([permissions.VIEW_LIBRARY.identifier])
295+
@authz_permissions([permissions.VIEW_LIBRARY.identifier, permissions.COURSES_VIEW_COURSE_TEAM.identifier])
296296
def get(self, request: HttpRequest) -> Response:
297297
"""Retrieve all users with role assignments within a specific scope."""
298298
serializer = ListUsersInRoleWithScopeSerializer(data=request.query_params)
@@ -319,7 +319,7 @@ def get(self, request: HttpRequest) -> Response:
319319
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions",
320320
},
321321
)
322-
@authz_permissions([permissions.MANAGE_LIBRARY_TEAM.identifier])
322+
@authz_permissions([permissions.MANAGE_LIBRARY_TEAM.identifier, permissions.COURSES_MANAGE_COURSE_TEAM.identifier])
323323
def put(self, request: HttpRequest) -> Response:
324324
"""Assign multiple users to a specific role within one or more scopes."""
325325
serializer = AddUsersToRoleWithScopeSerializer(data=request.data)
@@ -366,7 +366,7 @@ def put(self, request: HttpRequest) -> Response:
366366
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions",
367367
},
368368
)
369-
@authz_permissions([permissions.MANAGE_LIBRARY_TEAM.identifier])
369+
@authz_permissions([permissions.MANAGE_LIBRARY_TEAM.identifier, permissions.COURSES_MANAGE_COURSE_TEAM.identifier])
370370
def delete(self, request: HttpRequest) -> Response:
371371
"""Remove multiple users from a specific role within a scope."""
372372
serializer = RemoveUsersFromRoleWithScopeSerializer(data=request.query_params)
@@ -468,7 +468,7 @@ class RoleListView(APIView):
468468
status.HTTP_401_UNAUTHORIZED: "The user is not authenticated or does not have the required permissions",
469469
},
470470
)
471-
@authz_permissions([permissions.VIEW_LIBRARY.identifier])
471+
@authz_permissions([permissions.VIEW_LIBRARY.identifier, permissions.COURSES_VIEW_COURSE_TEAM.identifier])
472472
def get(self, request: HttpRequest) -> Response:
473473
"""Retrieve all roles and their permissions for a specific scope."""
474474
serializer = ListRolesWithScopeSerializer(data=request.query_params)

0 commit comments

Comments
 (0)