Skip to content

Commit 8e55e59

Browse files
authored
[FC-0099] feat: add rest api for roles and permissions (openedx#84)
1 parent cb10b9a commit 8e55e59

31 files changed

Lines changed: 2690 additions & 84 deletions

CHANGELOG.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,11 @@ Added
3333
* ADRs for key design decisions.
3434
* Casbin model (CONF) and engine layer for authorization.
3535
* Implementation of public API for roles and permissions management.
36+
37+
0.3.0 - 2025-10-10
38+
******************
39+
40+
Added
41+
=====
42+
43+
* Implementation of REST API for roles and permissions management.

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__ = "0.1.0"
7+
__version__ = "0.3.0"
88

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

openedx_authz/api/data.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
"""Data classes and enums for representing roles, permissions, and policies."""
22

33
import re
4+
from abc import abstractmethod
45
from enum import Enum
56
from typing import ClassVar, Literal, Type
67

78
from attrs import define
89
from opaque_keys import InvalidKeyError
910
from opaque_keys.edx.locator import LibraryLocatorV2
1011

12+
try:
13+
from openedx.core.djangoapps.content_libraries.models import ContentLibrary
14+
except ImportError:
15+
ContentLibrary = None
16+
1117
__all__ = [
1218
"UserData",
1319
"PermissionData",
@@ -18,10 +24,12 @@
1824
"RoleData",
1925
"ScopeData",
2026
"SubjectData",
27+
"ContentLibraryData",
2128
]
2229

2330
AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^"
2431
EXTERNAL_KEY_SEPARATOR = ":"
32+
GENERIC_SCOPE_WILDCARD = "*"
2533
NAMESPACED_KEY_PATTERN = rf"^.+{re.escape(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR)}.+$"
2634

2735

@@ -249,6 +257,20 @@ def get_subclass_by_external_key(mcs, external_key: str) -> Type["ScopeData"]:
249257

250258
return scope_subclass
251259

260+
@classmethod
261+
def get_all_namespaces(mcs) -> dict[str, Type["ScopeData"]]:
262+
"""Get all registered scope namespaces.
263+
264+
Returns:
265+
dict[str, Type["ScopeData"]]: A dictionary of all namespace prefixes registered in the scope registry.
266+
Each namespace corresponds to a ScopeData subclass (e.g., 'lib', 'sc').
267+
268+
Examples:
269+
>>> ScopeMeta.get_all_namespaces()
270+
{'sc': ScopeData, 'lib': ContentLibraryData, 'org': OrganizationData}
271+
"""
272+
return mcs.scope_registry
273+
252274
@classmethod
253275
def validate_external_key(mcs, external_key: str) -> bool:
254276
"""Validate the external_key format for the subclass.
@@ -301,6 +323,15 @@ def validate_external_key(cls, _: str) -> bool:
301323
"""
302324
return True
303325

326+
@abstractmethod
327+
def exists(self) -> bool:
328+
"""Check if the scope exists.
329+
330+
Returns:
331+
bool: True if the scope exists, False otherwise.
332+
"""
333+
raise NotImplementedError("Subclasses must implement exists method.")
334+
304335

305336
@define
306337
class ContentLibraryData(ScopeData):
@@ -355,6 +386,19 @@ def validate_external_key(cls, external_key: str) -> bool:
355386
except InvalidKeyError:
356387
return False
357388

389+
def exists(self) -> bool:
390+
"""Check if the content library exists.
391+
392+
Returns:
393+
bool: True if the content library exists, False otherwise.
394+
"""
395+
try:
396+
library_key = LibraryLocatorV2.from_string(self.library_id)
397+
ContentLibrary.objects.get_by_key(library_key=library_key)
398+
return True
399+
except ContentLibrary.DoesNotExist:
400+
return False
401+
358402
def __str__(self):
359403
"""Human readable string representation of the content library."""
360404
return self.library_id
@@ -562,6 +606,15 @@ class PermissionData:
562606
action: ActionData = None
563607
effect: Literal["allow", "deny"] = "allow"
564608

609+
@property
610+
def identifier(self) -> str:
611+
"""Get the permission identifier.
612+
613+
Returns:
614+
str: The permission identifier (e.g., 'delete_library').
615+
"""
616+
return self.action.external_key
617+
565618
def __str__(self):
566619
"""Human readable string representation of the permission and its effect."""
567620
return f"{self.action} - {self.effect}"
@@ -571,7 +624,7 @@ def __repr__(self):
571624
return f"{self.action.namespaced_key} => {self.effect}"
572625

573626

574-
@define
627+
@define(eq=False)
575628
class RoleData(AuthZData):
576629
"""A role is a named collection of permissions that can be assigned to subjects.
577630
@@ -600,6 +653,12 @@ class RoleData(AuthZData):
600653
NAMESPACE: ClassVar[str] = "role"
601654
permissions: list[PermissionData] = []
602655

656+
def __eq__(self, other):
657+
"""Compare roles based on their namespaced_key."""
658+
if not isinstance(other, RoleData):
659+
return False
660+
return self.namespaced_key == other.namespaced_key
661+
603662
@property
604663
def name(self) -> str:
605664
"""The human-readable name of the role (e.g., 'Library Admin', 'Course Instructor').
@@ -612,6 +671,14 @@ def name(self) -> str:
612671
"""
613672
return self.external_key.replace("_", " ").title()
614673

674+
def get_permission_identifiers(self) -> list[str]:
675+
"""Get the technical identifiers for all permissions in this role.
676+
677+
Returns:
678+
list[str]: Permission identifiers (e.g., ['delete_library', 'edit_content']).
679+
"""
680+
return [permission.identifier for permission in self.permissions]
681+
615682
def __str__(self):
616683
"""Human readable string representation of the role and its permissions."""
617684
return f"{self.name}: {', '.join(str(p) for p in self.permissions)}"

openedx_authz/api/permissions.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,7 @@ def get_all_permissions_in_scope(scope: ScopeData) -> list[PermissionData]:
4242
Returns:
4343
list of PermissionData: A list of PermissionData objects associated with the given scope.
4444
"""
45-
actions = enforcer.get_filtered_policy(
46-
PolicyIndex.SCOPE.value, scope.namespaced_key
47-
)
45+
actions = enforcer.get_filtered_policy(PolicyIndex.SCOPE.value, scope.namespaced_key)
4846
return [get_permission_from_policy(action) for action in actions]
4947

5048

@@ -63,6 +61,5 @@ def is_subject_allowed(
6361
Returns:
6462
bool: True if the subject has the specified permission in the scope, False otherwise.
6563
"""
66-
return enforcer.enforce(
67-
subject.namespaced_key, action.namespaced_key, scope.namespaced_key
68-
)
64+
enforcer.load_policy()
65+
return enforcer.enforce(subject.namespaced_key, action.namespaced_key, scope.namespaced_key)

openedx_authz/api/roles.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def get_permissions_for_active_roles_in_scope(
114114
dict[str, list[PermissionData]]: A dictionary mapping the role external_key to its
115115
permissions and scopes.
116116
"""
117+
enforcer.load_policy()
117118
filtered_policy = enforcer.get_filtered_grouping_policy(
118119
GroupingPolicyIndex.SCOPE.value, scope.namespaced_key
119120
)
@@ -145,6 +146,7 @@ def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]:
145146
Returns:
146147
list[Role]: A list of roles.
147148
"""
149+
enforcer.load_policy()
148150
policy_filtered = enforcer.get_filtered_policy(
149151
PolicyIndex.SCOPE.value, scope.namespaced_key
150152
)
@@ -190,21 +192,27 @@ def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]:
190192
Returns:
191193
list[list[str]]: A list of policies in the specified scope.
192194
"""
195+
enforcer.load_policy()
193196
return enforcer.get_filtered_grouping_policy(
194197
GroupingPolicyIndex.SCOPE.value, scope.namespaced_key
195198
)
196199

197200

198201
def assign_role_to_subject_in_scope(
199202
subject: SubjectData, role: RoleData, scope: ScopeData
200-
) -> None:
203+
) -> bool:
201204
"""Assign a role to a subject.
202205
203206
Args:
204207
subject: The ID of the subject.
205208
role: The role to assign.
209+
scope: The scope to assign the role to.
210+
211+
Returns:
212+
bool: True if the role was assigned successfully, False otherwise.
206213
"""
207-
enforcer.add_role_for_user_in_domain(
214+
enforcer.load_policy()
215+
return enforcer.add_role_for_user_in_domain(
208216
subject.namespaced_key,
209217
role.namespaced_key,
210218
scope.namespaced_key,
@@ -226,15 +234,19 @@ def batch_assign_role_to_subjects_in_scope(
226234

227235
def unassign_role_from_subject_in_scope(
228236
subject: SubjectData, role: RoleData, scope: ScopeData
229-
) -> None:
237+
) -> bool:
230238
"""Unassign a role from a subject.
231239
232240
Args:
233241
subject: The ID of the subject.
234242
role: The role to unassign.
235243
scope: The scope from which to unassign the role.
244+
245+
Returns:
246+
bool: True if the role was unassigned successfully, False otherwise.
236247
"""
237-
enforcer.delete_roles_for_user_in_domain(
248+
enforcer.load_policy()
249+
return enforcer.delete_roles_for_user_in_domain(
238250
subject.namespaced_key, role.namespaced_key, scope.namespaced_key
239251
)
240252

@@ -291,6 +303,7 @@ def get_subject_role_assignments_in_scope(
291303
Returns:
292304
list[RoleAssignmentData]: A list of role assignments for the subject in the scope.
293305
"""
306+
enforcer.load_policy()
294307
# TODO: we still need to get the remaining data for the role like email, etc
295308
role_assignments = []
296309
for namespaced_key in enforcer.get_roles_for_user_in_domain(
@@ -378,3 +391,17 @@ def get_all_subject_role_assignments_in_scope(
378391
)
379392

380393
return list(role_assignments_per_subject.values())
394+
395+
396+
def get_subjects_for_role(role: RoleData) -> list[SubjectData]:
397+
"""Get all the subjects assigned to a specific role.
398+
399+
Args:
400+
role (RoleData): The role to filter subjects.
401+
402+
Returns:
403+
list[SubjectData]: A list of subjects assigned to the specified role.
404+
"""
405+
enforcer.load_policy()
406+
policies = enforcer.get_filtered_grouping_policy(GroupingPolicyIndex.ROLE.value, role.namespaced_key)
407+
return [SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value]) for policy in policies]

openedx_authz/api/users.py

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
get_subject_role_assignments,
2020
get_subject_role_assignments_for_role_in_scope,
2121
get_subject_role_assignments_in_scope,
22+
get_subjects_for_role,
2223
unassign_role_from_subject_in_scope,
2324
)
2425

@@ -32,29 +33,29 @@
3233
"get_user_role_assignments_for_role_in_scope",
3334
"get_all_user_role_assignments_in_scope",
3435
"is_user_allowed",
36+
"get_users_for_role",
3537
]
3638

3739

38-
def assign_role_to_user_in_scope(
39-
user_external_key: str, role_external_key: str, scope_external_key: str
40-
) -> bool:
40+
def assign_role_to_user_in_scope(user_external_key: str, role_external_key: str, scope_external_key: str) -> bool:
4141
"""Assign a role to a user in a specific scope.
4242
4343
Args:
4444
user (str): ID of the user (e.g., 'john_doe').
4545
role_external_key (str): Name of the role to assign.
4646
scope (str): Scope in which to assign the role.
47+
48+
Returns:
49+
bool: True if the role was assigned successfully, False otherwise.
4750
"""
48-
assign_role_to_subject_in_scope(
51+
return assign_role_to_subject_in_scope(
4952
UserData(external_key=user_external_key),
5053
RoleData(external_key=role_external_key),
5154
ScopeData(external_key=scope_external_key),
5255
)
5356

5457

55-
def batch_assign_role_to_users_in_scope(
56-
users: list[str], role_external_key: str, scope_external_key: str
57-
):
58+
def batch_assign_role_to_users_in_scope(users: list[str], role_external_key: str, scope_external_key: str):
5859
"""Assign a role to multiple users in a specific scope.
5960
6061
Args:
@@ -70,26 +71,25 @@ def batch_assign_role_to_users_in_scope(
7071
)
7172

7273

73-
def unassign_role_from_user(
74-
user_external_key: str, role_external_key: str, scope_external_key: str
75-
):
74+
def unassign_role_from_user(user_external_key: str, role_external_key: str, scope_external_key: str):
7675
"""Unassign a role from a user in a specific scope.
7776
7877
Args:
7978
user_external_key (str): ID of the user (e.g., 'john_doe').
8079
role_external_key (str): Name of the role to unassign.
8180
scope_external_key (str): Scope in which to unassign the role.
81+
82+
Returns:
83+
bool: True if the role was unassigned successfully, False otherwise.
8284
"""
83-
unassign_role_from_subject_in_scope(
85+
return unassign_role_from_subject_in_scope(
8486
UserData(external_key=user_external_key),
8587
RoleData(external_key=role_external_key),
8688
ScopeData(external_key=scope_external_key),
8789
)
8890

8991

90-
def batch_unassign_role_from_users(
91-
users: list[str], role_external_key: str, scope_external_key: str
92-
):
92+
def batch_unassign_role_from_users(users: list[str], role_external_key: str, scope_external_key: str):
9393
"""Unassign a role from multiple users in a specific scope.
9494
9595
Args:
@@ -117,9 +117,7 @@ def get_user_role_assignments(user_external_key: str) -> list[RoleAssignmentData
117117
return get_subject_role_assignments(UserData(external_key=user_external_key))
118118

119119

120-
def get_user_role_assignments_in_scope(
121-
user_external_key: str, scope_external_key: str
122-
) -> list[RoleAssignmentData]:
120+
def get_user_role_assignments_in_scope(user_external_key: str, scope_external_key: str) -> list[RoleAssignmentData]:
123121
"""Get the roles assigned to a user in a specific scope.
124122
125123
Args:
@@ -164,9 +162,7 @@ def get_all_user_role_assignments_in_scope(
164162
Returns:
165163
list[RoleAssignmentData]: A list of user role assignments and all their metadata in the specified scope.
166164
"""
167-
return get_all_subject_role_assignments_in_scope(
168-
ScopeData(external_key=scope_external_key)
169-
)
165+
return get_all_subject_role_assignments_in_scope(ScopeData(external_key=scope_external_key))
170166

171167

172168
def is_user_allowed(
@@ -189,3 +185,16 @@ def is_user_allowed(
189185
ActionData(external_key=action_external_key),
190186
ScopeData(external_key=scope_external_key),
191187
)
188+
189+
190+
def get_users_for_role(role_external_key: str) -> list[UserData]:
191+
"""Get all the users assigned to a specific role.
192+
193+
Args:
194+
role_external_key (str): The role to filter users (e.g., 'library_admin').
195+
196+
Returns:
197+
list[UserData]: A list of users assigned to the specified role.
198+
"""
199+
users = get_subjects_for_role(RoleData(external_key=role_external_key))
200+
return [UserData(namespaced_key=user.namespaced_key) for user in users]

0 commit comments

Comments
 (0)