Skip to content

Commit 4b2b47c

Browse files
committed
feat: Implemented team member assignments endpoint
1 parent e33e57b commit 4b2b47c

12 files changed

Lines changed: 605 additions & 10 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.6.0 - 2026-04-09
18+
******************
19+
20+
Added
21+
=====
22+
23+
* Add the ``/api/authz/v1/users/<username>/assignments`` endpoint to get a list of role assignations for a user.
24+
1725
1.5.0 - 2026-04-09
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.5.0"
7+
__version__ = "1.6.0"
88

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

openedx_authz/api/data.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"RoleData",
3838
"ScopeData",
3939
"SubjectData",
40+
"SuperAdminAssignmentData",
4041
"UserData",
4142
]
4243

@@ -1101,6 +1102,19 @@ def __repr__(self):
11011102
return f"{self.subject.namespaced_key} => [{role_keys}] @ {self.scope.namespaced_key}"
11021103

11031104

1105+
@define
1106+
class SuperAdminAssignmentData:
1107+
"""Represents a superadmin entry in a team member assignment list.
1108+
1109+
Used alongside RoleAssignmentData in serializer contexts where a user is a
1110+
staff/superuser and their access is not derived from a specific role assignment.
1111+
"""
1112+
1113+
subject: SubjectData = None
1114+
is_staff: bool = False
1115+
is_superuser: bool = False
1116+
1117+
11041118
@define
11051119
class UserAssignments:
11061120
"""A user with their role assignments"""

openedx_authz/api/users.py

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

12+
from django.contrib.auth import get_user_model
13+
1214
from openedx_authz.api.data import (
1315
ActionData,
1416
PermissionData,
1517
RoleAssignmentData,
1618
RoleData,
1719
ScopeData,
20+
SuperAdminAssignmentData,
1821
UserAssignments,
1922
UserAssignmentsFilter,
2023
UserData,
@@ -36,6 +39,9 @@
3639
)
3740
from openedx_authz.api.utils import filter_user_assignments, get_user_assignment_map
3841

42+
User = get_user_model()
43+
44+
3945
__all__ = [
4046
"assign_role_to_user_in_scope",
4147
"batch_assign_role_to_users_in_scope",
@@ -44,13 +50,15 @@
4450
"get_user_role_assignments",
4551
"get_user_role_assignments_in_scope",
4652
"get_user_role_assignments_for_role_in_scope",
53+
"get_user_role_assignments_for_user_filtered",
4754
"get_user_role_assignments_filtered",
4855
"get_all_user_role_assignments_in_scope",
4956
"get_visible_role_assignments_for_user",
5057
"is_user_allowed",
5158
"get_scopes_for_user_and_permission",
5259
"get_users_for_role_in_scope",
5360
"unassign_all_roles_from_user",
61+
"get_superadmins",
5462
]
5563

5664

@@ -168,6 +176,42 @@ def get_user_role_assignments_for_role_in_scope(
168176
)
169177

170178

