Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
18 changes: 18 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ Added
``openedx_authz/engine/schema/paragon_icons.py`` and regenerated with
``make paragon_icons`` (ADR 0029).
* Added the Instructor Dashboard permissions from issue #484 for course information, enrollments, learner grading, learner extensions, and cohorts. The ``enrollments``, ``learner_grading``, and ``learner_administration`` applets define them and add them to the ``course_admin`` and ``course_staff`` roles.
* Added list controls to the role catalog, ``GET /api/authz/v1/roles/``, for the Admin Console
Roles and Permissions table (ADR 0036):

* ``search`` matches role display names, ignoring case.
* ``definition_kind`` (``static`` or ``user_defined``) keeps only roles of that kind.
* ``sort_by`` (``display_name``, ``scope_type``, ``user_count`` or ``updated_at``) and ``order``
(``asc`` or ``desc``) sort the roles. Roles without ``updated_at`` go last, and ties are
broken by role identifier.
* Unknown values of ``definition_kind``, ``sort_by`` or ``order`` return 400.
* ``count`` and the pages reflect the filtered and sorted roles. The ``categories`` and
``permissions`` catalogs are not filtered.

* Added ``user_count`` and ``updated_at`` to each role of the role catalog. ``user_count`` is the
number of distinct active users with the role in the scopes of the requested types that the
caller can view. ``updated_at`` is ``null`` for static roles.
* Added ``get_visible_role_user_counts`` to the public API to count those users.

Changed
========
Expand All @@ -61,6 +77,8 @@ Changed
* **Breaking**: ``GET /api/authz/v1/scopes/`` no longer accepts ``course`` and ``library`` as
``scope_type`` values. Use ``course-v1`` and ``lib`` instead. Requests with the old values now
return a 400 validation error, the same as ``/roles/``.
* The role catalog now lists roles by display name by default, instead of by role identifier.
Pass ``sort_by`` and ``order`` to choose another order.

Removed
=======
Expand Down
4 changes: 4 additions & 0 deletions src/openedx_authz/api/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ def get_role_catalog(scope_types: list[str]) -> list[dict]:
- ``definition_kind``: The :class:`DefinitionKind` value of the role.
- ``permissions``: Sorted identifiers of the permissions the role grants in the scope types.
- ``scope_types``: Sorted scope types, among the requested ones, in which the role has grants.
- ``updated_at``: When a custom role last changed. Always ``None`` here, because static roles
have no meaningful edit date.
"""
roles = AuthzRoleDefinition.objects.visible().with_grants_in_scope_types(scope_types)
return [
Expand All @@ -84,6 +86,8 @@ def get_role_catalog(scope_types: list[str]) -> list[dict]:
"definition_kind": DefinitionKind.STATIC.value,
"permissions": sorted({grant.permission.identifier for grant in role.grants}),
"scope_types": sorted({grant.scope for grant in role.grants}),
# TODO: set correct value for ``updated_at`` when custom roles arrive.
"updated_at": None,
}
for role in roles.order_by("role_id")
]
49 changes: 49 additions & 0 deletions src/openedx_authz/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"""

import logging
from collections import defaultdict

