Skip to content

Commit a710eea

Browse files
committed
feat: Implement team members endpoint for admin console
feat: Implement team members endpoint for admin console
1 parent ef8b1d1 commit a710eea

15 files changed

Lines changed: 768 additions & 30 deletions

File tree

CHANGELOG.rst

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

17+
1.3.0 - 2026-04-01
18+
******************
19+
20+
Added
21+
=====
22+
1723
* Add ADR for global scope support for role assignments.
24+
* Add ``get_all_subject_role_assignments`` and ``get_all_user_role_assignments`` api functions to fetch all user role assignments.
25+
* 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.
26+
27+
Fixed
28+
=====
29+
30+
* Fix enforcer ``is_admin_or_superuser_check`` that was not taking into account Org glob scopes.
1831

1932
1.2.0 - 2026-03-30
2033
******************

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.2.0"
7+
__version__ = "1.3.0"
88

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

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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
assign_role_to_subject_in_scope,
2323
batch_assign_role_to_subjects_in_scope,
2424
batch_unassign_role_from_subjects_in_scope,
25+
get_all_subject_role_assignments,
2526
get_all_subject_role_assignments_in_scope,
2627
get_role_assignments,
2728
get_scopes_for_subject_and_permission,
@@ -38,6 +39,7 @@
3839
"batch_assign_role_to_users_in_scope",
3940
"unassign_role_from_user",
4041
"batch_unassign_role_from_users",
42+
"get_all_user_role_assignments",
4143
"get_user_role_assignments",
4244
"get_user_role_assignments_in_scope",
4345
"get_user_role_assignments_for_role_in_scope",
@@ -118,6 +120,15 @@ def batch_unassign_role_from_users(users: list[str], role_external_key: str, sco
118120
)
119121

120122

