Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
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 @@ -14,6 +14,14 @@ Change Log
Unreleased
**********

1.8.0 - 2026-04-14
******************

Added
=====

* Add the ``/api/authz/v1/users/<username>/assignments/`` endpoint to get a list of role assignations for a user.

1.7.0 - 2026-04-14
******************

Expand Down
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__ = "1.7.0"
__version__ = "1.8.0"

ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
14 changes: 14 additions & 0 deletions openedx_authz/api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"RoleData",
"ScopeData",
"SubjectData",
"SuperAdminAssignmentData",
"UserData",
]

Expand Down Expand Up @@ -1119,6 +1120,19 @@ def __repr__(self):
return f"{self.subject.namespaced_key} => [{role_keys}] @ {self.scope.namespaced_key}"


@define
class SuperAdminAssignmentData:
"""Represents a superadmin entry in a team member assignment list.

Used alongside RoleAssignmentData in serializer contexts where a user is a
staff/superuser and their access is not derived from a specific role assignment.
"""

user: "User" = None
is_staff: bool = False
is_superuser: bool = False


@define
class UserAssignments:
"""A user with their role assignments"""
Expand Down
3 changes: 1 addition & 2 deletions openedx_authz/api/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,5 @@ def get_all_role_assignments_per_scope_type(scope_types: tuple[type[ScopeData],
list[RoleAssignmentData]: All assignments whose scope is an instance of any of the given scope types.
"""
return [
role_assignment for role_assignment in get_role_assignments()
if isinstance(role_assignment.scope, scope_types)
role_assignment for role_assignment in get_role_assignments() if isinstance(role_assignment.scope, scope_types)
]
78 changes: 76 additions & 2 deletions openedx_authz/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
"""

from django.contrib.auth import get_user_model
from django.db.models import Q

from openedx_authz.api.data import (
ActionData,
PermissionData,
RoleAssignmentData,
RoleData,
ScopeData,
SuperAdminAssignmentData,
UserAssignments,
UserAssignmentsFilter,
UserData,
Expand All @@ -39,6 +41,9 @@
from openedx_authz.api.utils import filter_user_assignments, get_user_assignment_map
from openedx_authz.utils import get_user_by_username_or_email

User = get_user_model()


__all__ = [
"assign_role_to_user_in_scope",
"batch_assign_role_to_users_in_scope",
Expand All @@ -50,11 +55,13 @@
"get_user_role_assignments_filtered",
"get_all_user_role_assignments_in_scope",
"get_visible_role_assignments_for_user",
"get_visible_user_role_assignments_filtered_by_current_user",
"is_user_allowed",
"get_scopes_for_user_and_permission",
"get_users_for_role_in_scope",
"unassign_all_roles_from_user",
"validate_users",
"get_superadmin_assignments",
]


Expand Down Expand Up @@ -172,6 +179,42 @@ def get_user_role_assignments_for_role_in_scope(
)


def get_visible_user_role_assignments_filtered_by_current_user(
user_external_key: str,
orgs: list[str] = None,
roles: list[str] = None,
allowed_for_user_external_key: str = None,
) -> list[RoleAssignmentData]:
Comment thread
rodmgwgu marked this conversation as resolved.
"""
Get role assignments for a specific user, filtered by orgs and/or roles,
and only include assignments that the specified user has permission to view.

Args:
user_external_key: The user to get assignments for (e.g., 'john_doe').
orgs: Optional list of orgs to filter by (e.g., ['edX', 'MITx']).
roles: Optional list of roles to filter by (e.g., ['library_admin']).
allowed_for_user_external_key: The username to check permissions against (e.g., 'john_doe').

Returns:
list[RoleAssignmentData]: A list of role assignments for the user, filtered by orgs/roles and permissions.
"""
user_role_assignments = get_user_role_assignments(user_external_key=user_external_key)
Comment thread
mariajgrimaldi marked this conversation as resolved.
# Filter assignments based on the user's permissions
user_role_assignments = _filter_allowed_assignments(
user_external_key=allowed_for_user_external_key,
assignments=user_role_assignments,
)
if orgs:
# Filter by orgs
user_role_assignments = [a for a in user_role_assignments if getattr(a.scope, "org", None) in orgs]
if roles:
# Filter by roles
user_role_assignments = [
a for a in user_role_assignments if any(role.external_key in roles for role in a.roles)
]
return user_role_assignments


def get_user_role_assignments_filtered(
*,
user_external_key: str | None = None,
Expand Down Expand Up @@ -214,11 +257,14 @@ def get_all_user_role_assignments_in_scope(


def _filter_allowed_assignments(
user_external_key: str, assignments: list[RoleAssignmentData]
assignments: list[RoleAssignmentData], user_external_key: str = None
) -> list[RoleAssignmentData]:
"""
Filter the given role assignments to only include those that the user has permission to view.
"""
if not user_external_key:
# If no user is specified, return all assignments
return assignments
allowed_assignments: list[RoleAssignmentData] = []
for assignment in assignments:
permission = None
Expand Down Expand Up @@ -354,7 +400,6 @@ def validate_users(user_identifiers: list[str]) -> tuple[list[str], list[str]]:
Returns:
tuple: (valid_users, invalid_users) lists
"""
User = get_user_model()
valid_users = []
invalid_users = []

Expand All @@ -369,3 +414,32 @@ def validate_users(user_identifiers: list[str]) -> tuple[list[str], list[str]]:
invalid_users.append(user_identifier)

return valid_users, invalid_users


def get_superadmin_assignments(user_external_keys: list[str] | None = None) -> list[SuperAdminAssignmentData]:
"""Returns all superadmins as SuperAdminAssignmentData.

A superadmin is a User with a Django staff or superuser role.
Superadmins automatically are allowed to do any action.

Args:
user_external_keys (list[str] or None): To filter by usernames

Returns:
list[SuperAdminAssignmentData]: The superadmin data
"""
superadmin_filter = Q(is_active=True) & (Q(is_staff=True) | Q(is_superuser=True))
Comment thread
rodmgwgu marked this conversation as resolved.
if user_external_keys is not None:
superadmin_filter &= Q(username__in=user_external_keys)
requested_users = User.objects.filter(superadmin_filter)

superadmin_assignments: list[SuperAdminAssignmentData] = []
for requested_user in requested_users:
superadmin_assignments.append(
SuperAdminAssignmentData(
user=requested_user,
is_staff=requested_user.is_staff,
is_superuser=requested_user.is_superuser,
)
)
return superadmin_assignments
21 changes: 9 additions & 12 deletions openedx_authz/engine/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,7 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis

# Permission applied to individual user
logger.info(
f"Migrating permission for User: {permission.user.username} "
f"to Role: {role} in Scope: {scope_external_key}"
f"Migrating permission for User: {permission.user.username} to Role: {role} in Scope: {scope_external_key}"
)

is_user_added = assign_role_to_user_in_scope(
Expand Down Expand Up @@ -322,25 +321,26 @@ def migrate_authz_to_legacy_course_roles(
_validate_migration_input(course_id_list, org_id)

role_assignments = get_all_role_assignments_per_scope_type(
scope_types=(CourseOverviewData, OrgCourseOverviewGlobData,)
scope_types=(
CourseOverviewData,
OrgCourseOverviewGlobData,
)
)

# Two cases here:
# 1. org_id provided: filter by org — includes org-level glob and course-level scopes for that org.
# 2. only course_id_list provided: filter by course_id — org-level glob scopes are excluded (no course_id).
if org_id:
role_assignments = [
role_assignment
for role_assignment in role_assignments
if role_assignment.scope.org == org_id
role_assignment for role_assignment in role_assignments if role_assignment.scope.org == org_id
]

if course_id_list and not org_id:
role_assignments = [
role_assignment
for role_assignment in role_assignments
if isinstance(role_assignment.scope, CourseOverviewData) and
role_assignment.scope.course_id in course_id_list
if isinstance(role_assignment.scope, CourseOverviewData)
and role_assignment.scope.course_id in course_id_list
]

roles_with_errors = []
Expand All @@ -350,13 +350,10 @@ def migrate_authz_to_legacy_course_roles(
user_external_keys = {assignment.subject.external_key for assignment in role_assignments}
users_by_username = {
subject.user.username: subject.user
for subject in user_subject_model.objects.filter(
user__username__in=user_external_keys
).select_related("user")
for subject in user_subject_model.objects.filter(user__username__in=user_external_keys).select_related("user")
}

for role_assignment in role_assignments:

# Per valid role assignment, create corresponding CourseAccessRole entry
# depending on whether the scope is course-level or org-level glob
try:
Expand Down
8 changes: 8 additions & 0 deletions openedx_authz/rest_api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ class SortField(BaseEnum):
EMAIL = "email"


class AssignmentSortField(BaseEnum):
"""Enum for the role assignment fields to sort by."""

ROLE = "role"
ORG = "org"
SCOPE = "scope"


class SortOrder(BaseEnum):
"""Enum for the order to sort by."""

Expand Down
36 changes: 35 additions & 1 deletion openedx_authz/rest_api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
GLOBAL_SCOPE_WILDCARD,
ScopeData,
)
from openedx_authz.rest_api.data import SearchField, SortField, SortOrder
from openedx_authz.rest_api.data import AssignmentSortField, SearchField, SortField, SortOrder


def get_generic_scope(scope: ScopeData) -> ScopeData:
Expand Down Expand Up @@ -93,3 +93,37 @@ def filter_users(users: list[dict], search: str | None, roles: list[str] | None)
filtered_users.append(user)

return filtered_users


def sort_assignments(
assignments: list[dict],
sort_by: AssignmentSortField = AssignmentSortField.ROLE,
order: SortOrder = SortOrder.ASC,
) -> list[dict]:
"""
Sort role assignments by a given field and order.

Args:
assignments (list[dict]): The assignments to sort.
sort_by (AssignmentSortField, optional): The field to sort by. Defaults to AssignmentSortField.ROLE.
order (SortOrder, optional): The order to sort by. Defaults to SortOrder.ASC.

Raises:
ValueError: If the sort field is invalid.
ValueError: If the sort order is invalid.

Returns:
list[dict]: The sorted assignments.
"""
if sort_by not in AssignmentSortField.values():
raise ValueError(f"Invalid field: '{sort_by}'. Must be one of {AssignmentSortField.values()}")

if order not in SortOrder.values():
raise ValueError(f"Invalid order: '{order}'. Must be one of {SortOrder.values()}")

sorted_assignments = sorted(
assignments,
key=lambda assignment: (assignment.get(sort_by) or "").lower(),
reverse=order == SortOrder.DESC,
)
return sorted_assignments
13 changes: 11 additions & 2 deletions openedx_authz/rest_api/v1/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

from rest_framework.filters import BaseFilterBackend

from openedx_authz.rest_api.data import SortField, SortOrder
from openedx_authz.rest_api.utils import filter_users, sort_users
from openedx_authz.rest_api.data import AssignmentSortField, SortField, SortOrder
from openedx_authz.rest_api.utils import filter_users, sort_assignments, sort_users


class TeamMemberSearchFilter(BaseFilterBackend):
Expand All @@ -21,3 +21,12 @@ def filter_queryset(self, request, queryset, view):
sort_by = request.query_params.get("sort_by", SortField.USERNAME)
order = request.query_params.get("order", SortOrder.ASC)
return sort_users(users=queryset, sort_by=sort_by, order=order)


class TeamMemberAssignmentsOrderingFilter(BaseFilterBackend):
"""Sort team member assignments by a given field and order."""

def filter_queryset(self, request, queryset, view):
sort_by = request.query_params.get("sort_by", AssignmentSortField.ROLE)
order = request.query_params.get("order", SortOrder.ASC)
return sort_assignments(assignments=queryset, sort_by=sort_by, order=order)
Loading