Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1ffb2f6
feat: add rest api for roles and permissions
BryanttV Sep 30, 2025
45c238c
refactor: enhance permission handling with dynamic scope delegation
BryanttV Oct 10, 2025
3c5f3e7
test: update role user API tests to use new admin user and adjust sco…
BryanttV Oct 10, 2025
43db8ff
docs: fix docstring warnings
BryanttV Oct 10, 2025
8730e35
docs: update json examples in view docstrings
BryanttV Oct 10, 2025
ceba0ae
docs: improve docstrings for permission classes by adding attribute d…
BryanttV Oct 10, 2025
8d68f1f
docs: update docstrings in permission classes to use backticks for co…
BryanttV Oct 10, 2025
deaee93
refactor: update NAMESPACE attributes to use ClassVar for type hinting
BryanttV Oct 10, 2025
9b1db5b
chore: rename enums.py to data.py
BryanttV Oct 10, 2025
c0c05d6
fix: update import path
BryanttV Oct 10, 2025
ac5fb4b
refactor: update imports
BryanttV Oct 10, 2025
71adb57
feat: add DynamicScopePermission to RoleListView
BryanttV Oct 10, 2025
6380719
refactor: use temporary permission instead a dynamic permission valid…
BryanttV Oct 10, 2025
c8a45ff
chore: bump version to 0.3.0
BryanttV Oct 10, 2025
ef60540
refactor: eliminate duplicates while preserving order in serializer l…
BryanttV Oct 10, 2025
a18eb72
feat: add generic scope wildcard constant to data module
BryanttV Oct 11, 2025
3a61f48
feat: add decorators for authentication and authorization
BryanttV Oct 11, 2025
65bdb73
feat: implement function to create generic scope from specific scope
BryanttV Oct 11, 2025
dfab7ce
feat: add init to rest_api.v1 module
BryanttV Oct 11, 2025
ee6992b
refactor: restructure ContentLibraryPermission to use MethodPermissio…
BryanttV Oct 11, 2025
0a2def7
feat: enhance role and scope validation in serializers
BryanttV Oct 11, 2025
5860324
feat: update views to use authz_permissions decorator
BryanttV Oct 11, 2025
8ee18d5
fix: update test cases to use 'scope' parameter instead of 'namespace'
BryanttV Oct 11, 2025
3250b68
chore: remove trailing newline
BryanttV Oct 11, 2025
b702f4f
docs: update API documentation
BryanttV Oct 11, 2025
76e2df2
refactor: restructure role test cases to enhance clarity and maintain…
BryanttV Oct 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,11 @@ Added
* ADRs for key design decisions.
* Casbin model (CONF) and engine layer for authorization.
* Implementation of public API for roles and permissions management.

0.3.0 - 2025-10-10
******************

Added
=====

* Implementation of REST API for roles and permissions management.
2 changes: 1 addition & 1 deletion openedx_authz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

import os

__version__ = "0.1.0"
__version__ = "0.3.0"

ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
69 changes: 68 additions & 1 deletion openedx_authz/api/data.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
"""Data classes and enums for representing roles, permissions, and policies."""

import re
from abc import abstractmethod
from enum import Enum
from typing import ClassVar, Literal, Type

from attrs import define
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import LibraryLocatorV2

try:
from openedx.core.djangoapps.content_libraries.models import ContentLibrary
except ImportError:
ContentLibrary = None

__all__ = [
"UserData",
"PermissionData",
Expand All @@ -18,10 +24,12 @@
"RoleData",
"ScopeData",
"SubjectData",
"ContentLibraryData",
]

AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^"
EXTERNAL_KEY_SEPARATOR = ":"
GENERIC_SCOPE_WILDCARD = "*"
NAMESPACED_KEY_PATTERN = rf"^.+{re.escape(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR)}.+$"


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

return scope_subclass

@classmethod
def get_all_namespaces(mcs) -> dict[str, Type["ScopeData"]]:
"""Get all registered scope namespaces.

Returns:
dict[str, Type["ScopeData"]]: A dictionary of all namespace prefixes registered in the scope registry.
Each namespace corresponds to a ScopeData subclass (e.g., 'lib', 'sc').

Examples:
>>> ScopeMeta.get_all_namespaces()
{'sc': ScopeData, 'lib': ContentLibraryData, 'org': OrganizationData}
"""
return mcs.scope_registry

@classmethod
def validate_external_key(mcs, external_key: str) -> bool:
"""Validate the external_key format for the subclass.
Expand Down Expand Up @@ -301,6 +323,15 @@ def validate_external_key(cls, _: str) -> bool:
"""
return True

@abstractmethod
def exists(self) -> bool:
"""Check if the scope exists.

Returns:
bool: True if the scope exists, False otherwise.
"""
raise NotImplementedError("Subclasses must implement exists method.")


@define
class ContentLibraryData(ScopeData):
Expand Down Expand Up @@ -355,6 +386,19 @@ def validate_external_key(cls, external_key: str) -> bool:
except InvalidKeyError:
return False