179+
def get_user_role_assignments_for_user_filtered(
180+
user_external_key: str,
181+
orgs: list[str] = None,
182+
roles: list[str] = None,
183+
allowed_for_user_external_key: str = None,
184+
) -> list[RoleAssignmentData]:
185+
"""
186+
Get role assignments for a specific user, filtered by orgs and/or roles,
187+
and only include assignments that the specified user has permission to view.
188+
189+
Args:
190+
user_external_key: The user to get assignments for (e.g., 'john_doe').
191+
orgs: Optional list of orgs to filter by (e.g., ['edX', 'MITx']).
192+
roles: Optional list of roles to filter by (e.g., ['library_admin']).
193+
allowed_for_user_external_key: The username to check permissions against (e.g., 'john_doe').
194+
195+
Returns:
196+
list[RoleAssignmentData]: A list of role assignments for the user, filtered by orgs/roles and permissions.
197+
"""
198+
user_role_assignments = get_user_role_assignments(user_external_key=user_external_key)
199+
# Filter assignments based on the user's permissions
200+
user_role_assignments = _filter_allowed_assignments(
201+
user_external_key=allowed_for_user_external_key,
202+
assignments=user_role_assignments,
203+
)
204+
if orgs:
205+
# Filter by orgs
206+
user_role_assignments = [a for a in user_role_assignments if a.scope.org in orgs]
207+
if roles:
208+
# Filter by roles
209+
user_role_assignments = [
210+
a for a in user_role_assignments if any(role.external_key in roles for role in a.roles)
211+
]
212+
return user_role_assignments
213+
214+
171215
def get_user_role_assignments_filtered(
172216
*,
173217
user_external_key: str | None = None,
@@ -339,3 +383,30 @@ def unassign_all_roles_from_user(user_external_key: str) -> bool:
339383
bool: True if any roles were removed, False otherwise.
340384
"""
341385
return unassign_subject_from_all_roles(UserData(external_key=user_external_key))
386+
387+
388+
def get_superadmins(user_external_keys: list[str] | None = None) -> list[SuperAdminAssignmentData]:
389+
"""Returns all superadmins as SuperAdminAssignmentData.
390+
391+
A superadmin is a User with a Django staff or superuser role.
392+
Superadmins automatically are allowed to do any action.
393+
394+
Args:
395+
user_external_keys (list[str] or None): To filter by usernames
396+
397+
Returns:
398+
list[SuperAdminAssignmentData]: The superadmin data
399+
"""
400+
# Retrieve user data to check if they are a superusers
401+
requested_users = User.objects.filter(username__in=user_external_keys, is_active=True)
402+
superadmin_assignments: list[SuperAdminAssignmentData] = []
403+
for requested_user in requested_users:
404+
if requested_user.is_staff or requested_user.is_superuser:
405+
superadmin_assignments.append(
406+
SuperAdminAssignmentData(
407+
subject=UserData(external_key=requested_user.username),
408+
is_staff=requested_user.is_staff,
409+
is_superuser=requested_user.is_superuser,
410+
)
411+
)
412+
return superadmin_assignments

openedx_authz/rest_api/data.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ class SortField(BaseEnum):
2020
EMAIL = "email"
2121

2222

23+
class AssignmentSortField(BaseEnum):
24+
"""Enum for the role assignment fields to sort by."""
25+
26+
ROLE = "role"
27+
ORG = "org"
28+
SCOPE = "scope"
29+
30+
2331
class SortOrder(BaseEnum):
2432
"""Enum for the order to sort by."""
2533

openedx_authz/rest_api/utils.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
from openedx_authz.api.data import (
44
GLOBAL_SCOPE_WILDCARD,
5+
RoleAssignmentData,
56
ScopeData,
67
)
7-
from openedx_authz.rest_api.data import SearchField, SortField, SortOrder
8+
from openedx_authz.rest_api.data import AssignmentSortField, SearchField, SortField, SortOrder
89

910

1011
def get_generic_scope(scope: ScopeData) -> ScopeData:
@@ -93,3 +94,37 @@ def filter_users(users: list[dict], search: str | None, roles: list[str] | None)
9394
filtered_users.append(user)
9495

9596
return filtered_users
97+
98+
99+
def sort_assignments(
100+
assignments: list[RoleAssignmentData],
101+
sort_by: AssignmentSortField = AssignmentSortField.ROLE,
102+
order: SortOrder = SortOrder.ASC,
103+
) -> list[RoleAssignmentData]:
104+
"""
105+
Sort role assignments by a given field and order.
106+
107+
Args:
108+
assignments (list[RoleAssignmentData]): The assignments to sort.
109+
sort_by (SortField, optional): The field to sort by. Defaults to AssignmentSortField.ROLE.
110+
order (SortOrder, optional): The order to sort by. Defaults to SortOrder.ASC.
111+
112+
Raises:
113+
ValueError: If the sort field is invalid.
114+
ValueError: If the sort order is invalid.
115+
116+
Returns:
117+
list[RoleAssignmentData]: The sorted assignments.
118+
"""
119+
if sort_by not in AssignmentSortField.values():
120+
raise ValueError(f"Invalid field: '{sort_by}'. Must be one of {AssignmentSortField.values()}")
121+
122+
if order not in SortOrder.values():
123+
raise ValueError(f"Invalid order: '{order}'. Must be one of {SortOrder.values()}")
124+
125+
sorted_assignments = sorted(
126+
assignments,
127+
key=lambda assignment: (assignment.get(sort_by) or "").lower(),
128+
reverse=order == SortOrder.DESC,
129+
)
130+
return sorted_assignments

openedx_authz/rest_api/v1/filters.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
from rest_framework.filters import BaseFilterBackend
44

5-
from openedx_authz.rest_api.data import SortField, SortOrder
6-
from openedx_authz.rest_api.utils import filter_users, sort_users
5+
from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder
6+
from openedx_authz.rest_api.utils import filter_users, sort_assignments, sort_users
77

88

99
class TeamMemberSearchFilter(BaseFilterBackend):
@@ -21,3 +21,12 @@ def filter_queryset(self, request, queryset, view):
2121
sort_by = request.query_params.get("sort_by", SortField.USERNAME)
2222
order = request.query_params.get("order", SortOrder.ASC)
2323
return sort_users(users=queryset, sort_by=sort_by, order=order)
24+
25+
26+
class TeamMemberAssignmentsOrderingFilter(BaseFilterBackend):
27+
"""Sort team member assignments by a given field and order."""
28+
29+
def filter_queryset(self, request, queryset, view):
30+
sort_by = request.query_params.get("sort_by", AssignmentSortField.ROLE)
31+
order = request.query_params.get("order", SortOrder.ASC)
32+
return sort_assignments(assignments=queryset, sort_by=sort_by, order=order)

openedx_authz/rest_api/v1/serializers.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from openedx_authz import api
77
from openedx_authz.api.data import UserAssignments
8-
from openedx_authz.rest_api.data import SortField, SortOrder
8+
from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder
99
from openedx_authz.rest_api.utils import get_generic_scope
1010
from openedx_authz.rest_api.v1.fields import (
1111
CaseSensitiveCommaSeparatedListField,
@@ -259,3 +259,66 @@ def get_email(self, obj: UserAssignments) -> str:
259259
def get_assignation_count(self, obj: UserAssignments) -> int:
260260
"""Get the assignation count for the given role assignment."""
261261
return len(obj.assignments)
262+
263+
264+
class ListTeamMemberAssignmentsSerializer(serializers.Serializer): # pylint: disable=abstract-method
265+
"""Serializer for listing team member assignments."""
266+
267+
orgs = CaseSensitiveCommaSeparatedListField(required=False, default=[])
268+
roles = CaseSensitiveCommaSeparatedListField(required=False, default=[])
269+
sort_by = serializers.ChoiceField(
270+
required=False,
271+
choices=[(e.value, e.name) for e in AssignmentSortField],
272+
default=AssignmentSortField.ROLE,
273+
)
274+
order = serializers.ChoiceField(
275+
required=False,
276+
choices=[(e.value, e.name) for e in SortOrder],
277+
default=SortOrder.ASC,
278+
)
279+
280+
281+
class TeamMemberAssignmentSerializer(serializers.Serializer): # pylint: disable=abstract-method
282+
"""Serializer for team member assignments."""
283+
284+
is_superadmin = serializers.SerializerMethodField()
285+
role = serializers.SerializerMethodField()
286+
org = serializers.SerializerMethodField()
287+
scope = serializers.SerializerMethodField()
288+
permission_count = serializers.SerializerMethodField()
289+
290+
def get_is_superadmin(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> bool:
291+
"""Get whether this assignment entry is for a superadmin."""
292+
return isinstance(obj, api.SuperAdminAssignmentData)
293+
294+
def get_role(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> str:
295+
"""Get the role for the given role assignment."""
296+
match obj:
297+
case api.SuperAdminAssignmentData():
298+
return "django.superuser" if obj.is_superuser else "django.staff"
299+
case api.RoleAssignmentData():
300+
return obj.roles[0].external_key if obj.roles else ""
301+
302+
def get_org(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> str:
303+
"""Get the org for the given role assignment."""
304+
match obj:
305+
case api.SuperAdminAssignmentData():
306+
return "*"
307+
case api.RoleAssignmentData():
308+
return getattr(obj.scope, "org", None)
309+
310+
def get_scope(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> str:
311+
"""Get the scope for the given role assignment."""
312+
match obj:
313+
case api.SuperAdminAssignmentData():
314+
return "*"
315+
case api.RoleAssignmentData():
316+
return obj.scope.external_key
317+
318+
def get_permission_count(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> int | None:
319+
"""Get the permission count for the given role assignment."""
320+
match obj:
321+
case api.SuperAdminAssignmentData():
322+
return None
323+
case api.RoleAssignmentData():
324+
return len(obj.roles[0].permissions) if obj.roles else 0

openedx_authz/rest_api/v1/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@
1414
path("roles/users/", views.RoleUserAPIView.as_view(), name="role-user-list"),
1515
path("orgs/", views.AdminConsoleOrgsAPIView.as_view(), name="orgs-list"),
1616
path("users/", views.TeamMembersAPIView.as_view(), name="user-list"),
17+
path("users/<str:username>/assignments", views.TeamMemberAssignmentsAPIView.as_view(), name="user-assignment-list"),
1718
]

0 commit comments

Comments
 (0)