Skip to content

Commit 7072655

Browse files
committed
feat: Implement team members endpoint for admin console
1 parent 9b3c787 commit 7072655

16 files changed

Lines changed: 833 additions & 52 deletions

File tree

CHANGELOG.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
1.4.0 - 2026-04-09
18+
******************
19+
20+
Added
21+
=====
22+
23+
* Add ``users/`` endpoint to fetch all team members, with optional filters for orgs, scopes, search by username user full name or email, sorting and pagination.
24+
25+
Fixed
26+
=====
27+
28+
* Fix enforcer ``is_admin_or_superuser_check`` that was not taking into account Org glob scopes.
29+
1730
1.3.0 2026-04-08
1831
****************
1932

openedx_authz/api/data.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,3 +1207,18 @@ def __repr__(self):
12071207
"""Developer friendly string representation of the role assignment."""
12081208
role_keys = ", ".join(role.namespaced_key for role in self.roles)
12091209
return f"{self.subject.namespaced_key} => [{role_keys}] @ {self.scope.namespaced_key}"
1210+
1211+
1212+
@define
1213+
class UserAssignments:
1214+
"""A user with their role assignments"""
1215+
1216+
user: "User"
1217+
assignments: list[RoleAssignmentData]
1218+
1219+
1220+
class UserAssignmentsFilter(Enum):
1221+
"""Enum for the filters that can be applied over UserAssignments."""
1222+
1223+
SCOPES = "scopes"
1224+
ORGS = "orgs"

