Skip to content

Commit ef2d318

Browse files
committed
squash!: Implement superadmins special case
1 parent f3dfb18 commit ef2d318

5 files changed

Lines changed: 147 additions & 58 deletions

File tree

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: 34 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",
@@ -52,6 +58,7 @@
5258
"get_scopes_for_user_and_permission",
5359
"get_users_for_role_in_scope",
5460
"unassign_all_roles_from_user",
61+
"get_superadmins",
5562
]
5663

5764

@@ -376,3 +383,30 @@ def unassign_all_roles_from_user(user_external_key: str) -> bool:
376383
bool: True if any roles were removed, False otherwise.
377384
"""
378385
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/v1/serializers.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -287,24 +287,38 @@ class TeamMemberAssignmentSerializer(serializers.Serializer): # pylint: disable
287287
scope = serializers.SerializerMethodField()
288288
permission_count = serializers.SerializerMethodField()
289289

290-
def get_is_superadmin(self, obj: api.RoleAssignmentData) -> bool:
291-
"""Geth whether this assignment entry is for a superadmin"""
292-
return False # TODO
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)
293293

294-
def get_role(self, obj: api.RoleAssignmentData) -> str:
294+
def get_role(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> str:
295295
"""Get the role for the given role assignment."""
296-
return obj.roles[0].external_key if obj.roles else ""
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 ""
297301

298-
def get_org(self, obj: api.RoleAssignmentData) -> str:
302+
def get_org(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> str:
299303
"""Get the org for the given role assignment."""
300-
if isinstance(obj.scope, (api.ContentLibraryData, api.OrgContentLibraryGlobData)):
301-
return obj.scope.org
302-
return ""
304+
match obj:
305+
case api.SuperAdminAssignmentData():
306+
return "*"
307+
case api.RoleAssignmentData():
308+
return getattr(obj.scope, "org", None)
303309

304-
def get_scope(self, obj: api.RoleAssignmentData) -> str:
310+
def get_scope(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> str:
305311
"""Get the scope for the given role assignment."""
306-
return obj.scope.external_key
312+
match obj:
313+
case api.SuperAdminAssignmentData():
314+
return "*"
315+
case api.RoleAssignmentData():
316+
return obj.scope.external_key
307317

308-
def get_permission_count(self, obj: api.RoleAssignmentData) -> int:
318+
def get_permission_count(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> int | None:
309319
"""Get the permission count for the given role assignment."""
310-
return len(obj.roles[0].permissions) if obj.roles else 0
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/views.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
from rest_framework.views import APIView
2121

2222
from openedx_authz import api
23-
from openedx_authz.api.users import get_user_role_assignments_for_user_filtered
23+
from openedx_authz.api.data import RoleAssignmentData, SuperAdminAssignmentData
24+
from openedx_authz.api.users import get_superadmins, get_user_role_assignments_for_user_filtered
2425
from openedx_authz.api.utils import get_user_map
2526
from openedx_authz.constants import permissions
2627
from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus
@@ -629,15 +630,18 @@ def get(self, request: HttpRequest, username: str) -> Response:
629630
serializer.is_valid(raise_exception=True)
630631
query_params = serializer.validated_data
631632

632-
user_role_assignments = get_user_role_assignments_for_user_filtered(
633+
user_role_assignments: list[RoleAssignmentData | SuperAdminAssignmentData] = []
634+
635+
# Retrieve superadmin assignments (django staff or superuser users), as they always have access to everything
636+
user_role_assignments += get_superadmins(user_external_keys=[username])
637+
638+
user_role_assignments += get_user_role_assignments_for_user_filtered(
633639
user_external_key=username,
634640
orgs=query_params.get("orgs"),
635641
roles=query_params.get("roles"),
636642
allowed_for_user_external_key=request.user.username,
637643
)
638644

639-
# TODO if calling user has permission to see superadmins, get them here and prepand to user_role_assignments
640-
641645
assignments = TeamMemberAssignmentSerializer(user_role_assignments, many=True).data
642646
for backend in self.filter_backends:
643647
assignments = backend().filter_queryset(request, assignments, self)

openedx_authz/tests/rest_api/test_views.py

Lines changed: 64 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,7 +1318,14 @@ class TestTeamMemberAssignmentsAPIView(ViewTestMixin):
13181318
regular_7 (library_contributor), regular_8 (library_user)
13191319
13201320
URL: /authz/v1/users/<username>/assignments
1321-
Response fields per item: role, org, scope, permission_count
1321+
Response fields per item: is_superadmin, role, org, scope, permission_count
1322+
1323+
Superadmin entry:
1324+
admin_1..3 are staff/superusers. Querying any of them always prepends one
1325+
SuperAdminAssignmentData entry: role="django.superuser" (or "django.staff"),
1326+
org="*", scope="*", permission_count=None, is_superadmin=True.
1327+
This entry is always included regardless of org/role filters, since those
1328+
filters are applied only to the role assignments, not to the superadmin entry.
13221329
13231330
Visibility via filter_allowed_assignments:
13241331
- Staff/superuser: sees all assignments for any user
@@ -1344,26 +1351,30 @@ def _url(self, username: str) -> str:
13441351
# -------------------------------------------------------------------- #
13451352

13461353
@data(
1347-
# Staff/superuser sees all assignments for any user
1348-
("admin_1", "admin_1", 1),
1349-
("admin_1", "admin_2", 1),
1350-
("admin_1", "admin_3", 1),
1354+
# Staff/superuser targets get 1 superadmin entry + their role assignment(s)
1355+
("admin_1", "admin_1", 2), # superadmin entry + library_admin in Org1
1356+
("admin_1", "admin_2", 2), # superadmin entry + library_user in Org2
1357+
("admin_1", "admin_3", 2), # superadmin entry + library_admin in Org3
1358+
# Regular user targets get only their role assignments (no superadmin entry)
13511359
("admin_1", "regular_5", 1),
1352-
# regular_1 has VIEW_LIBRARY on Org1:LIB1 only → sees admin_1's Org1 assignment
1353-
("regular_1", "admin_1", 1),
1354-
# regular_1 cannot see admin_2's Org2 assignment
1355-
("regular_1", "admin_2", 0),
1356-
# regular_9 has no assignments → sees nothing for any user
1357-
("regular_9", "admin_1", 0),
1360+
# The superadmin entry is always included for superadmin targets, visible to all callers
1361+
("regular_1", "admin_1", 2), # superadmin entry + library_admin in Org1 (visible via Org1 access)
1362+
# regular_1 cannot see admin_2's Org2 role assignment, but superadmin entry is still included
1363+
("regular_1", "admin_2", 1), # superadmin entry only
1364+
# regular_9 has no assignments but superadmin entry is still included for admin targets
1365+
("regular_9", "admin_1", 1), # superadmin entry only
13581366
)
13591367
@unpack
13601368
def test_visibility_limited_to_accessible_scopes(self, caller: str, target: str, expected_count: int):
1361-
"""Calling user only sees assignments for scopes it has view access to.
1369+
"""Calling user only sees role assignments for scopes it has view access to.
1370+
1371+
The superadmin entry is always included when the target is a superadmin,
1372+
regardless of the calling user's permissions.
13621373
13631374
Expected result:
1364-
- Staff/superuser sees all assignments for any user.
1365-
- Regular users only see assignments in scopes they can view.
1366-
- Users with no assignments see no results for any user.
1375+
- Superadmin targets always include the superadmin entry.
1376+
- Role assignments are filtered by the calling user's permissions.
1377+
- Regular user targets return only their visible role assignments.
13671378
"""
13681379
self.client.force_authenticate(user=User.objects.get(username=caller))
13691380

