Skip to content

Commit 2b732c3

Browse files
authored
feat: Implement team members endpoint for admin console (#244)
* feat: Implement team members endpoint for admin console
1 parent 8fc8733 commit 2b732c3

20 files changed

Lines changed: 1030 additions & 226 deletions

CHANGELOG.rst

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

17+
1.5.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.4.0 - 2026-04-09
1831
******************
1932

33+
Added
34+
=====
35+
2036
* Add ``orgs/`` endpoint to list and search orgs, with pagination, as required for filters in the Admin Console.
2137

2238
1.3.0 2026-04-08
2339
****************
2440

41+
Added
42+
=====
43+
2544
* Add stub CCX_COACH role/ CCXCourseOverviewData scope to prevent errors when working with CCX courses.
2645
* Add ADR for global scope support for role assignments.
2746

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.4.0"
7+
__version__ = "1.5.0"
88

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

openedx_authz/api/data.py

Lines changed: 79 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,25 @@
66
from abc import abstractmethod
77
from enum import Enum
88
from functools import cached_property
9-
from typing import Any, ClassVar, Literal, Type
9+
from typing import Any, ClassVar, Type
1010

1111
from attrs import define
1212
from opaque_keys import InvalidKeyError
1313
from opaque_keys.edx.keys import CourseKey
1414
from opaque_keys.edx.locator import LibraryLocatorV2
1515
from organizations.models import Organization
1616

17+
from openedx_authz.constants.permissions import COURSES_VIEW_COURSE_TEAM, VIEW_LIBRARY_TEAM
18+
from openedx_authz.data import AUTHZ_POLICY_ATTRIBUTES_SEPARATOR, ActionData, AuthzBaseClass, AuthZData, PermissionData
1719
from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model
1820

1921
ContentLibrary = get_content_library_model()
2022
CourseOverview = get_course_overview_model()
2123

2224
__all__ = [
2325
"ActionData",
26+
"AuthZData",
27+
"AuthzBaseClass",
2428
"ContentLibraryData",
2529
"CourseOverviewData",
2630
"GroupingPolicyIndex",
@@ -36,7 +40,6 @@
3640
"UserData",
3741
]
3842

39-
AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^"
4043
EXTERNAL_KEY_SEPARATOR = ":"
4144
GLOBAL_SCOPE_WILDCARD = "*"
4245
NAMESPACED_KEY_PATTERN = rf"^.+{re.escape(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR)}.+$"
@@ -86,65 +89,6 @@ class PolicyIndex(Enum):
8689
# The rest of the fields are optional and can be ignored for now
8790

8891

89-
class AuthzBaseClass:
90-
"""Base class for all authz classes.
91-
92-
Attributes:
93-
SEPARATOR: The separator between the namespace and the identifier (default: '^').
94-
NAMESPACE: The namespace prefix for the data type (e.g., 'user', 'role', 'act', 'lib').
95-
"""
96-
97-
SEPARATOR: ClassVar[str] = AUTHZ_POLICY_ATTRIBUTES_SEPARATOR
98-
NAMESPACE: ClassVar[str] = None
99-
100-
101-
@define
102-
class AuthZData(AuthzBaseClass):
103-
"""Base class for all authz data classes.
104-
105-
Attributes:
106-
NAMESPACE: The namespace prefix for the data type (e.g., 'user', 'role', 'act', 'lib').
107-
SEPARATOR: The separator between the namespace and the identifier (default: '^').
108-
external_key: The ID for the object outside of the authz system (e.g., 'john_doe' for a user,
109-
'instructor' for a role, 'lib:DemoX:CSPROB' for a content library).
110-
namespaced_key: The ID for the object within the authz system, combining namespace and external_key
111-
(e.g., 'user^john_doe', 'role^instructor', 'lib^lib:DemoX:CSPROB').
112-
113-
Examples:
114-
>>> user = UserData(external_key='john_doe')
115-
>>> user.namespaced_key
116-
'user^john_doe'
117-
>>> role = RoleData(namespaced_key='role^instructor')
118-
>>> role.external_key
119-
'instructor'
120-
"""
121-
122-
external_key: str = ""
123-
namespaced_key: str = ""
124-
125-
def __attrs_post_init__(self):
126-
"""Post-initialization processing for attributes.
127-
128-
This method ensures that either external_key or namespaced_key is provided,
129-
and derives the other attribute based on the NAMESPACE and SEPARATOR.
130-
"""
131-
if not self.NAMESPACE:
132-
# No namespace defined, nothing to do
133-
return
134-
135-
if not self.external_key and not self.namespaced_key:
136-
raise ValueError("Either external_key or namespaced_key must be provided.")
137-
138-
# Case 1: Initialized with external_key only, derive namespaced_key
139-
if not self.namespaced_key:
140-
self.namespaced_key = f"{self.NAMESPACE}{self.SEPARATOR}{self.external_key}"
141-
142-
# Case 2: Initialized with namespaced_key only, derive external_key. Assume valid format for
143-
# namespaced_key at this point.
144-
if not self.external_key:
145-
self.external_key = self.namespaced_key.split(self.SEPARATOR, 1)[1]
146-
147-
14892
class ScopeMeta(type):
14993
"""Metaclass for ScopeData to handle dynamic subclass instantiation based on namespace."""
15094

@@ -397,6 +341,20 @@ def validate_external_key(cls, _: str) -> bool:
397341
"""
398342
return True
399343

344+
@classmethod
345+
@abstractmethod
346+
def get_admin_view_permission(cls) -> PermissionData:
347+
"""Get the permission required to view this scope
348+
349+
This method should be implemented on every ScopeData subclass to define
350+
which permission to check against when a user tries to see assignations
351+
related to this scope in the Admin Console.
352+
353+
Returns:
354+
PermissionData: The permission required to view this scope in the admin console.
355+
"""
356+
raise NotImplementedError("Subclasses must implement get_admin_view_permission method.")
357+
400358
@abstractmethod
401359
def get_object(self) -> Any | None:
402360
"""Retrieve the underlying domain object that this scope represents.
@@ -494,6 +452,15 @@ def validate_external_key(cls, external_key: str) -> bool:
494452
except InvalidKeyError:
495453
return False
496454

455+
@classmethod
456+
def get_admin_view_permission(cls) -> PermissionData:
457+
"""Get the permission required to view this scope
458+
459+
Returns:
460+
PermissionData: The permission required to view this scope in the admin console.
461+
"""
462+
return VIEW_LIBRARY_TEAM
463+
497464
def get_object(self) -> ContentLibrary | None:
498465
"""Retrieve the ContentLibrary instance associated with this scope.
499466
@@ -607,6 +574,15 @@ def validate_external_key(cls, external_key: str) -> bool:
607574
except InvalidKeyError:
608575
return False
609576

577+
@classmethod
578+
def get_admin_view_permission(cls) -> PermissionData:
579+
"""Get the permission required to view this scope
580+
581+
Returns:
582+
PermissionData: The permission required to view this scope in the admin console.
583+
"""
584+
return COURSES_VIEW_COURSE_TEAM
585+
610586
def get_object(self) -> CourseOverview | None:
611587
"""Retrieve the CourseOverview instance associated with this scope.
612588
@@ -710,6 +686,15 @@ def validate_external_key(cls, external_key: str) -> bool:
710686

711687
return True
712688

689+
@classmethod
690+
def get_admin_view_permission(cls) -> PermissionData:
691+
"""Get the permission required to view this scope
692+
693+
Returns:
694+
PermissionData: The permission required to view this scope in the admin console.
695+
"""
696+
raise NotImplementedError("Subclasses must implement get_admin_view_permission method.")
697+
713698
@classmethod
714699
def get_org(cls, external_key: str) -> str | None:
715700
"""Extract the organization identifier from the glob pattern.
@@ -799,6 +784,15 @@ class OrgContentLibraryGlobData(OrgGlobData):
799784
NAMESPACE: ClassVar[str] = "lib"
800785
ID_SEPARATOR: ClassVar[str] = ":"
801786

787+
@classmethod
788+
def get_admin_view_permission(cls) -> PermissionData:
789+
"""Get the permission required to view this scope
790+
791+
Returns:
792+
PermissionData: The permission required to view this scope in the admin console.
793+
"""
794+
return VIEW_LIBRARY_TEAM
795+
802796

803797
@define
804798
class OrgCourseOverviewGlobData(OrgGlobData):
@@ -839,6 +833,15 @@ class OrgCourseOverviewGlobData(OrgGlobData):
839833
NAMESPACE: ClassVar[str] = "course-v1"
840834
ID_SEPARATOR: ClassVar[str] = "+"
841835

836+
@classmethod
837+
def get_admin_view_permission(cls) -> PermissionData:
838+
"""Get the permission required to view this scope
839+
840+
Returns:
841+
PermissionData: The permission required to view this scope in the admin console.
842+
"""
843+
return COURSES_VIEW_COURSE_TEAM
844+
842845

843846
class CCXCourseOverviewData(CourseOverviewData):
844847
"""CCX course scope for authorization in the Open edX platform.
@@ -994,117 +997,6 @@ def __repr__(self):
994997
return self.namespaced_key
995998

996999

997-
@define
998-
class ActionData(AuthZData):
999-
"""An action represents an operation that can be performed in the authorization system.
1000-
1001-
Actions are the operations that can be allowed or denied in authorization policies.
1002-
1003-
Attributes:
1004-
NAMESPACE: 'act' for actions.
1005-
external_key: The action identifier (e.g., 'content_libraries.view_library').
1006-
namespaced_key: The action identifier with namespace (e.g., 'act^content_libraries.view_library').
1007-
name: Property that returns a human-readable action name (e.g., 'Content Libraries > View Library').
1008-
1009-
Examples:
1010-
>>> action = ActionData(external_key='content_libraries.delete_library')
1011-
>>> action.namespaced_key
1012-
'act^content_libraries.delete_library'
1013-
>>> action.name
1014-
'Content Libraries > Delete Library'
1015-
"""
1016-
1017-
NAMESPACE: ClassVar[str] = "act"
1018-
1019-
@property
1020-
def name(self) -> str:
1021-
"""The human-readable name of the action (e.g., 'Content Libraries > Delete Library').
1022-
1023-
This property transforms the external_key into a human-readable display name
1024-
by replacing dots with ' > ' and capitalizing each word.
1025-
1026-
Returns:
1027-
str: The human-readable action name (e.g., 'Content Libraries > Delete Library').
1028-
"""
1029-
parts = self.external_key.split(".")
1030-
return " > ".join(part.replace("_", " ").title() for part in parts)
1031-
1032-
def __str__(self):
1033-
"""Human readable string representation of the action."""
1034-
return self.name
1035-
1036-
def __repr__(self):
1037-
"""Developer friendly string representation of the action."""
1038-
return self.namespaced_key
1039-
1040-
1041-
@define
1042-
class PermissionData:
1043-
"""A permission combines an action with an effect (allow or deny).
1044-
1045-
Permissions define whether a specific action should be allowed or denied.
1046-
They are typically associated with roles in the authorization system.
1047-
1048-
Attributes:
1049-
action: The action being permitted or denied (ActionData instance).
1050-
effect: The effect of the permission, either 'allow' or 'deny' (default: 'allow').
1051-
1052-
Examples:
1053-
>>> read_action = ActionData(external_key='read')
1054-
>>> permission = PermissionData(action=read_action, effect='allow')
1055-
>>> str(permission)
1056-
'Read - allow'
1057-
>>> write_action = ActionData(external_key='write')
1058-
>>> deny_perm = PermissionData(action=write_action, effect='deny')
1059-
>>> str(deny_perm)
1060-
'Write - deny'
1061-
"""
1062-
1063-
action: ActionData = None
1064-
effect: Literal["allow", "deny"] = "allow"
1065-
1066-
@property
1067-
def identifier(self) -> str:
1068-
"""Get the permission identifier.
1069-
1070-
Returns:
1071-
str: The permission identifier (e.g., 'content_libraries.delete_library').
1072-
"""
1073-
return self.action.external_key
1074-
1075-
def __eq__(self, other: "PermissionData") -> bool:
1076-
"""Compare permissions based on their action identifier.
1077-
1078-
Two PermissionData instances are considered equal if they have the same action's
1079-
external_key and effect.
1080-
1081-
Args:
1082-
other: Another PermissionData instance or any object.
1083-
1084-
Returns:
1085-
bool: True if the actions match, False otherwise.
1086-
1087-
Example:
1088-
>>> perm1 = PermissionData(action=ActionData(external_key='view'), effect='allow')
1089-
>>> perm2 = PermissionData(action=ActionData(external_key='view'), effect='allow')
1090-
>>> perm1 == perm2 # True - same action and effect
1091-
True
1092-
>>> perm1 in [perm2] # Uses __eq__
1093-
True
1094-
"""
1095-
if self.action is None or other.action is None:
1096-
return False
1097-
return self.action.external_key == other.action.external_key and self.effect == other.effect
1098-
1099-
def __str__(self):
1100-
"""Human readable string representation of the permission and its effect."""
1101-
return f"{self.action} - {self.effect}"
1102-
1103-
def __repr__(self):
1104-
"""Developer friendly string representation of the permission."""
1105-
return f"{self.action.namespaced_key} => {self.effect}"
1106-
1107-
11081000
@define(eq=False)
11091001
class RoleData(AuthZData):
11101002
"""A role is a named collection of permissions that can be assigned to subjects.
@@ -1207,3 +1099,18 @@ def __repr__(self):
12071099
"""Developer friendly string representation of the role assignment."""
12081100
role_keys = ", ".join(role.namespaced_key for role in self.roles)
12091101
return f"{self.subject.namespaced_key} => [{role_keys}] @ {self.scope.namespaced_key}"
1102+
1103+
1104+
@define
1105+
class UserAssignments:
1106+
"""A user with their role assignments"""
1107+
1108+
user: "User"
1109+
assignments: list[RoleAssignmentData]
1110+
1111+
1112+
class UserAssignmentsFilter(Enum):
1113+
"""Enum for the filters that can be applied over UserAssignments."""
1114+
1115+
SCOPES = "scopes"
1116+
ORGS = "orgs"

0 commit comments

Comments
 (0)