Skip to content
Open
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
14 changes: 11 additions & 3 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,23 @@ Change Log
Unreleased
**********

1.17.0 - 2026-05-26
*******************

Added
=====

* Add support for platform glob scopes.

1.16.0 - 2026-05-21
********************

Changed
=======

* (Patched onto newer changes as well as 0.20.1) Removed checks for libraries v2 when the enforcer is loaded. This was
originally add to improve performance, but a circular import on
openedx-platform caused it to always default to true. This ensures that the
* (Patched onto newer changes as well as 0.20.1) Removed checks for libraries v2 when
the enforcer is loaded. This was originally add to improve performance, but a circular
import on openedx-platform caused it to always default to true. This ensures that the
enforcer continues to work even if the circular import is resolved.

1.15.0 - 2026-04-30
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.16.0"
__version__ = "1.17.0"

ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
380 changes: 340 additions & 40 deletions openedx_authz/api/data.py

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions openedx_authz/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
(e.g., 'user^john_doe').
"""

import logging

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

Expand Down Expand Up @@ -41,6 +43,8 @@
from openedx_authz.api.utils import filter_user_assignments, get_user_assignment_map
from openedx_authz.utils import get_user_by_username_or_email

log = logging.getLogger(__name__)

User = get_user_model()


Expand Down Expand Up @@ -272,16 +276,20 @@ def _filter_allowed_assignments(
) -> list[RoleAssignmentData]:
"""
Filter the given role assignments to only include those that the user has permission to view.

Assignments whose scope does not implement ``get_admin_view_permission``
are skipped with a warning
"""
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

# Get the permission needed to view the specific scope in the admin console
permission = assignment.scope.get_admin_view_permission().identifier
try:
permission = assignment.scope.get_admin_view_permission().identifier
except NotImplementedError:
log.warning("Skipping assignment with unsupported scope %r", assignment.scope.external_key)
continue

if permission and is_user_allowed(
user_external_key=user_external_key,
Expand Down
2 changes: 2 additions & 0 deletions openedx_authz/engine/matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
CourseOverviewData,
OrgContentLibraryGlobData,
OrgCourseOverviewGlobData,
PlatformCourseOverviewGlobData,
ScopeData,
UserData,
)
Expand All @@ -21,6 +22,7 @@
(CourseOverviewData.NAMESPACE, CourseOverviewData),
(OrgContentLibraryGlobData.NAMESPACE, OrgContentLibraryGlobData),
(OrgCourseOverviewGlobData.NAMESPACE, OrgCourseOverviewGlobData),
(PlatformCourseOverviewGlobData.NAMESPACE, PlatformCourseOverviewGlobData),
}


Expand Down
1 change: 0 additions & 1 deletion openedx_authz/migrations/0009_roleassignmentaudit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@


class Migration(migrations.Migration):

dependencies = [
("openedx_authz", "0008_authzcourseauthoringmigrationrun"),
]
Expand Down
4 changes: 1 addition & 3 deletions openedx_authz/rest_api/v1/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,7 @@ def get_scope_namespace(self, request) -> str:
scopes_list = request.data.get("scopes")
if scopes_list and isinstance(scopes_list, list):
if not self._scopes_have_homogeneous_namespaces(scopes_list):
raise ValueError(
f"Mixed scope namespaces in bulk request are not allowed: {scopes_list}"
)
raise ValueError(f"Mixed scope namespaces in bulk request are not allowed: {scopes_list}")
scope_value = self.get_scope_value(request)
if not scope_value:
return self.NAMESPACE
Expand Down
13 changes: 12 additions & 1 deletion openedx_authz/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from rest_framework import serializers

from openedx_authz import api
from openedx_authz.api.data import UserAssignments
from openedx_authz.api.data import GLOBAL_SCOPE_WILDCARD, UserAssignments
from openedx_authz.rest_api.data import (
AssignmentSortField,
ScopesTypeField,
Expand Down Expand Up @@ -87,6 +87,11 @@ def _validate_scope_and_role(self, scope_value: str, role_value: str) -> None:
serializers.ValidationError: If the scope is not registered, doesn't exist,
or if the role is not defined in the scope.
"""
if scope_value == GLOBAL_SCOPE_WILDCARD:
raise serializers.ValidationError(
{"scope": "Global wildcard scope '*' is not accepted via the API. Use a specific scope key."}
)