openedx_authz/api/roles.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
"batch_unassign_role_from_subjects_in_scope",
3232
"get_all_roles_in_scope",
3333
"get_all_roles_names",
34+
"get_subject_role_assignments_in_scope",
35+
"get_subject_role_assignments_for_role_in_scope",
36+
"get_all_subject_role_assignments",
3437
"get_all_subject_role_assignments_in_scope",
3538
"get_permissions_for_active_roles_in_scope",
3639
"get_permissions_for_roles",
@@ -269,6 +272,29 @@ def batch_unassign_role_from_subjects_in_scope(subjects: list[SubjectData], role
269272
unassign_role_from_subject_in_scope(subject, role, scope)
270273

271274

275+
def get_all_subject_role_assignments() -> list[RoleAssignmentData]:
276+
"""Get all the roles for every subject across all scopes.
277+
278+
Returns:
279+
list[RoleAssignmentData]: A list of role assignments for the subject.
280+
"""
281+
enforcer = AuthzEnforcer.get_enforcer()
282+
role_assignments = []
283+
for policy in enforcer.get_grouping_policy():
284+
subject = SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value])
285+
role = RoleData(namespaced_key=policy[GroupingPolicyIndex.ROLE.value])
286+
role.permissions = get_permissions_for_single_role(role)
287+
288+
role_assignments.append(
289+
RoleAssignmentData(
290+
subject=subject,
291+
roles=[role],
292+
scope=ScopeData(namespaced_key=policy[GroupingPolicyIndex.SCOPE.value]),
293+
)
294+
)
295+
return role_assignments
296+
297+
272298
def get_subject_role_assignments(subject: SubjectData) -> list[RoleAssignmentData]:
273299
"""Get all the roles for a subject across all scopes.
274300

openedx_authz/api/users.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@
1111

1212
from openedx_authz.api.data import (
1313
ActionData,
14+
ContentLibraryData,
15+
CourseOverviewData,
16+
OrgContentLibraryGlobData,
17+
OrgCourseOverviewGlobData,
1418
PermissionData,
1519
RoleAssignmentData,
1620
RoleData,
1721
ScopeData,
22+
UserAssignments,
23+
UserAssignmentsFilter,
1824
UserData,
1925
)
2026
from openedx_authz.api.permissions import is_subject_allowed
@@ -32,6 +38,8 @@
3238
unassign_role_from_subject_in_scope,
3339
unassign_subject_from_all_roles,
3440
)
41+
from openedx_authz.api.utils import filter_user_assignments, get_user_assignment_map
42+
from openedx_authz.constants.permissions import COURSES_MANAGE_COURSE_TEAM, MANAGE_LIBRARY_TEAM
3543

3644
__all__ = [
3745
"assign_role_to_user_in_scope",
@@ -43,6 +51,7 @@
4351
"get_user_role_assignments_for_role_in_scope",
4452
"get_user_role_assignments_filtered",
4553
"get_all_user_role_assignments_in_scope",
54+
"get_visible_role_assignments_for_user",
4655
"is_user_allowed",
4756
"get_scopes_for_user_and_permission",
4857
"get_users_for_role_in_scope",
@@ -205,6 +214,71 @@ def get_all_user_role_assignments_in_scope(
205214
return get_all_subject_role_assignments_in_scope(ScopeData(external_key=scope_external_key))
206215

207216

217+
def _filter_allowed_assignments(
218+
user_external_key: str, assignments: list[RoleAssignmentData]
219+
) -> list[RoleAssignmentData]:
220+
"""
221+
Filter the given role assignments to only include those that the user has permission to view.
222+
"""
223+
allowed_assignments: list[RoleAssignmentData] = []
224+
for assignment in assignments:
225+
permission = None
226+
227+
# For CourseOverviewData and ContentLibraryData, check for the view permission
228+
if isinstance(assignment.scope, (CourseOverviewData, OrgCourseOverviewGlobData)):
229+
permission = COURSES_MANAGE_COURSE_TEAM.identifier
230+
elif isinstance(assignment.scope, (ContentLibraryData, OrgContentLibraryGlobData)):
231+
permission = MANAGE_LIBRARY_TEAM.identifier
232+
233+
if permission and is_user_allowed(
234+
user_external_key=user_external_key,
235+
action_external_key=permission,
236+
scope_external_key=assignment.scope.external_key,
237+
):
238+
allowed_assignments.append(assignment)
239+
240+
return allowed_assignments
241+
242+
243+
def get_visible_role_assignments_for_user(
244+
orgs: list[str] = None,
245+
scopes: list[str] = None,
246+
allowed_for_user_external_key: str = None,
247+
) -> list[UserAssignments]:
248+
"""
249+
Get all user role assignments filtered by orgs and/or scopes, and only include
250+
assignments that the specified user has permission to view.
251+
252+
Args:
253+
orgs: Optional list of orgs to filter by (e.g., ['edX', 'MITx']).
254+
scopes: Optional list of scopes to filter by (e.g., ['lib:DemoX:CSPROB']).
255+
allowed_for_user_external_key: The username to check permissions against (e.g., 'john_doe').
256+
257+
Returns:
258+
list[UserAssignments]: A list of users with their role assignments, filtered by orgs/scopes and permissions.
259+
"""
260+
user_role_assignments = get_user_role_assignments_filtered()
261+
# Filter assignments based on the user's permissions
262+
user_role_assignments = _filter_allowed_assignments(
263+
user_external_key=allowed_for_user_external_key,
264+
assignments=user_role_assignments,
265+
)
266+
# Group assignments by user
267+
users_with_assignments = get_user_assignment_map(user_role_assignments)
268+
269+
users_with_assignments = filter_user_assignments(
270+
users_with_assignments=users_with_assignments,
271+
by=UserAssignmentsFilter.SCOPES,
272+
values=scopes,
273+
)
274+
users_with_assignments = filter_user_assignments(
275+
users_with_assignments=users_with_assignments,
276+
by=UserAssignmentsFilter.ORGS,
277+
values=orgs,
278+
)
279+
return users_with_assignments
280+
281+
208282
def is_user_allowed(
209283
user_external_key: str,
210284
action_external_key: str,

openedx_authz/api/utils.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Utility functions used on api"""
2+
3+
from django.contrib.auth import get_user_model
4+
5+
from openedx_authz.api.data import (
6+
RoleAssignmentData,
7+
UserAssignments,
8+
UserAssignmentsFilter,
9+
)
10+
11+
User = get_user_model()
12+
13+
14+
def get_user_map(usernames: list[str]) -> dict[str, User]:
15+
"""
16+
Retrieve a dictionary mapping usernames to User objects for efficient batch lookups.
17+
18+
This function performs a single optimized database query to fetch multiple users,
19+
making it ideal for scenarios where we need to look up several users at once
20+
(e.g., when serializing multiple user role assignments).
21+
22+
Args:
23+
usernames (list[str]): List of usernames to retrieve. Duplicates are automatically
24+
handled by the database query.
25+
26+
Returns:
27+
dict[str, User]: Dictionary mapping each username to its corresponding User object.
28+
Only users that exist in the database are included in the returned dictionary.
29+
"""
30+
users = User.objects.filter(username__in=usernames).select_related("profile")
31+
return {user.username: user for user in users}
32+
33+
34+
def get_user_assignment_map(role_assignments: list[RoleAssignmentData]) -> list[UserAssignments]:
35+
"""
36+
Group role assignments by user
37+
"""
38+
usernames = {assignment.subject.username for assignment in role_assignments}
39+
user_map = get_user_map(usernames)
40+
41+
users_with_assignments: list[UserAssignments] = []
42+
43+
for username, user in user_map.items():
44+
assignments = [a for a in role_assignments if a.subject.username == username]
45+
users_with_assignments.append(UserAssignments(user=user, assignments=assignments))
46+
47+
return users_with_assignments
48+
49+
50+
def filter_user_assignments(
51+
users_with_assignments: list[UserAssignments],
52+
by: UserAssignmentsFilter,
53+
values: list[str],
54+
) -> list[UserAssignments]:
55+
"""
56+
Filter user assignments by orgs or scopes.
57+
"""
58+
if not values:
59+
return users_with_assignments
60+
61+
def _get_value_to_filter(assignment: RoleAssignmentData) -> str:
62+
if by == UserAssignmentsFilter.SCOPES:
63+
return assignment.scope.external_key
64+
elif by == UserAssignmentsFilter.ORGS:
65+
return assignment.scope.org
66+
else:
67+
raise ValueError(f"Invalid filter: '{by}'. Must be one of {[f.value for f in UserAssignmentsFilter]}")
68+
69+
filtered_users: list[UserAssignments] = []
70+
for uwa in users_with_assignments:
71+
if any(_get_value_to_filter(a) in values for a in uwa.assignments):
72+
# Also filter assignments to reflect the correct number of assignments
73+
filtered_assignments = [a for a in uwa.assignments if _get_value_to_filter(a) in values]
74+
filtered_users.append(UserAssignments(user=uwa.user, assignments=filtered_assignments))
75+
users_with_assignments = filtered_users
76+
77+
return filtered_users

