Skip to content

Commit bb20c63

Browse files
committed
feat: add support for course permission in authz rest api
1 parent f4962be commit bb20c63

2 files changed

Lines changed: 42 additions & 13 deletions

File tree

openedx_authz/rest_api/v1/permissions.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ def get_scope_value(self, request) -> str | None:
6565
Returns:
6666
str | None: The scope value if found (e.g., 'lib:DemoX:CSPROB'), or None if not present.
6767
"""
68-
return request.data.get("scope") or request.query_params.get("scope")
68+
scope = request.data.get("scope") or request.query_params.get("scope")
69+
if not scope:
70+
scopes = request.data.get("scopes")
71+
if scopes and isinstance(scopes, list):
72+
scope = scopes[0]
73+
return scope
6974

7075
def get_scope_namespace(self, request) -> str:
7176
"""Derive the namespace from the request scope value.
@@ -175,13 +180,26 @@ def has_permission(self, request, view) -> bool:
175180
users, the permission check is delegated to the permission class registered
176181
for the request's scope namespace.
177182
183+
For bulk PUT requests that carry a ``scopes`` list, every scope in the list
184+
must pass at least one of the required permissions (OR logic per permission,
185+
AND logic across scopes).
186+
178187
Examples:
179188
>>> # Regular user gets scope-specific check
180189
>>> request.data = {"scope": "lib:DemoX:CSPROB"}
181190
>>> permission.has_permission(request, view) # Delegates to ContentLibraryPermission
182191
"""
183192
if request.user.is_superuser or request.user.is_staff:
184193
return True
194+
scopes_list = request.data.get("scopes")
195+
if scopes_list and isinstance(scopes_list, list):
196+
perm_instance = self._get_permission_instance(request) # namespace resolved from scopes[0]
197+
if not isinstance(perm_instance, MethodPermissionMixin):
198+
return False
199+
required = perm_instance.get_required_permissions(request, view)
200+
if not required:
201+
return False
202+
return all(perm_instance.validate_permissions(request, required, sv) for sv in scopes_list)
185203
return self._get_permission_instance(request).has_permission(request, view)
186204

187205
def has_object_permission(self, request, view, obj) -> bool:
@@ -240,23 +258,19 @@ def get_required_permissions(self, request, view) -> list[str]:
240258
return []
241259

242260
def validate_permissions(self, request, permissions: list[str], scope_value: str) -> bool:
243-
"""Validate that the user has all required permissions for the scope.
261+
"""Validate that the user has at least one of the required permissions for the scope.
244262
245263
Args:
246264
request: The Django REST framework request object.
247-
permissions: List of permission identifiers to check.
265+
permissions: List of permission identifiers to check (OR logic — any one suffices).
248266
scope_value: The scope to check permissions against.
249267
250268
Returns:
251-
bool: True if user has all required permissions, False otherwise.
269+
bool: True if user has at least one required permission, False otherwise.
252270
"""
253271
if not permissions:
254272
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
273+
return any(api.is_user_allowed(request.user.username, permission, scope_value) for permission in permissions)
260274

261275

262276
class AnyScopePermission(MethodPermissionMixin, BasePermission):
@@ -282,6 +296,21 @@ def has_permission(self, request, view) -> bool:
282296
return any(api.get_scopes_for_user_and_permission(request.user.username, permission) for permission in required)
283297

284298

299+
class CoursePermission(MethodPermissionMixin, BaseScopePermission):
300+
"""Permission handler for course scopes (namespace ``course-v1``)."""
301+
302+
NAMESPACE: ClassVar[str] = "course-v1"
303+
304+
def has_permission(self, request, view) -> bool:
305+
scope_value = self.get_scope_value(request)
306+
if not scope_value:
307+
return False
308+
permissions = self.get_required_permissions(request, view)
309+
if permissions:
310+
return self.validate_permissions(request, permissions, scope_value)
311+
return True
312+
313+
285314
class ContentLibraryPermission(MethodPermissionMixin, BaseScopePermission):
286315
"""Permission handler for content library scopes.
287316

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)