@@ -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
262330class 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+
285384class ContentLibraryPermission (MethodPermissionMixin , BaseScopePermission ):
286385 """Permission handler for content library scopes.
287386
0 commit comments