openedx_authz/engine/matcher.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,24 @@
33
from django.contrib.auth import get_user_model
44
from edx_django_utils.cache import RequestCache
55

6-
from openedx_authz.api.data import ContentLibraryData, CourseOverviewData, ScopeData, UserData
7-
from openedx_authz.rest_api.utils import get_user_by_username_or_email
6+
from openedx_authz.api.data import (
7+
ContentLibraryData,
8+
CourseOverviewData,
9+
OrgContentLibraryGlobData,
10+
OrgCourseOverviewGlobData,
11+
ScopeData,
12+
UserData,
13+
)
14+
from openedx_authz.utils import get_user_by_username_or_email
815

916
User = get_user_model()
1017

1118

1219
SCOPES_WITH_ADMIN_OR_SUPERUSER_CHECK = {
1320
(ContentLibraryData.NAMESPACE, ContentLibraryData),
1421
(CourseOverviewData.NAMESPACE, CourseOverviewData),
22+
(OrgContentLibraryGlobData.NAMESPACE, OrgContentLibraryGlobData),
23+
(OrgCourseOverviewGlobData.NAMESPACE, OrgCourseOverviewGlobData),
1524
}
1625

1726

openedx_authz/rest_api/utils.py

Lines changed: 4 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
"""Utility functions for the Open edX AuthZ REST API."""
22