from django.contrib.auth import get_user_model
from django.db.models import Q
Expand Down Expand Up @@ -60,6 +61,7 @@
"get_user_role_assignments_filtered",
"get_all_user_role_assignments_in_scope",
"get_visible_role_assignments_for_user",
"get_visible_role_user_counts",
"get_visible_user_role_assignments_filtered_by_current_user",
"is_user_allowed",
"is_user_allowed_in_any_scope",
Expand Down Expand Up @@ -398,6 +400,53 @@ def get_visible_role_assignments_for_user(
return get_user_assignment_map(user_role_assignments)


def get_visible_role_user_counts(
scope_types: list[str],
allowed_for_user_external_key: str | None = None,
) -> dict[str, int]:
"""Count the distinct active users that hold each role in the given scope types.

Backs the ``user_count`` of the role catalog. It counts users, not assignments: a
user with a role in three courses adds 1 to that role. Only the assignments the viewer can see
count, using the same scope-based visibility as the team members endpoints, and only
active users count, so deactivated accounts don't inflate the Members column.

Assignments are Casbin grouping policies whose scope column holds full keys or globs, so they
can't be grouped by scope type in SQL. They are loaded once and counted in Python, with one
query for the active users.

Args:
scope_types: The scope types to count in (e.g., ``['course-v1', 'lib']``). Org and platform
globs of a scope type (e.g., ``lib:Org1:*``) count as that scope type.
allowed_for_user_external_key: The username of the viewer. If None, every assignment counts.

Returns:
dict[str, int]: The user count by role external key. Roles without active, visible users
are left out.
"""
assignments = [
assignment
for assignment in get_role_assignments()
if isinstance(assignment.subject, UserData) and assignment.scope.NAMESPACE in scope_types
]
assignments = _filter_allowed_assignments(assignments, user_external_key=allowed_for_user_external_key)

active_usernames = set(
User.objects.filter(
username__in={assignment.subject.username for assignment in assignments},
is_active=True,
).values_list("username", flat=True)
)

usernames_by_role: dict[str, set[str]] = defaultdict(set)
for assignment in assignments:
if assignment.subject.username not in active_usernames:
continue
for role in assignment.roles:
usernames_by_role[role.external_key].add(assignment.subject.username)
return {role: len(usernames) for role, usernames in usernames_by_role.items()}


def is_user_allowed(
user_external_key: str,
action_external_key: str,
Expand Down
9 changes: 9 additions & 0 deletions src/openedx_authz/rest_api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ class UserAssignmentSortField(BaseEnum):
EMAIL = "email"


class RoleSortField(BaseEnum):
"""Enum for the role catalog fields to sort by."""

DISPLAY_NAME = "display_name"
SCOPE_TYPE = "scope_type"
USER_COUNT = "user_count"
UPDATED_AT = "updated_at"


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

Expand Down
77 changes: 77 additions & 0 deletions src/openedx_authz/rest_api/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Utility functions for the Open edX AuthZ REST API."""

from datetime import datetime

from openedx_authz.api.data import (
GLOBAL_SCOPE_WILDCARD,
ScopeData,
Expand All @@ -8,6 +10,7 @@
from openedx_authz.rest_api.data import (
AssignmentSortField,
BaseEnum,
RoleSortField,
ScopesTypeField,
SearchField,
SerializedAssignment,
Expand Down Expand Up @@ -204,6 +207,80 @@ def sort_user_assignments(
return _sort_by_field(assignments, sort_by, order, UserAssignmentSortField)


def filter_roles(roles: list[dict], search: str | None, definition_kind: str | None) -> list[dict]:
"""Filter role catalog entries by display name and definition kind.

Runs in Python, not in the database, because static role names are translated with gettext
and the search has to match the name the user sees.

Args:
roles (list[dict]): Role catalog entries, as returned by ``get_role_catalog``.
search (str | None): Optional case-insensitive substring of ``display_name``.
definition_kind (str | None): Optional ``DefinitionKind`` value to keep.

Returns:
list[dict]: The matching roles, preserving the original order.
"""
if not search and not definition_kind:
return roles

search = search.lower() if search else None
return [
role
for role in roles
if (not search or search in (role.get("display_name") or "").lower())
and (not definition_kind or role.get("definition_kind") == definition_kind)
]


def _role_sort_value(role: dict, sort_by: str) -> str | int | datetime | None:
"""Return the value of a role to sort by, or None when the role has no value for the field."""
if sort_by == RoleSortField.DISPLAY_NAME:
return (role.get("display_name") or "").lower()
if sort_by == RoleSortField.SCOPE_TYPE:
return ",".join(role.get("scope_types") or [])
if sort_by == RoleSortField.USER_COUNT:
return role.get("user_count") or 0
return role.get("updated_at")


def sort_roles(
roles: list[dict],
sort_by: RoleSortField = RoleSortField.DISPLAY_NAME,
order: SortOrder = SortOrder.ASC,
) -> list[dict]:
"""Sort role catalog entries by a field and order.

Unlike :func:`_sort_by_field`, the values are not all strings: ``user_count`` sorts as a
number and ``updated_at`` as a date. Roles without a value (``updated_at`` of static roles)
go last in both orders, so a descending sort still starts with the most recently updated
role. Ties are broken by ``role`` identifier, ascending, so pages are stable.

Args:
roles (list[dict]): Role catalog entries, with ``user_count`` and ``updated_at`` set.
sort_by (RoleSortField, optional): The field to sort by. Defaults to ``display_name``.
order (SortOrder, optional): The order to sort by. Defaults to ``asc``.

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

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

# Sort by identifier first: the sort below is stable, also when reversed, so this breaks ties.
by_role = sorted(roles, key=lambda role: role["role"])
with_value = [role for role in by_role if _role_sort_value(role, sort_by) is not None]
without_value = [role for role in by_role if _role_sort_value(role, sort_by) is None]
with_value.sort(key=lambda role: _role_sort_value(role, sort_by), reverse=order == SortOrder.DESC)
return with_value + without_value


def inject_scope_display_names(assignments: list[SerializedAssignment]) -> None:
"""Set ``scope_display_name`` in-place on serialized assignments using one bulk lookup.

Expand Down
61 changes: 52 additions & 9 deletions src/openedx_authz/rest_api/v1/admin_console/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,21 @@

from rest_framework.filters import BaseFilterBackend

from openedx_authz.rest_api.data import AssignmentSortField, SearchField, SortField, SortOrder, UserAssignmentSortField
from openedx_authz.rest_api.utils import filter_users, sort_assignments, sort_user_assignments, sort_users
from openedx_authz.rest_api.data import (
AssignmentSortField,
SearchField,
SortField,
SortOrder,
UserAssignmentSortField,
)
from openedx_authz.rest_api.utils import (
filter_roles,
filter_users,
sort_assignments,
sort_roles,
sort_user_assignments,
sort_users,
)


class TeamMemberSearchFilter(BaseFilterBackend):
Expand All @@ -15,20 +28,25 @@ def filter_queryset(self, request, queryset, view):


class TeamMemberOrderingFilter(BaseFilterBackend):
"""Sort team members by a given field and order."""
"""Sort team members by a given field and order.

An empty ``sort_by`` or ``order`` (e.g. ``?sort_by=``) falls back to the default, as the query
serializer does. Passing the empty string on would make the sort raise ``ValueError`` (a 500).
The other ordering backends below follow the same rule.
"""

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)
sort_by = request.query_params.get("sort_by") or SortField.USERNAME
order = request.query_params.get("order") or 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)
sort_by = request.query_params.get("sort_by") or AssignmentSortField.ROLE
order = request.query_params.get("order") or SortOrder.ASC
return sort_assignments(assignments=queryset, sort_by=sort_by, order=order)


Expand All @@ -51,6 +69,31 @@ class UserAssignmentsOrderingFilter(BaseFilterBackend):
"""Sort user assignments by a given field and order."""

def filter_queryset(self, request, queryset, view):
sort_by = request.query_params.get("sort_by", UserAssignmentSortField.FULL_NAME)
order = request.query_params.get("order", SortOrder.ASC)
sort_by = request.query_params.get("sort_by") or UserAssignmentSortField.FULL_NAME
order = request.query_params.get("order") or SortOrder.ASC
return sort_user_assignments(assignments=queryset, sort_by=sort_by, order=order)


class RoleSearchFilter(BaseFilterBackend):
"""Filter role catalog entries by display name and definition kind.

Reads ``view.validated_query_params``, the output of ``ListRolesQuerySerializer``, instead of the
raw query string, so the values are already stripped, lowercased and defaulted. Reading the raw
``search`` would miss matches for values with surrounding spaces.
"""

def filter_queryset(self, request, queryset, view):
params = view.validated_query_params
return filter_roles(roles=queryset, search=params["search"], definition_kind=params["definition_kind"])


class RoleOrderingFilter(BaseFilterBackend):
"""Sort role catalog entries by a given field and order.

Reads ``view.validated_query_params``, like :class:`RoleSearchFilter`, so empty values already
carry the serializer defaults and only supported values reach :func:`sort_roles`.
"""

def filter_queryset(self, request, queryset, view):
params = view.validated_query_params
return sort_roles(roles=queryset, sort_by=params["sort_by"], order=params["order"])
26 changes: 24 additions & 2 deletions src/openedx_authz/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from openedx_authz import api
from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, DefinitionKind
from openedx_authz.rest_api.data import ScopesTypeField, SortField, SortOrder
from openedx_authz.rest_api.data import RoleSortField, ScopesTypeField, SortField, SortOrder
from openedx_authz.rest_api.utils import get_generic_scope, parse_scope_types
from openedx_authz.rest_api.v1.fields import CommaSeparatedListField, LowercaseCharField

Expand Down Expand Up @@ -193,9 +193,29 @@ class ListUsersInRoleWithScopeResponseSerializer(serializers.Serializer): # pyl


class ListRolesQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Serializer for validating the query parameters of the role catalog."""
"""Serializer for validating the query parameters of the role catalog.

``search``, ``sort_by`` and ``order`` reuse the names of the team members endpoints,
and ``definition_kind`` filters by how a role is defined. Unknown values are a 400.
"""

scope_types = serializers.CharField()
search = LowercaseCharField(required=False, default=None)
definition_kind = serializers.ChoiceField(
required=False,
choices=[(e.value, e.name) for e in DefinitionKind],
default=None,
)
sort_by = serializers.ChoiceField(
required=False,
choices=[(e.value, e.name) for e in RoleSortField],
default=RoleSortField.DISPLAY_NAME.value,
)
order = serializers.ChoiceField(
required=False,
choices=[(e.value, e.name) for e in SortOrder],
default=SortOrder.ASC.value,
)

def validate_scope_types(self, value: str) -> list[str]:
"""Convert the comma-separated scope types to a list of unique, supported scope types.
Expand Down Expand Up @@ -242,6 +262,8 @@ class RoleCatalogRoleSerializer(serializers.Serializer): # pylint: disable=abst
definition_kind = serializers.ChoiceField(choices=[(e.value, e.name) for e in DefinitionKind])
permissions = serializers.ListField(child=serializers.CharField(max_length=255))
scope_types = serializers.ListField(child=serializers.CharField(max_length=255))
user_count = serializers.IntegerField()
updated_at = serializers.DateTimeField(allow_null=True)


class RoleCatalogMetaSerializer(serializers.Serializer): # pylint: disable=abstract-method
Expand Down
Loading
Loading