|
6 | 6 | from abc import abstractmethod |
7 | 7 | from enum import Enum |
8 | 8 | from functools import cached_property |
9 | | -from typing import Any, ClassVar, Literal, Type |
| 9 | +from typing import Any, ClassVar, Type |
10 | 10 |
|
11 | 11 | from attrs import define |
12 | 12 | from opaque_keys import InvalidKeyError |
13 | 13 | from opaque_keys.edx.keys import CourseKey |
14 | 14 | from opaque_keys.edx.locator import LibraryLocatorV2 |
15 | 15 | from organizations.models import Organization |
16 | 16 |
|
| 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 |
17 | 19 | from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model |
18 | 20 |
|
19 | 21 | ContentLibrary = get_content_library_model() |
20 | 22 | CourseOverview = get_course_overview_model() |
21 | 23 |
|
22 | 24 | __all__ = [ |
23 | 25 | "ActionData", |
| 26 | + "AuthZData", |
| 27 | + "AuthzBaseClass", |
24 | 28 | "ContentLibraryData", |
25 | 29 | "CourseOverviewData", |
26 | 30 | "GroupingPolicyIndex", |
|
36 | 40 | "UserData", |
37 | 41 | ] |
38 | 42 |
|
39 | | -AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^" |
40 | 43 | EXTERNAL_KEY_SEPARATOR = ":" |
41 | 44 | GLOBAL_SCOPE_WILDCARD = "*" |
42 | 45 | NAMESPACED_KEY_PATTERN = rf"^.+{re.escape(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR)}.+$" |
@@ -86,65 +89,6 @@ class PolicyIndex(Enum): |
86 | 89 | # The rest of the fields are optional and can be ignored for now |
87 | 90 |
|
88 | 91 |
|
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 | | - |
148 | 92 | class ScopeMeta(type): |
149 | 93 | """Metaclass for ScopeData to handle dynamic subclass instantiation based on namespace.""" |
150 | 94 |
|
@@ -397,6 +341,20 @@ def validate_external_key(cls, _: str) -> bool: |
397 | 341 | """ |
398 | 342 | return True |
399 | 343 |
|
| 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 | + |
400 | 358 | @abstractmethod |
401 | 359 | def get_object(self) -> Any | None: |
402 | 360 | """Retrieve the underlying domain object that this scope represents. |
@@ -494,6 +452,15 @@ def validate_external_key(cls, external_key: str) -> bool: |
494 | 452 | except InvalidKeyError: |
495 | 453 | return False |
496 | 454 |
|
| 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 | + |
497 | 464 | def get_object(self) -> ContentLibrary | None: |
498 | 465 | """Retrieve the ContentLibrary instance associated with this scope. |
499 | 466 |
|
@@ -607,6 +574,15 @@ def validate_external_key(cls, external_key: str) -> bool: |
607 | 574 | except InvalidKeyError: |
608 | 575 | return False |
609 | 576 |
|
| 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 | + |
610 | 586 | def get_object(self) -> CourseOverview | None: |
611 | 587 | """Retrieve the CourseOverview instance associated with this scope. |
612 | 588 |
|
@@ -710,6 +686,15 @@ def validate_external_key(cls, external_key: str) -> bool: |
710 | 686 |
|
711 | 687 | return True |
712 | 688 |
|
| 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 | + |
713 | 698 | @classmethod |
714 | 699 | def get_org(cls, external_key: str) -> str | None: |
715 | 700 | """Extract the organization identifier from the glob pattern. |
@@ -799,6 +784,15 @@ class OrgContentLibraryGlobData(OrgGlobData): |
799 | 784 | NAMESPACE: ClassVar[str] = "lib" |
800 | 785 | ID_SEPARATOR: ClassVar[str] = ":" |
801 | 786 |
|
| 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 | + |
802 | 796 |
|
803 | 797 | @define |
804 | 798 | class OrgCourseOverviewGlobData(OrgGlobData): |
@@ -839,6 +833,15 @@ class OrgCourseOverviewGlobData(OrgGlobData): |
839 | 833 | NAMESPACE: ClassVar[str] = "course-v1" |
840 | 834 | ID_SEPARATOR: ClassVar[str] = "+" |
841 | 835 |
|
| 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 | + |
842 | 845 |
|
843 | 846 | class CCXCourseOverviewData(CourseOverviewData): |
844 | 847 | """CCX course scope for authorization in the Open edX platform. |
@@ -994,117 +997,6 @@ def __repr__(self): |
994 | 997 | return self.namespaced_key |
995 | 998 |
|
996 | 999 |
|
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 | | - |
1108 | 1000 | @define(eq=False) |
1109 | 1001 | class RoleData(AuthZData): |
1110 | 1002 | """A role is a named collection of permissions that can be assigned to subjects. |
@@ -1207,3 +1099,18 @@ def __repr__(self): |
1207 | 1099 | """Developer friendly string representation of the role assignment.""" |
1208 | 1100 | role_keys = ", ".join(role.namespaced_key for role in self.roles) |
1209 | 1101 | 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