3-
from django.contrib.auth import get_user_model
4-
from django.db.models import Q
5-
6-
from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, ScopeData
3+
from openedx_authz.api.data import (
4+
GLOBAL_SCOPE_WILDCARD,
5+
ScopeData,
6+
)
77
from openedx_authz.rest_api.data import SearchField, SortField, SortOrder
88

9-
User = get_user_model()
10-
119

1210
def get_generic_scope(scope: ScopeData) -> ScopeData:
1311
"""
@@ -31,46 +29,6 @@ def get_generic_scope(scope: ScopeData) -> ScopeData:
3129
return ScopeData(namespaced_key=f"{scope.NAMESPACE}{ScopeData.SEPARATOR}{GLOBAL_SCOPE_WILDCARD}")
3230

3331

34-
def get_user_map(usernames: list[str]) -> dict[str, User]:
35-
"""
36-
Retrieve a dictionary mapping usernames to User objects for efficient batch lookups.
37-
38-
This function performs a single optimized database query to fetch multiple users,
39-
making it ideal for scenarios where we need to look up several users at once
40-
(e.g., when serializing multiple user role assignments).
41-
42-
Args:
43-
usernames (list[str]): List of usernames to retrieve. Duplicates are automatically
44-
handled by the database query.
45-
46-
Returns:
47-
dict[str, User]: Dictionary mapping each username to its corresponding User object.
48-
Only users that exist in the database are included in the returned dictionary.
49-
"""
50-
users = User.objects.filter(username__in=usernames).select_related("profile")
51-
return {user.username: user for user in users}
52-
53-
54-
def get_user_by_username_or_email(username_or_email: str) -> User:
55-
"""
56-
Retrieve a user by their username or email address.
57-
58-
Args:
59-
username_or_email (str): The username or email address to search for.
60-
61-
Returns:
62-
User: The User object if found and not retired.
63-
64-
Raises:
65-
User.DoesNotExist: If no user matches the provided username or email,
66-
or if the user has an associated retirement request.
67-
"""
68-
user = User.objects.get(Q(email=username_or_email) | Q(username=username_or_email))
69-
if hasattr(user, "userretirementrequest"):
70-
raise User.DoesNotExist
71-
return user
72-
73-
7432
def sort_users(
7533
users: list[dict],
7634
sort_by: SortField = SortField.USERNAME,

openedx_authz/rest_api/v1/fields.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ def to_representation(self, value):
1515
return ",".join(value).lower()
1616

1717

18+
class CaseSensitiveCommaSeparatedListField(serializers.CharField):
19+
"""Serializer for a comma-separated list of strings, case-sensitive."""
20+
21+
def to_internal_value(self, data):
22+
"""Convert string separated by commas to list of unique items preserving order"""
23+
return list(dict.fromkeys(item.strip() for item in data.split(",") if item.strip()))
24+
25+
def to_representation(self, value):
26+
"""Convert list to string separated by commas"""
27+
return ",".join(value)
28+
29+
1830
class LowercaseCharField(serializers.CharField):
1931
"""Serializer for a lowercase string."""
2032

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Custom DRF filter backends for the Open edX AuthZ REST API."""
2+
3+
from rest_framework.filters import BaseFilterBackend
4+
5+
from openedx_authz.rest_api.data import SortField, SortOrder
6+
from openedx_authz.rest_api.utils import filter_users, sort_users
7+
8+
9+
class TeamMemberSearchFilter(BaseFilterBackend):
10+
"""Filter team members by a search term."""
11+
12+
def filter_queryset(self, request, queryset, view):
13+
search = request.query_params.get("search")
14+
return filter_users(users=queryset, search=search, roles=None)
15+
16+
17+
class TeamMemberOrderingFilter(BaseFilterBackend):
18+
"""Sort team members by a given field and order."""
19+
20+
def filter_queryset(self, request, queryset, view):
21+
sort_by = request.query_params.get("sort_by", SortField.USERNAME)
22+
order = request.query_params.get("order", SortOrder.ASC)
23+
return sort_users(users=queryset, sort_by=sort_by, order=order)

0 commit comments

Comments
 (0)