def exists(self) -> bool:
"""Check if the content library exists.

Returns:
bool: True if the content library exists, False otherwise.
"""
try:
library_key = LibraryLocatorV2.from_string(self.library_id)
ContentLibrary.objects.get_by_key(library_key=library_key)
return True
except ContentLibrary.DoesNotExist:
return False

def __str__(self):
"""Human readable string representation of the content library."""
return self.library_id
Expand Down Expand Up @@ -562,6 +606,15 @@ class PermissionData:
action: ActionData = None
effect: Literal["allow", "deny"] = "allow"

@property
def identifier(self) -> str:
"""Get the permission identifier.

Returns:
str: The permission identifier (e.g., 'delete_library').
"""
return self.action.external_key

def __str__(self):
"""Human readable string representation of the permission and its effect."""
return f"{self.action} - {self.effect}"
Expand All @@ -571,7 +624,7 @@ def __repr__(self):
return f"{self.action.namespaced_key} => {self.effect}"


@define
@define(eq=False)
class RoleData(AuthZData):
"""A role is a named collection of permissions that can be assigned to subjects.

Expand Down Expand Up @@ -600,6 +653,12 @@ class RoleData(AuthZData):
NAMESPACE: ClassVar[str] = "role"
permissions: list[PermissionData] = []

def __eq__(self, other):
"""Compare roles based on their namespaced_key."""
if not isinstance(other, RoleData):
return False
return self.namespaced_key == other.namespaced_key

@property
def name(self) -> str:
"""The human-readable name of the role (e.g., 'Library Admin', 'Course Instructor').
Expand All @@ -612,6 +671,14 @@ def name(self) -> str:
"""
return self.external_key.replace("_", " ").title()

def get_permission_identifiers(self) -> list[str]:
"""Get the technical identifiers for all permissions in this role.

Returns:
list[str]: Permission identifiers (e.g., ['delete_library', 'edit_content']).
"""
return [permission.identifier for permission in self.permissions]

def __str__(self):
"""Human readable string representation of the role and its permissions."""
return f"{self.name}: {', '.join(str(p) for p in self.permissions)}"
Expand Down
9 changes: 3 additions & 6 deletions openedx_authz/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,7 @@ def get_all_permissions_in_scope(scope: ScopeData) -> list[PermissionData]:
Returns:
list of PermissionData: A list of PermissionData objects associated with the given scope.
"""
actions = enforcer.get_filtered_policy(
PolicyIndex.SCOPE.value, scope.namespaced_key
)
actions = enforcer.get_filtered_policy(PolicyIndex.SCOPE.value, scope.namespaced_key)
return [get_permission_from_policy(action) for action in actions]


Expand All @@ -63,6 +61,5 @@ def is_subject_allowed(
Returns:
bool: True if the subject has the specified permission in the scope, False otherwise.
"""
return enforcer.enforce(
subject.namespaced_key, action.namespaced_key, scope.namespaced_key
)
enforcer.load_policy()
return enforcer.enforce(subject.namespaced_key, action.namespaced_key, scope.namespaced_key)
35 changes: 31 additions & 4 deletions openedx_authz/api/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def get_permissions_for_active_roles_in_scope(
dict[str, list[PermissionData]]: A dictionary mapping the role external_key to its
permissions and scopes.
"""
enforcer.load_policy()
filtered_policy = enforcer.get_filtered_grouping_policy(
GroupingPolicyIndex.SCOPE.value, scope.namespaced_key
)
Expand Down Expand Up @@ -145,6 +146,7 @@ def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]:
Returns:
list[Role]: A list of roles.
"""
enforcer.load_policy()
policy_filtered = enforcer.get_filtered_policy(
PolicyIndex.SCOPE.value, scope.namespaced_key
)
Expand Down Expand Up @@ -190,21 +192,27 @@ def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]:
Returns:
list[list[str]]: A list of policies in the specified scope.
"""
enforcer.load_policy()
return enforcer.get_filtered_grouping_policy(
GroupingPolicyIndex.SCOPE.value, scope.namespaced_key
)