@@ -1400,14 +1411,14 @@ def test_unknown_user_returns_empty(self):
14001411
# ------------------------------------------------------------------ #
14011412

14021413
@data(
1403-
# admin_3 has library_admin in lib:Org3:LIB3
1404-
("admin_3", "Org3", 1),
1405-
("admin_3", "Org1", 0),
1406-
# regular_5 has library_admin in lib:Org3:LIB3
1414+
# admin_3 has library_admin in lib:Org3:LIB3; superadmin entry is always included
1415+
("admin_3", "Org3", 2), # superadmin entry + Org3 role assignment
1416+
("admin_3", "Org1", 1), # superadmin entry only (no Org1 role assignment)
1417+
# regular_5 has library_admin in lib:Org3:LIB3 (no superadmin entry)
14071418
("regular_5", "Org3", 1),
14081419
("regular_5", "Org1", 0),
1409-
# non-existent org returns no results
1410-
("admin_1", "OrgX", 0),
1420+
# non-existent org: superadmin entry still included for admin targets
1421+
("admin_1", "OrgX", 1), # superadmin entry only
14111422
)
14121423
@unpack
14131424
def test_filter_by_orgs(self, target: str, orgs: str, expected_count: int):
@@ -1442,13 +1453,14 @@ def test_filter_by_multiple_orgs(self):
14421453
# ------------------------------------------------------------------ #
14431454

