Skip to content

Commit ce74be1

Browse files
committed
feat: add rest api for roles and permissions
1 parent b7dbf55 commit ce74be1

22 files changed

Lines changed: 1322 additions & 37 deletions

openedx_authz/api/data.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,3 +416,16 @@ class RoleAssignmentData(AuthZData):
416416
subject: SubjectData = None # Needs defaults to avoid value error from attrs
417417
roles: list[RoleData] = []
418418
scope: ScopeData = None
419+
420+
421+
@define
422+
class SubjectRoleAssignmentData(AuthZData):
423+
"""A subject role assignment is the assignment of one or more roles to a subject in a specific scope.
424+
425+
Attributes:
426+
subject: The ID of the subject namespaced (e.g., 'user@john_doe').
427+
roles: The roles assigned to the subject.
428+
"""
429+
430+
subject: SubjectData = None
431+
roles: list[RoleData] = None

openedx_authz/api/permissions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def is_subject_allowed(
6363
Returns:
6464
bool: True if the subject has the specified permission in the scope, False otherwise.
6565
"""
66+
enforcer.load_policy()
6667
return enforcer.enforce(
6768
subject.namespaced_key, action.namespaced_key, scope.namespaced_key
6869
)

openedx_authz/api/roles.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
RoleData,
1919
ScopeData,
2020
SubjectData,
21+
UserData,
22+
SubjectRoleAssignmentData,
2123
)
2224
from openedx_authz.api.permissions import get_permission_from_policy
2325
from openedx_authz.engine.enforcer import enforcer
@@ -114,6 +116,7 @@ def get_permissions_for_active_roles_in_scope(
114116
dict[str, list[PermissionData]]: A dictionary mapping the role external_key to its
115117
permissions and scopes.
116118
"""
119+
enforcer.load_policy()
117120
filtered_policy = enforcer.get_filtered_grouping_policy(
118121
GroupingPolicyIndex.SCOPE.value, scope.namespaced_key
119122
)
@@ -145,6 +148,7 @@ def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]:
145148
Returns:
146149
list[Role]: A list of roles.
147150
"""
151+
enforcer.load_policy()
148152
policy_filtered = enforcer.get_filtered_policy(
149153
PolicyIndex.SCOPE.value, scope.namespaced_key
150154
)
@@ -190,6 +194,7 @@ def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]:
190194
Returns:
191195
list[list[str]]: A list of policies in the specified scope.
192196
"""
197+
enforcer.load_policy()
193198
return enforcer.get_filtered_grouping_policy(
194199
GroupingPolicyIndex.SCOPE.value, scope.namespaced_key
195200
)
@@ -204,6 +209,7 @@ def assign_role_to_subject_in_scope(
204209
subject: The ID of the subject.
205210
role: The role to assign.
206211
"""
212+
enforcer.load_policy()
207213
enforcer.add_role_for_user_in_domain(
208214
subject.namespaced_key,
209215
role.namespaced_key,
@@ -234,6 +240,7 @@ def unassign_role_from_subject_in_scope(
234240
role: The role to unassign.
235241
scope: The scope from which to unassign the role.
236242
"""
243+
enforcer.load_policy()
237244
enforcer.delete_roles_for_user_in_domain(
238245
subject.namespaced_key, role.namespaced_key, scope.namespaced_key
239246
)
@@ -291,6 +298,7 @@ def get_subject_role_assignments_in_scope(
291298
Returns:
292299
list[RoleAssignment]: A list of role assignments for the subject in the scope.
293300
"""
301+
enforcer.load_policy()
294302
# TODO: we still need to get the remaining data for the role like email, etc
295303
role_assignments = []
296304
for namespaced_key in enforcer.get_roles_for_user_in_domain(
@@ -372,3 +380,17 @@ def get_all_subject_role_assignments_in_scope(scope: ScopeData) -> list[RoleAssi
372380
)
373381

374382
return list(role_assignments_per_subject.values())
383+
384+
385+
def get_subjects_for_role(role: RoleData) -> list[SubjectData]:
386+
"""Get all the subjects assigned to a specific role.
387+
388+
Args:
389+
role (RoleData): The role to filter subjects.
390+
391+
Returns:
392+
list[SubjectData]: A list of subjects assigned to the specified role.
393+
"""
394+
enforcer.load_policy()
395+
policies = enforcer.get_filtered_grouping_policy(GroupingPolicyIndex.ROLE.value, role.namespaced_key)
396+
return [SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value]) for policy in policies]

openedx_authz/api/users.py

Lines changed: 15 additions & 0 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,6 +33,7 @@
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

@@ -183,3 +185,16 @@ def is_user_allowed(
183185
ActionData(external_key=action_external_key),
184186
ScopeData(external_key=scope_external_key),
185187
)
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]

openedx_authz/apps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ class OpenedxAuthzConfig(AppConfig):
1717
"url_config": {
1818
"lms.djangoapp": {
1919
"namespace": "openedx-authz",
20-
"regex": r"^openedx-authz/",
20+
"regex": r"^api/",
2121
"relative_path": "urls",
2222
},
2323
"cms.djangoapp": {
2424
"namespace": "openedx-authz",
25-
"regex": r"^openedx-authz/",
25+
"regex": r"^api/",
2626
"relative_path": "urls",
2727
},
2828
},

openedx_authz/rest_api/enums.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Enums for the Open edX AuthZ REST API."""
2+
3+
from enum import Enum
4+
5+
6+
class BaseEnum(str, Enum):
7+
"""Base enum class."""
8+
9+
@classmethod
10+
def values(cls):
11+
"""List the values of the enum."""
12+
return [e.value for e in cls]
13+
14+
15+
class SortField(BaseEnum):
16+
"""Enum for the fields to sort by."""
17+
18+
USERNAME = "username"
19+
FULL_NAME = "full_name"
20+
EMAIL = "email"
21+
22+
23+
class SortOrder(BaseEnum):
24+
"""Enum for the order to sort by."""
25+
26+
ASC = "asc"
27+
DESC = "desc"
28+
29+
30+
class SearchField(BaseEnum):
31+
"""Enum for the fields allowed for text search filtering."""
32+
33+
USERNAME = "username"
34+
FULL_NAME = "full_name"
35+
EMAIL = "email"

openedx_authz/rest_api/urls.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Open edX AuthZ API URLs."""
2+
3+
from django.urls import include, path
4+
5+
from openedx_authz.rest_api.v1 import urls as v1_urls
6+
7+
urlpatterns = [
8+
path("v1/", include(v1_urls)),
9+
]

openedx_authz/rest_api/utils.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Utility functions for the Open edX AuthZ REST API."""
2+
3+
from django.contrib.auth import get_user_model
4+
from django.db.models import Q
5+
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
6+
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
7+
from rest_framework.permissions import IsAuthenticated
8+
9+
from openedx_authz.rest_api.enums import SearchField, SortField, SortOrder
10+
11+
User = get_user_model()
12+
13+
14+
def view_auth_classes(is_authenticated=True):
15+
"""
16+
Function and class decorator that abstracts the authentication and permission checks for api views.
17+
"""
18+
19+
def _decorator(func_or_class):
20+
"""
21+
Requires either OAuth2 or Session-based authentication.
22+
"""
23+
func_or_class.authentication_classes = [
24+
JwtAuthentication,
25+
SessionAuthenticationAllowInactiveUser,
26+
]
27+
if is_authenticated:
28+
func_or_class.permission_classes.insert(0, IsAuthenticated)
29+
return func_or_class
30+
31+
return _decorator
32+
33+
34+
def get_user_by_username_or_email(username_or_email: str) -> User:
35+
"""
36+
Retrieve a user by their username or email address.
37+
38+
Args:
39+
username_or_email (str): The username or email address to search for.
40+
41+
Returns:
42+
User: The User object if found and not retired.
43+
44+
Raises:
45+
User.DoesNotExist: If no user matches the provided username or email,
46+
or if the user has an associated retirement request.
47+
"""
48+
user = User.objects.get(Q(email=username_or_email) | Q(username=username_or_email))
49+
if hasattr(user, "userretirementrequest"):
50+
raise User.DoesNotExist
51+
return user
52+
53+
54+
def sort_users(
55+
users: list[dict], sort_by: SortField = SortField.USERNAME, order: SortOrder = SortOrder.ASC
56+
) -> list[dict]:
57+
"""
58+
Sort users by a given field and order.
59+
60+
Args:
61+
users (list[dict]): The users to sort.
62+
sort_by (SortField, optional): The field to sort by. Defaults to SortField.USERNAME.
63+
order (SortOrder, optional): The order to sort by. Defaults to SortOrder.ASC.
64+
65+
Raises:
66+
ValueError: If the sort field is invalid.
67+
ValueError: If the sort order is invalid.
68+
69+
Returns:
70+
list[dict]: The sorted users.
71+
"""
72+
if sort_by not in SortField.values():
73+
raise ValueError(f"Invalid field: '{sort_by}'. Must be one of {SortField.values()}")
74+
75+
if order not in SortOrder.values():
76+
raise ValueError(f"Invalid order: '{order}'. Must be one of {SortOrder.values()}")
77+
78+
sorted_users = sorted(users, key=lambda user: (user.get(sort_by) or "").lower(), reverse=order == SortOrder.DESC)
79+
return sorted_users
80+
81+
82+
def filter_users(users: list[dict], search: str | None, roles: list[str] | None) -> list[dict]:
83+
"""
84+
Filter users by a case-insensitive search string and/or by roles.
85+
86+
Args:
87+
users (list[dict]): The users to filter.
88+
search (str | None): Optional search term matched against fields in ``SearchField``.
89+
roles (list[str] | None): Optional list of roles; include users that have any of these roles.
90+
91+
Returns:
92+
list[dict]: The filtered users, preserving the original order.
93+
"""
94+
if not search and not roles:
95+
return users
96+
97+
filtered_users = []
98+
for user in users:
99+
if search:
100+
matches_search = any(search in (user.get(field) or "").lower() for field in SearchField.values())
101+
if not matches_search:
102+
continue
103+
104+
if roles:
105+
matches_role = any(role in user.get("roles", []) for role in roles)
106+
if not matches_role:
107+
continue
108+
109+
filtered_users.append(user)
110+
111+
return filtered_users
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Fields serializer for the Open edX AuthZ REST API."""
2+
3+
from rest_framework import serializers
4+
5+
6+
class CommaSeparatedListField(serializers.Field):
7+
"""Serializer for a comma-separated list of strings."""
8+
9+
def to_internal_value(self, data):
10+
"""Convert string separated by commas to list"""
11+
return [item.strip().lower() for item in data.split(",") if item.strip()]
12+
13+
def to_representation(self, value):
14+
"""Convert list to string separated by commas"""
15+
return ",".join(value).lower()
16+
17+
18+
class LowercaseCharField(serializers.CharField):
19+
"""Serializer for a lowercase string."""
20+
21+
def to_internal_value(self, data):
22+
"""Convert string to lowercase"""
23+
return data.strip().lower()
24+
25+
def to_representation(self, value):
26+
"""Convert string to lowercase"""
27+
return value.strip().lower()
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Pagination classes for the REST API."""
2+
3+
from rest_framework.pagination import PageNumberPagination
4+
5+
6+
class AuthZAPIViewPagination(PageNumberPagination):
7+
"""Pagination class for the AuthZ API views."""
8+
9+
page_size = 10
10+
page_size_query_param = "page_size"
11+
max_page_size = 100

0 commit comments

Comments
 (0)