try:
scope = api.ScopeData(external_key=scope_value)
except ValueError as exc:
Expand Down Expand Up @@ -215,6 +220,10 @@ def validate_scope(self, value: str) -> api.ScopeData:
>>> validate_scope('lib:DemoX:CSPROB')
ContentLibraryData(external_key='lib:DemoX:CSPROB')
"""
if value == GLOBAL_SCOPE_WILDCARD:
raise serializers.ValidationError(
"Global wildcard scope '*' is not accepted via the API. Use a specific scope key."
)
try:
return api.ScopeData(external_key=value)
except ValueError as exc:
Expand Down Expand Up @@ -398,6 +407,8 @@ def get_org(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) ->
case api.SuperAdminAssignmentData():
return "*"
case api.RoleAssignmentData():
if obj.scope.IS_PLATFORM_GLOB:
return "*"
return getattr(obj.scope, "org", "")

def get_scope(self, obj: api.RoleAssignmentData | api.SuperAdminAssignmentData) -> str:
Expand Down
22 changes: 15 additions & 7 deletions openedx_authz/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
CourseOverviewData,
OrgContentLibraryGlobData,
OrgCourseOverviewGlobData,
PlatformGlobData,
RoleAssignmentData,
SuperAdminAssignmentData,
UserAssignmentData,
Expand Down Expand Up @@ -256,7 +257,9 @@ class RoleUserAPIView(APIView):
**Authentication and Permissions**

- Requires authenticated user.
- Requires ``manage_library_team`` permission for the scope.

- GET: Requires ``view_library_team`` or ``view_course_team`` permission according to the scope.
- PUT and DELETE: Requires ``manage_library_team`` or ``manage_course_team`` permission according to the scope.

**Example Request**

Expand Down Expand Up @@ -784,7 +787,7 @@ def _get_allowed_scope_queryset(
*,
username: str,
scope_cls: type,
glob_cls: type,
org_glob_cls: type,
get_permission: callable,
queryset_builder: callable,
extract_ids: callable,
Expand All @@ -801,7 +804,7 @@ def _get_allowed_scope_queryset(
Args:
username: The username to check permissions for.
scope_cls: The concrete scope data class (e.g., CourseOverviewData).
glob_cls: The org-level glob class (e.g., OrgCourseOverviewGlobData).
org_glob_cls: The org-level glob class (e.g., OrgCourseOverviewGlobData).
get_permission: Callable that returns the permission for a scope class.
queryset_builder: Callable that builds the filtered queryset (e.g., _get_courses_queryset).
extract_ids: Callable that extracts specific IDs from non-glob scopes.
Expand All @@ -812,9 +815,14 @@ def _get_allowed_scope_queryset(
QuerySet: The filtered queryset projected to the unified scope shape.
"""
allowed_scopes = get_scopes_for_user_and_permission(username, get_permission(scope_cls).identifier)
specific_scopes = [s for s in allowed_scopes if not isinstance(s, glob_cls)]

has_platform_access = any(isinstance(s, PlatformGlobData) for s in allowed_scopes)
if has_platform_access:
return queryset_builder(allowed_ids=None, allowed_orgs=None, search=search, orgs=orgs)

specific_scopes = [s for s in allowed_scopes if not s.IS_GLOB]
allowed_ids = extract_ids(specific_scopes)
allowed_orgs = {s.org for s in allowed_scopes if isinstance(s, glob_cls)}
allowed_orgs = {s.org for s in allowed_scopes if isinstance(s, org_glob_cls)}
return queryset_builder(allowed_ids, allowed_orgs, search=search, orgs=orgs)

def _build_queryset(self, courses_qs: QuerySet | None, libraries_qs: QuerySet | None) -> QuerySet:
Expand Down Expand Up @@ -873,7 +881,7 @@ def get_permission(scope_cls):
courses_qs = self._get_allowed_scope_queryset(
username=user.username,
scope_cls=CourseOverviewData,
glob_cls=OrgCourseOverviewGlobData,
org_glob_cls=OrgCourseOverviewGlobData,
get_permission=get_permission,
queryset_builder=self._get_courses_queryset,
extract_ids=lambda scopes: {s.external_key for s in scopes},
Expand All @@ -886,7 +894,7 @@ def get_permission(scope_cls):
libraries_qs = self._get_allowed_scope_queryset(
username=user.username,
scope_cls=ContentLibraryData,
glob_cls=OrgContentLibraryGlobData,
org_glob_cls=OrgContentLibraryGlobData,
get_permission=get_permission,
queryset_builder=self._get_libraries_queryset,
extract_ids=lambda scopes: {
Expand Down
Loading