14441455
@data(
1445-
("admin_1", roles.LIBRARY_ADMIN.external_key, 1),
1446-
("admin_1", roles.LIBRARY_USER.external_key, 0),
1456+
# role filter applies only to role assignments; superadmin entry is always included for admin targets
1457+
("admin_1", roles.LIBRARY_ADMIN.external_key, 2), # superadmin entry + library_admin
1458+
("admin_1", roles.LIBRARY_USER.external_key, 1), # superadmin entry only
14471459
("regular_5", roles.LIBRARY_ADMIN.external_key, 1),
14481460
("regular_5", roles.LIBRARY_USER.external_key, 0),
14491461
("regular_6", roles.LIBRARY_AUTHOR.external_key, 1),
14501462
("regular_6", roles.LIBRARY_ADMIN.external_key, 0),
1451-
("admin_1", "non_existent_role", 0),
1463+
("admin_1", "non_existent_role", 1), # superadmin entry only
14521464
)
14531465
@unpack
14541466
def test_filter_by_roles(self, target: str, role_filter: str, expected_count: int):
@@ -1463,20 +1475,20 @@ def test_filter_by_roles(self, target: str, role_filter: str, expected_count: in
14631475
self.assertEqual(response.data["count"], expected_count)
14641476

14651477
def test_filter_by_multiple_roles(self):
1466-
"""Multiple roles are OR-combined.
1478+
"""Multiple roles are OR-combined for role assignments; superadmin entry always included.
14671479
14681480
Expected result:
1469-
- Returns assignments matching any of the given roles.
1481+
- Returns assignments matching any of the given roles, plus the superadmin entry.
14701482
"""
1471-
# regular_6 has library_author, regular_7 has library_contributor — use admin_3
1472-
# who has library_admin in Org3:LIB3; filter for admin + author returns 1
1483+
# admin_3 has library_admin in Org3:LIB3; filter for admin + author returns
1484+
# 1 role assignment + 1 superadmin entry = 2
14731485
response = self.client.get(
14741486
self._url("admin_3"),
14751487
{"roles": f"{roles.LIBRARY_ADMIN.external_key},{roles.LIBRARY_AUTHOR.external_key}"},
14761488
)
14771489

14781490
self.assertEqual(response.status_code, status.HTTP_200_OK)
1479-
self.assertEqual(response.data["count"], 1)
1491+
self.assertEqual(response.data["count"], 2)
14801492

14811493
# ------------------------------------------------------------------ #
14821494
# Sorting #
@@ -1566,24 +1578,35 @@ def test_pagination(self, query_params: dict, expected_page_count: int, has_next
15661578
def test_response_shape(self):
15671579
"""Each result item contains the expected fields.
15681580
1581+
admin_1 is a superuser, so the response contains two items:
1582+
- A superadmin entry with role="django.superuser", org="*", scope="*",
1583+
permission_count=None, is_superadmin=True
1584+
- A regular role assignment entry with concrete values and is_superadmin=False
1585+
15691586
Expected result:
15701587
- Returns 200 OK.
1571-
- Each item has role, org, scope, and permission_count.
1572-
- Values match the known assignment for admin_1.
1588+
- Each item has is_superadmin, role, org, scope, and permission_count.
15731589
"""
15741590
response = self.client.get(self._url("admin_1"))
15751591

15761592
self.assertEqual(response.status_code, status.HTTP_200_OK)
1577-
self.assertEqual(response.data["count"], 1)
1578-
item = response.data["results"][0]
1579-
self.assertIn("role", item)
1580-
self.assertIn("org", item)
1581-
self.assertIn("scope", item)
1582-
self.assertIn("permission_count", item)
1583-
self.assertEqual(item["role"], roles.LIBRARY_ADMIN.external_key)
1584-
self.assertEqual(item["org"], "Org1")
1585-
self.assertEqual(item["scope"], "lib:Org1:LIB1")
1586-
self.assertGreater(item["permission_count"], 0)
1593+
self.assertEqual(response.data["count"], 2)
1594+
1595+
superadmin_item = next(item for item in response.data["results"] if item["is_superadmin"])
1596+
self.assertIn(superadmin_item["role"], ("django.superuser", "django.staff"))
1597+
self.assertEqual(superadmin_item["org"], "*")
1598+
self.assertEqual(superadmin_item["scope"], "*")
1599+
self.assertIsNone(superadmin_item["permission_count"])
1600+
1601+
role_item = next(item for item in response.data["results"] if not item["is_superadmin"])
1602+
self.assertIn("role", role_item)
1603+
self.assertIn("org", role_item)
1604+
self.assertIn("scope", role_item)
1605+
self.assertIn("permission_count", role_item)
1606+
self.assertEqual(role_item["role"], roles.LIBRARY_ADMIN.external_key)
1607+
self.assertEqual(role_item["org"], "Org1")
1608+
self.assertEqual(role_item["scope"], "lib:Org1:LIB1")
1609+
self.assertGreater(role_item["permission_count"], 0)
15871610

15881611

15891612
@ddt

0 commit comments

Comments
 (0)