def assign_role_to_subject_in_scope(
subject: SubjectData, role: RoleData, scope: ScopeData
) -> None:
) -> bool:
"""Assign a role to a subject.

Args:
subject: The ID of the subject.
role: The role to assign.
scope: The scope to assign the role to.

Returns:
bool: True if the role was assigned successfully, False otherwise.
"""
enforcer.add_role_for_user_in_domain(
enforcer.load_policy()
return enforcer.add_role_for_user_in_domain(
subject.namespaced_key,
role.namespaced_key,
scope.namespaced_key,
Expand All @@ -226,15 +234,19 @@ def batch_assign_role_to_subjects_in_scope(

def unassign_role_from_subject_in_scope(
subject: SubjectData, role: RoleData, scope: ScopeData
) -> None:
) -> bool:
"""Unassign a role from a subject.

Args:
subject: The ID of the subject.
role: The role to unassign.
scope: The scope from which to unassign the role.

Returns:
bool: True if the role was unassigned successfully, False otherwise.
"""
enforcer.delete_roles_for_user_in_domain(
enforcer.load_policy()
return enforcer.delete_roles_for_user_in_domain(
subject.namespaced_key, role.namespaced_key, scope.namespaced_key
)

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

return list(role_assignments_per_subject.values())


def get_subjects_for_role(role: RoleData) -> list[SubjectData]:
"""Get all the subjects assigned to a specific role.

Args:
role (RoleData): The role to filter subjects.

Returns:
list[SubjectData]: A list of subjects assigned to the specified role.
"""
enforcer.load_policy()
policies = enforcer.get_filtered_grouping_policy(GroupingPolicyIndex.ROLE.value, role.namespaced_key)
return [SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value]) for policy in policies]
49 changes: 29 additions & 20 deletions openedx_authz/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
get_subject_role_assignments,
get_subject_role_assignments_for_role_in_scope,
get_subject_role_assignments_in_scope,
get_subjects_for_role,
unassign_role_from_subject_in_scope,
)

Expand All @@ -32,29 +33,29 @@
"get_user_role_assignments_for_role_in_scope",
"get_all_user_role_assignments_in_scope",
"is_user_allowed",
"get_users_for_role",
]


def assign_role_to_user_in_scope(
user_external_key: str, role_external_key: str, scope_external_key: str
) -> bool:
def assign_role_to_user_in_scope(user_external_key: str, role_external_key: str, scope_external_key: str) -> bool:
"""Assign a role to a user in a specific scope.

Args:
user (str): ID of the user (e.g., 'john_doe').
role_external_key (str): Name of the role to assign.
scope (str): Scope in which to assign the role.

Returns:
bool: True if the role was assigned successfully, False otherwise.
"""
assign_role_to_subject_in_scope(
return assign_role_to_subject_in_scope(
UserData(external_key=user_external_key),
RoleData(external_key=role_external_key),
ScopeData(external_key=scope_external_key),
)


def batch_assign_role_to_users_in_scope(
users: list[str], role_external_key: str, scope_external_key: str
):
def batch_assign_role_to_users_in_scope(users: list[str], role_external_key: str, scope_external_key: str):
"""Assign a role to multiple users in a specific scope.

Args:
Expand All @@ -70,26 +71,25 @@ def batch_assign_role_to_users_in_scope(
)


def unassign_role_from_user(
user_external_key: str, role_external_key: str, scope_external_key: str
):
def unassign_role_from_user(user_external_key: str, role_external_key: str, scope_external_key: str):
"""Unassign a role from a user in a specific scope.

Args:
user_external_key (str): ID of the user (e.g., 'john_doe').
role_external_key (str): Name of the role to unassign.
scope_external_key (str): Scope in which to unassign the role.

Returns:
bool: True if the role was unassigned successfully, False otherwise.
"""
unassign_role_from_subject_in_scope(
return unassign_role_from_subject_in_scope(
UserData(external_key=user_external_key),
RoleData(external_key=role_external_key),
ScopeData(external_key=scope_external_key),
)


def batch_unassign_role_from_users(
users: list[str], role_external_key: str, scope_external_key: str
):
def batch_unassign_role_from_users(users: list[str], role_external_key: str, scope_external_key: str):
"""Unassign a role from multiple users in a specific scope.

Args:
Expand Down Expand Up @@ -117,9 +117,7 @@ def get_user_role_assignments(user_external_key: str) -> list[RoleAssignmentData
return get_subject_role_assignments(UserData(external_key=user_external_key))


def get_user_role_assignments_in_scope(
user_external_key: str, scope_external_key: str
) -> list[RoleAssignmentData]:
def get_user_role_assignments_in_scope(user_external_key: str, scope_external_key: str) -> list[RoleAssignmentData]:
"""Get the roles assigned to a user in a specific scope.

Args:
Expand Down Expand Up @@ -164,9 +162,7 @@ def get_all_user_role_assignments_in_scope(
Returns:
list[RoleAssignmentData]: A list of user role assignments and all their metadata in the specified scope.
"""
return get_all_subject_role_assignments_in_scope(
ScopeData(external_key=scope_external_key)
)
return get_all_subject_role_assignments_in_scope(ScopeData(external_key=scope_external_key))


def is_user_allowed(
Expand All @@ -189,3 +185,16 @@ def is_user_allowed(
ActionData(external_key=action_external_key),
ScopeData(external_key=scope_external_key),
)


def get_users_for_role(role_external_key: str) -> list[UserData]:
"""Get all the users assigned to a specific role.

Args:
role_external_key (str): The role to filter users (e.g., 'library_admin').

Returns:
list[UserData]: A list of users assigned to the specified role.
"""
users = get_subjects_for_role(RoleData(external_key=role_external_key))
return [UserData(namespaced_key=user.namespaced_key) for user in users]
Loading