123+
def get_all_user_role_assignments() -> list[RoleAssignmentData]:
124+
"""Get all roles for all users across all scopes.
125+
126+
Returns:
127+
list[RoleAssignmentData]: A list of role assignments and all their metadata assigned to the user.
128+
"""
129+
return get_all_subject_role_assignments()
130+
131+
121132
def get_user_role_assignments(user_external_key: str) -> list[RoleAssignmentData]:
122133
"""Get all roles for a user across all scopes.
123134

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: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
11
"""Utility functions for the Open edX AuthZ REST API."""
22

3+
import logging
4+
from dataclasses import dataclass
5+
36
from django.contrib.auth import get_user_model
4-
from django.db.models import Q
57

6-
from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, ScopeData
8+
from openedx_authz.api.data import (
9+
GLOBAL_SCOPE_WILDCARD,
10+
ContentLibraryData,
11+
CourseOverviewData,
12+
OrgContentLibraryGlobData,
13+
OrgCourseOverviewGlobData,
14+
RoleAssignmentData,
15+
ScopeData,
16+
)
17+
from openedx_authz.api.users import is_user_allowed
18+
from openedx_authz.constants.permissions import COURSES_VIEW_COURSE, VIEW_LIBRARY
719
from openedx_authz.rest_api.data import SearchField, SortField, SortOrder
820

21+
logger = logging.getLogger(__name__)
22+
923
User = get_user_model()
1024

1125

@@ -51,26 +65,6 @@ def get_user_map(usernames: list[str]) -> dict[str, User]:
5165
return {user.username: user for user in users}
5266

5367

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-
7468
def sort_users(
7569
users: list[dict],
7670
sort_by: SortField = SortField.USERNAME,
@@ -135,3 +129,49 @@ def filter_users(users: list[dict], search: str | None, roles: list[str] | None)
135129
filtered_users.append(user)
136130

137131
return filtered_users
132+
133+
134+
@dataclass
135+
class UserAssignments:
136+
user: "User"
137+
assignments: list[RoleAssignmentData]
138+
139+
140+
def get_user_assignment_map(role_assignments: list[RoleAssignmentData]) -> list[UserAssignments]:
141+
"""
142+
Group role assignments by user
143+
"""
144+
usernames = {assignment.subject.username for assignment in role_assignments}
145+
user_map = get_user_map(usernames)
146+
147+
users_with_assignments: list[UserAssignments] = []
148+
149+
for username, user in user_map.items():
150+
assignments = [a for a in role_assignments if a.subject.username == username]
151+
users_with_assignments.append(UserAssignments(user=user, assignments=assignments))
152+
153+
return users_with_assignments
154+
155+
156+
def filter_allowed_assignments(user: "User", assignments: list[RoleAssignmentData]) -> list[RoleAssignmentData]:
157+
"""
158+
Filter the given role assignments to only include those that the user has permission to view.
159+
"""
160+
allowed_assignments: list[RoleAssignmentData] = []
161+
for assignment in assignments:
162+
permission = None
163+
164+
# For CourseOverviewData and ContentLibraryData, check for the view permission
165+
if isinstance(assignment.scope, (CourseOverviewData, OrgCourseOverviewGlobData)):
166+
permission = COURSES_VIEW_COURSE.identifier
167+
elif isinstance(assignment.scope, (ContentLibraryData, OrgContentLibraryGlobData)):
168+
permission = VIEW_LIBRARY.identifier
169+
170+
if permission and is_user_allowed(
171+
user_external_key=user.username,
172+
action_external_key=permission,
173+
scope_external_key=assignment.scope.external_key,
174+
):
175+
allowed_assignments.append(assignment)
176+
177+
return allowed_assignments

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

openedx_authz/rest_api/v1/serializers.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@
55

66
from openedx_authz import api
77
from openedx_authz.rest_api.data import SortField, SortOrder
8-
from openedx_authz.rest_api.utils import get_generic_scope
9-
from openedx_authz.rest_api.v1.fields import CommaSeparatedListField, LowercaseCharField
8+
from openedx_authz.rest_api.utils import UserAssignments, get_generic_scope
9+
from openedx_authz.rest_api.v1.fields import (
10+
CaseSensitiveCommaSeparatedListField,
11+
CommaSeparatedListField,
12+
LowercaseCharField,
13+
)
1014

1115
User = get_user_model()
1216

@@ -203,3 +207,46 @@ def get_email(self, obj) -> str:
203207
def get_roles(self, obj: api.RoleAssignmentData) -> list[str]:
204208
"""Get the roles for the given role assignment."""
205209
return [role.external_key for role in obj.roles]
210+
211+
212+
class ListTeamMembersSerializer(serializers.Serializer): # pylint: disable=abstract-method
213+
"""Serializer for listing team members."""
214+
215+
scopes = CaseSensitiveCommaSeparatedListField(required=False, default=[])
216+
orgs = CaseSensitiveCommaSeparatedListField(required=False, default=[])
217+
sort_by = serializers.ChoiceField(
218+
required=False,
219+
choices=[(e.value, e.name) for e in SortField],
220+
default=SortField.USERNAME,
221+
)
222+
order = serializers.ChoiceField(
223+
required=False,
224+
choices=[(e.value, e.name) for e in SortOrder],
225+
default=SortOrder.ASC,
226+
)
227+
search = LowercaseCharField(required=False, default=None)
228+
229+
230+
class TeamMemberSerializer(serializers.Serializer): # pylint: disable=abstract-method
231+
"""Serializer for team members."""
232+
233+
username = serializers.SerializerMethodField()
234+
full_name = serializers.SerializerMethodField()
235+
email = serializers.SerializerMethodField()
236+
assignation_count = serializers.SerializerMethodField()
237+
238+
def get_username(self, obj: UserAssignments) -> str:
239+
"""Get the username for the given role assignment."""
240+
return getattr(obj.user, "username", "") if obj.user else ""
241+
242+
def get_full_name(self, obj: UserAssignments) -> str:
243+
"""Get the full name for the given role assignment."""
244+
return obj.user.get_full_name() if obj.user else ""
245+
246+
def get_email(self, obj: UserAssignments) -> str:
247+
"""Get the email for the given role assignment."""
248+
return getattr(obj.user, "email", "") if obj.user else ""
249+
250+
def get_assignation_count(self, obj: UserAssignments) -> int:
251+
"""Get the assignation count for the given role assignment."""
252+
return len(obj.assignments)

openedx_authz/rest_api/v1/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
),
1313
path("roles/", views.RoleListView.as_view(), name="role-list"),
1414
path("roles/users/", views.RoleUserAPIView.as_view(), name="role-user-list"),
15+
path("users/", views.TeamMembersAPIView.as_view(), name="user-list"),
1516
]

0 commit comments

Comments
 (0)