Skip to content

Commit 8401052

Browse files
committed
feat: add rest api for roles and permissions
1 parent afb2671 commit 8401052

11 files changed

Lines changed: 252 additions & 14 deletions

File tree

openedx_authz/api/data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class AuthZData:
3636
Subclasses are automatically registered by their NAMESPACE for factory pattern.
3737
"""
3838

39-
SEPARATOR: str = "@"
39+
SEPARATOR: str = ":"
4040
NAMESPACE: str = None
4141

4242
# TODO: Implement factory method to return correct subclass based on NAMESPACE prefix.

openedx_authz/api/permissions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,5 @@ def has_permission(
6767
Returns:
6868
bool: True if the subject has the specified permission in the scope, False otherwise.
6969
"""
70+
enforcer.load_policy()
7071
return enforcer.enforce(subject.subject_id, action.action_id, scope.scope_id)

openedx_authz/api/roles.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def get_permissions_for_active_roles_in_scope(
103103
dict[str, list[PermissionData]]: A dictionary mapping the role name to its
104104
permissions and scopes.
105105
"""
106+
enforcer.load_policy()
106107
filtered_policy = enforcer.get_filtered_grouping_policy(
107108
GroupingPolicyIndex.SCOPE.value, scope.scope_id
108109
)
@@ -134,6 +135,7 @@ def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]:
134135
Returns:
135136
list[Role]: A list of roles.
136137
"""
138+
enforcer.load_policy()
137139
policy_filtered = enforcer.get_filtered_policy(
138140
PolicyIndex.SCOPE.value, scope.scope_id
139141
)
@@ -179,6 +181,7 @@ def assign_role_to_subject_in_scope(
179181
subject: The ID of the subject.
180182
role: The role to assign.
181183
"""
184+
enforcer.load_policy()
182185
# TODO: we need to make some uppercase/lowercase decisions in the lookups
183186
# for now, we assume the caller has done the right thing
184187
# and passed in the correctly namespaced IDs
@@ -210,6 +213,7 @@ def unassign_role_from_subject_in_scope(
210213
role: The role to unassign.
211214
scope: The scope from which to unassign the role.
212215
"""
216+
enforcer.load_policy()
213217
enforcer.delete_roles_for_user_in_domain(
214218
subject.subject_id, role.role_id, scope.scope_id
215219
)
@@ -271,6 +275,7 @@ def get_subject_role_assignments_in_scope(
271275
Returns:
272276
list[RoleAssignment]: A list of role assignments for the subject in the scope.
273277
"""
278+
enforcer.load_policy()
274279
# TODO: we still need to get the remaining data for the role like email, etc
275280
role_assignments = []
276281
for role_id in enforcer.get_roles_for_user_in_domain(
@@ -303,9 +308,10 @@ def get_subjects_role_assignments_for_role_in_scope(
303308
Returns:
304309
list[RoleAssignment]: A list of subjects assigned to the specified role in the specified scope.
305310
"""
311+
enforcer.load_policy()
306312
role_assignments = []
307313
for subject in enforcer.get_users_for_role_in_domain(role.role_id, scope.scope_id):
308-
if subject.startswith(f"{RoleData.NAMESPACE}@"):
314+
if subject.startswith(f"{RoleData.NAMESPACE}{RoleData.SEPARATOR}"):
309315
# Skip roles that are also subjects
310316
continue
311317
role_assignments.append(

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/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+
]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Custom permissions for the Open edX AuthZ REST API."""
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Serializers for the Open edX AuthZ REST API."""
2+
3+
from rest_framework import serializers
4+
5+
6+
class ScopeMixin(serializers.Serializer): # pylint: disable=abstract-method
7+
"""Mixin providing scope field functionality."""
8+
9+
scope = serializers.CharField(max_length=255)
10+
11+
12+
class RoleMixin(serializers.Serializer): # pylint: disable=abstract-method
13+
"""Mixin providing role field functionality."""
14+
15+
role = serializers.CharField(max_length=255)
16+
17+
18+
class ActionMixin(serializers.Serializer): # pylint: disable=abstract-method
19+
"""Mixin providing action field functionality."""
20+
21+
action = serializers.CharField(max_length=255)
22+
23+
24+
class PermissionValidationSerializer(ActionMixin, ScopeMixin): # pylint: disable=abstract-method
25+
"""Serializer for permission validation request."""
26+
27+
28+
class AddUserToRoleWithScopeSerializer(RoleMixin, ScopeMixin): # pylint: disable=abstract-method
29+
"""Serializer for adding a user to a role with a scope."""
30+
31+
users = serializers.ListField(child=serializers.CharField(max_length=255))
32+
33+
34+
class RemoveUserFromRoleWithScopeSerializer(RoleMixin, ScopeMixin): # pylint: disable=abstract-method
35+
"""Serializer for removing a user from a role with a scope."""
36+
37+
user = serializers.CharField(max_length=255)
38+
39+
40+
class ListUsersInRoleWithScopeSerializer(RoleMixin, ScopeMixin): # pylint: disable=abstract-method
41+
"""Serializer for listing users in a role with a scope."""
42+
43+
44+
class ListRolesWithScopeSerializer(ScopeMixin): # pylint: disable=abstract-method
45+
"""Serializer for listing roles with a scope."""

openedx_authz/rest_api/v1/urls.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Open edX AuthZ API v1 URLs."""
2+
3+
from django.urls import path
4+
5+
from openedx_authz.rest_api.v1 import views
6+
7+
urlpatterns = [
8+
path("permissions/validate/", views.PermissionValidationView.as_view(), name="permission-validate"),
9+
path("roles/", views.RoleListView.as_view(), name="role-list"),
10+
path("role/users/", views.RoleUserAPIView.as_view(), name="role-users"),
11+
]

openedx_authz/rest_api/v1/views.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Views for the Open edX AuthZ REST API."""
2+
3+
import logging
4+
5+
from common.djangoapps.student.models.user import get_user_by_username_or_email
6+
from django.contrib.auth import get_user_model
7+
from rest_framework import status
8+
from rest_framework.permissions import IsAuthenticated
9+
from rest_framework.response import Response
10+
from rest_framework.views import APIView
11+
12+
from openedx_authz.api.data import ActionData, ScopeData, UserData
13+
from openedx_authz.api.permissions import has_permission
14+
from openedx_authz.api.roles import get_role_definitions_in_scope
15+
from openedx_authz.api.users import (
16+
assign_role_to_user_in_scope,
17+
get_user_role_assignments_for_role_in_scope,
18+
unassign_role_from_user,
19+
)
20+
from openedx_authz.rest_api.v1.serializers import (
21+
AddUserToRoleWithScopeSerializer,
22+
ListRolesWithScopeSerializer,
23+
ListUsersInRoleWithScopeSerializer,
24+
PermissionValidationSerializer,
25+
RemoveUserFromRoleWithScopeSerializer,
26+
)
27+
28+
logger = logging.getLogger(__name__)
29+
30+
User = get_user_model()
31+
32+
33+
class PermissionValidationView(APIView):
34+
"""
35+
Validate permissions for the authenticated user.
36+
"""
37+
38+
permission_classes = [IsAuthenticated]
39+
40+
def post(self, request):
41+
"""Validate permissions for the authenticated user."""
42+
serializer = PermissionValidationSerializer(data=request.data, many=True)
43+
serializer.is_valid(raise_exception=True)
44+
45+
username = request.user.username
46+
subject = UserData(username=username)
47+
48+
for perm in serializer.validated_data:
49+
try:
50+
action = ActionData(name=perm["action"])
51+
scope = ScopeData(name=perm["scope"])
52+
allowed = has_permission(subject, action, scope)
53+
perm["allowed"] = allowed
54+
except Exception as e: # pylint: disable=broad-exception-caught
55+
logger.error(f"Error validating permission for user {username}: {e}")
56+
57+
return Response(serializer.validated_data, status=status.HTTP_200_OK)
58+
59+
60+
class RoleUserAPIView(APIView):
61+
"""
62+
APIView for managing users and their roles.
63+
Handles GET (list), PUT (add), and DELETE (remove) operations.
64+
"""
65+
66+
permission_classes = [IsAuthenticated]
67+
68+
def get(self, request):
69+
"""
70+
Get list of users in the role.
71+
"""
72+
serializer = ListUsersInRoleWithScopeSerializer(data=request.query_params)
73+
serializer.is_valid(raise_exception=True)
74+
75+
role_name = serializer.validated_data["role"]
76+
scope = serializer.validated_data["scope"]
77+
78+
response_data = []
79+
role_assignments = get_user_role_assignments_for_role_in_scope(role_name, scope)
80+
for assignment in role_assignments:
81+
# TODO: Should we get all users at once instead of one by one?
82+
user = get_user_by_username_or_email(assignment.subject.username)
83+
response_data.append(
84+
{
85+
"username": assignment.subject.username,
86+
"full_name": user.profile.name,
87+
"email": user.email,
88+
}
89+
)
90+
91+
return Response(response_data, status=status.HTTP_200_OK)
92+
93+
def put(self, request):
94+
"""
95+
Add users to the role.
96+
"""
97+
serializer = AddUserToRoleWithScopeSerializer(data=request.data)
98+
serializer.is_valid(raise_exception=True)
99+
100+
completed, errors = [], []
101+
role_name = serializer.validated_data["role"]
102+
scope = serializer.validated_data["scope"]
103+
104+
for user_identifier in serializer.validated_data["users"]:
105+
try:
106+
user = get_user_by_username_or_email(user_identifier)
107+
assign_role_to_user_in_scope(user.username, role_name, scope)
108+
completed.append({"user": user_identifier, "status": "role_added"})
109+
except User.DoesNotExist:
110+
errors.append({"user": user_identifier, "error": "user_not_found"})
111+
except Exception as e: # pylint: disable=broad-exception-caught
112+
logger.error(f"Error assigning role to user {user_identifier}: {e}")
113+
errors.append({"user": user_identifier, "error": "assignment_failed"})
114+
115+
response_data = {"completed": completed, "errors": errors}
116+
return Response(response_data, status=status.HTTP_207_MULTI_STATUS)
117+
118+
def delete(self, request):
119+
"""
120+
Remove user role from the role.
121+
"""
122+
serializer = RemoveUserFromRoleWithScopeSerializer(data=request.query_params)
123+
serializer.is_valid(raise_exception=True)
124+
125+
user_identifier = serializer.validated_data["user"]
126+
role_name = serializer.validated_data["role"]
127+
scope = serializer.validated_data["scope"]
128+
129+
try:
130+
user = get_user_by_username_or_email(user_identifier)
131+
unassign_role_from_user(user.username, role_name, scope)
132+
return Response({"message": "Role successfully removed from user"}, status=status.HTTP_200_OK)
133+
except User.DoesNotExist:
134+
return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND)
135+
except Exception as e: # pylint: disable=broad-exception-caught
136+
logger.error(f"Error removing role from user {user_identifier}: {e}")
137+
return Response({"error": "Internal server error"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
138+
139+
140+
class RoleListView(APIView):
141+
"""
142+
Get list of roles with their permissions for a scope.
143+
"""
144+
145+
permission_classes = [IsAuthenticated]
146+
147+
def get(self, request):
148+
"""Get list of roles with their permissions for a library."""
149+
serializer = ListRolesWithScopeSerializer(data=request.query_params)
150+
serializer.is_valid(raise_exception=True)
151+
152+
scope = ScopeData(name=serializer.validated_data["scope"])
153+
154+
response_data = []
155+
roles = get_role_definitions_in_scope(scope)
156+
157+
for role in roles:
158+
response_data.append(
159+
{
160+
"role": role.name,
161+
"permissions": [perm.action.name for perm in role.permissions] if role.permissions else [],
162+
# TODO: Get user count using a api function
163+
"user_count": 0,
164+
}
165+
)
166+
167+
return Response(response_data, status=status.HTTP_200_OK)

openedx_authz/settings/common.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ def plugin_settings(settings):
2222
settings.INSTALLED_APPS.append(casbin_adapter_app)
2323

2424
# Add Casbin configuration
25-
settings.CASBIN_MODEL = os.path.join(
26-
ROOT_DIRECTORY, "engine", "config", "model.conf"
27-
)
25+
settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf")
2826
settings.CASBIN_WATCHER_ENABLED = True
2927
# TODO: Replace with a more dynamic configuration
3028
# Redis host and port are temporarily loaded here for the MVP

0 commit comments

Comments
 (0)