Skip to content

Commit 3aa30b5

Browse files
committed
squash!: Refactor to define admin view permissions in data classes
1 parent 7072655 commit 3aa30b5

7 files changed

Lines changed: 211 additions & 197 deletions

File tree

openedx_authz/api/data.py

Lines changed: 64 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.

openedx_authz/api/users.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,6 @@
1111

1212
from openedx_authz.api.data import (
1313
ActionData,
14-
ContentLibraryData,
15-
CourseOverviewData,
16-
OrgContentLibraryGlobData,
17-
OrgCourseOverviewGlobData,
1814
PermissionData,
1915
RoleAssignmentData,
2016
RoleData,
@@ -39,7 +35,6 @@
3935
unassign_subject_from_all_roles,
4036
)
4137
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
4338

4439
__all__ = [
4540
"assign_role_to_user_in_scope",
@@ -224,11 +219,8 @@ def _filter_allowed_assignments(
224219
for assignment in assignments:
225220
permission = None
226221

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
222+
# Get the permission needed to view the specific scope in the admin console
223+
permission = assignment.scope.get_admin_view_permission().identifier
232224

233225
if permission and is_user_allowed(
234226
user_external_key=user_external_key,

openedx_authz/api/utils.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,17 @@ def filter_user_assignments(
5454
) -> list[UserAssignments]:
5555
"""
5656
Filter user assignments by orgs or scopes.
57+
58+
Returns a list of users that have at least one assignment matching the filters,
59+
with only the matching assignments for each matching user.
60+
61+
Args:
62+
users_with_assignments (list[UserAssignments]): The list of users with their role assignments.
63+
by (UserAssignmentsFilter): The filter type (by orgs or scopes).
64+
values (list[str]): The list of orgs or scopes to filter by.
65+
66+
Returns:
67+
list[UserAssignments]: The filtered list of users with their role assignments.
5768
"""
5869
if not values:
5970
return users_with_assignments
@@ -62,7 +73,7 @@ def _get_value_to_filter(assignment: RoleAssignmentData) -> str:
6273
if by == UserAssignmentsFilter.SCOPES:
6374
return assignment.scope.external_key
6475
elif by == UserAssignmentsFilter.ORGS:
65-
return assignment.scope.org
76+
return getattr(assignment.scope, "org", None)
6677
else:
6778
raise ValueError(f"Invalid filter: '{by}'. Must be one of {[f.value for f in UserAssignmentsFilter]}")
6879

openedx_authz/constants/permissions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Default permission constants.
33
"""
44

5-
from openedx_authz.api.data import ActionData, PermissionData
5+
from openedx_authz.data import ActionData, PermissionData
66

77
# Content Library Permissions
88

0 commit comments

Comments
 (0)