diff --git a/openedx_authz/filters.py b/openedx_authz/filters.py new file mode 100644 index 00000000..49d10212 --- /dev/null +++ b/openedx_authz/filters.py @@ -0,0 +1,109 @@ +"""Open edX Filters exposed by openedx_authz's REST API.""" + +from typing import Any, Generic, TypedDict, TypeVar + +from openedx_filters.tooling import OpenEdxPublicFilter + + +class ScopedItem(TypedDict, total=False): + """Optional scope on a permission result; omission represents an any-scope check.""" + + scope: str | None + + +class ValidationItem(ScopedItem): + """A permission result, including the action and its authorization outcome.""" + + action: str + allowed: bool + + +class RoleAssignmentItems(TypedDict): + """Validated input for assigning a role to users in one or more scopes.""" + + role: str + users: list[str] + scopes: list[str] + + +class RoleRemovalItems(TypedDict): + """Validated input for removing a role from users in one scope.""" + + role: str + users: list[str] + scope: str + + +AuthorizationItems = TypeVar("AuthorizationItems", list[ValidationItem], RoleAssignmentItems, RoleRemovalItems) + + +class AuthorizationDataRequested(OpenEdxPublicFilter, Generic[AuthorizationItems]): + """ + Shared pipeline plumbing for operation-specific REST authorization filters. + + Subclasses declare their payload type and filter identifier. Pipeline steps own + rejection rules and return data in the original shape, preserving earlier errors. + """ + + @classmethod + def run_filter( + cls, items: AuthorizationItems, + ) -> tuple[AuthorizationItems, list[dict[str, Any]]]: + """ + Run the operation's configured pipeline with an initially empty error list. + + Args: + items (AuthorizationItems): Computed permission results or validated role + change data, using the payload type declared by the subclass. + Returns: + tuple[AuthorizationItems, list[dict[str, Any]]]: Items in their original + shape and accumulated pipeline errors. Without a configured pipeline, + returns the original items and an empty error list. + """ + data = super().run_pipeline(items=items, errors=[]) + return data["items"], data["errors"] + + +class PermissionValidationRequested(AuthorizationDataRequested[list[ValidationItem]]): + """ + Filter computed permission results before response serialization. + + Each item contains ``action`` and ``allowed``, with an optional ``scope``. + + Trigger: + ``PermissionValidationMeView.post``, after authorization checks and before + response serialization. + + Filter Type: + org.openedx.authz.permission_validation.requested.v1 + """ + + filter_type = "org.openedx.authz.permission_validation.requested.v1" + + +class RoleAssignmentRequested(AuthorizationDataRequested[RoleAssignmentItems]): + """ + Filter validated ``role``, ``users``, and ``scopes`` before assignment writes. + + Trigger: + ``RoleUserAPIView.put``, after request validation and before assigning roles. + + Filter Type: + org.openedx.authz.role_assignment.requested.v1 + """ + + filter_type = "org.openedx.authz.role_assignment.requested.v1" + + +class RoleRemovalRequested(AuthorizationDataRequested[RoleRemovalItems]): + """ + Filter validated ``role``, ``users``, and ``scope`` before removal writes. + + Trigger: + ``RoleUserAPIView.delete``, after request validation and before removing roles. + + Filter Type: + org.openedx.authz.role_removal.requested.v1 + """ + + filter_type = "org.openedx.authz.role_removal.requested.v1" diff --git a/openedx_authz/rest_api/v1/course_authoring/pipeline.py b/openedx_authz/rest_api/v1/course_authoring/pipeline.py new file mode 100644 index 00000000..445e8e96 --- /dev/null +++ b/openedx_authz/rest_api/v1/course_authoring/pipeline.py @@ -0,0 +1,262 @@ +""" +Pipeline steps implementing course-authoring visibility for REST authorization filters. + +This is the isolated, opt-in implementation of the exception documented in +``docs/decisions/0016-rest-api-domain-ownership-boundary.rst`` and +``docs/decisions/0017-cross-domain-filtering-via-openedx-filters.rst``. It's the only place in +openedx_authz that computes course-authoring-flag visibility, and it's never registered unless a +deployment's ``OPEN_EDX_FILTERS_CONFIG`` explicitly wires it in, typically via a Tutor plugin +patch. Deleting this file and the patch that registers it removes the mechanism entirely; no +endpoint code depends on it existing. +""" + +from collections.abc import Iterable +from typing import Generic + +from openedx_filters.filters import PipelineStep + +from openedx_authz import api +from openedx_authz.filters import ( + AuthorizationItems, + RoleAssignmentItems, + RoleRemovalItems, + ValidationItem, +) + +SCOPE_NOT_AVAILABLE_ERROR = "scope_not_available" + +try: + # common.djangoapps.student.roles and openedx.core are edx-platform's own modules. This app + # is an edx-platform plugin, so they're always available at runtime; the imports are only + # guarded so this module can still load under this repo's own standalone test suite + # (openedx_authz.settings.test, no edx-platform installed). + from common.djangoapps.student.roles import enable_authz_course_authoring + from openedx.core.djangoapps.waffle_utils.models import WaffleFlagOrgOverrideModel + from openedx.core.toggles import AUTHZ_COURSE_AUTHORING_FLAG +except ImportError: + enable_authz_course_authoring = None + WaffleFlagOrgOverrideModel = None + AUTHZ_COURSE_AUTHORING_FLAG = None + + +def is_scope_visible(scope: api.ScopeData) -> bool: + """ + Return whether a scope is visible under the course-authoring flag. + + - Library and other non-course scopes (e.g. ``lib:DemoX:CSPROB``): always visible. + - Concrete course (e.g. ``course-v1:DemoX+CS101+2024``): full course/org/platform + cascade via ``enable_authz_course_authoring(course_key)``. + - Org-level course glob (e.g. ``course-v1:DemoX+*``): org override, else platform default. + - Platform-level course glob (``course-v1:*``): platform tier only, no course or org. + + Args: + scope (ScopeData): A resolved scope instance. + + Returns: + bool: True if the scope should count as visible. + """ + if scope.NAMESPACE != api.CourseOverviewData.NAMESPACE: + return True + if isinstance(scope, api.CourseOverviewData): + return enable_authz_course_authoring(scope.course_key) + if isinstance(scope, api.OrgCourseOverviewGlobData): + # enable_authz_course_authoring only accepts a course key, and there's no public + # edx-platform API to check an org alone, so this checks the org override directly + # (see issue #360 for follow-up) when asked to check an org-level course glob + org_override = WaffleFlagOrgOverrideModel.override_value(AUTHZ_COURSE_AUTHORING_FLAG.name, scope.org) + if org_override == WaffleFlagOrgOverrideModel.ALL_CHOICES.on: + return True + if org_override == WaffleFlagOrgOverrideModel.ALL_CHOICES.off: + return False + return enable_authz_course_authoring() + + +class CourseAuthoringVisibilityFilter(PipelineStep, Generic[AuthorizationItems]): + """Share scope visibility and error accumulation across operations.""" + + def run_filter( # pylint: disable=arguments-differ + self, + items: AuthorizationItems, + errors: list[dict], + **kwargs, + ) -> dict: + """ + Apply the subclass's transformation and preserve earlier pipeline errors. + + Args: + items (AuthorizationItems): Operation-specific input documented by the subclass. + errors (list[dict]): Errors from earlier steps, preserved before new errors. + **kwargs: Additional pipeline arguments, unused by this step. + + Returns: + dict: Filtered ``items`` in the original shape and accumulated ``errors``. + """ + filtered_items, new_errors = self._filter_items(items) + return {"items": filtered_items, "errors": [*errors, *new_errors]} + + def _filter_items(self, items: AuthorizationItems) -> tuple[AuthorizationItems, list[dict]]: + """ + Define the transformation implemented by each operation-specific step. + + Args: + items (AuthorizationItems): The operation's input data. + + Returns: + tuple[AuthorizationItems, list[dict]]: Transformed items in the original + shape and errors produced by this step, excluding earlier errors. + + Raises: + NotImplementedError: The subclass has not implemented its transformation. + """ + raise NotImplementedError("Subclasses must implement their operation's transformation.") + + @staticmethod + def _hidden_scopes(scopes: Iterable[str | None]) -> set[str]: + """ + Find scopes hidden by the course-authoring flag. + + Args: + scopes (Iterable[str | None]): External scope keys. None and empty strings + represent any-scope checks and are skipped. + + Returns: + set[str]: Scope keys hidden by the flag. + """ + return { + scope + for scope in scopes + if scope and not is_scope_visible(api.ScopeData(external_key=scope)) + } + + @staticmethod + def _role_change_errors(user_identifiers: list[str], hidden_scopes: Iterable[str]) -> list[dict]: + """ + Build one error per affected user/scope pair. + + Args: + user_identifiers (list[str]): Usernames or email addresses from the batch. + hidden_scopes (Iterable[str]): Hidden external scope keys, in error order. + + Returns: + list[dict]: Errors containing ``user_identifier``, ``scope``, and + ``error="scope_not_available"``, ordered by scope then user as supplied. + """ + return [ + { + "user_identifier": user_identifier, + "scope": scope, + "error": SCOPE_NOT_AVAILABLE_ERROR, + } + for scope in hidden_scopes + for user_identifier in user_identifiers + ] + + +class CourseAuthoringPermissionValidationFilter(CourseAuthoringVisibilityFilter[list[ValidationItem]]): + """ + Deny permission results whose scopes are hidden, retaining every result. + + Input ``items`` contains computed permission results:: + + [ + { + "action": "courses.manage_course_team", + "scope": "course-v1:DemoX+CS101+2024", + "allowed": True, + }, + {"action": "courses.manage_course_team", "allowed": True}, + ] + + An absent, None, or empty ``scope`` represents an any-scope check and remains + unchanged. Hidden scopes receive ``allowed=False``; other fields are preserved. + """ + + def _filter_items(self, items: list[ValidationItem]) -> tuple[list[ValidationItem], list[dict]]: + """ + Set hidden-scope permission results to disallowed without dropping items. + + Args: + items (list[ValidationItem]): Computed results with ``action``, ``allowed``, + and an optional ``scope``; see the class docstring for an input example. + + Returns: + tuple[list[ValidationItem], list[dict]]: Results in their original order, + with ``allowed=False`` for hidden scopes, and an empty error list. + Any-scope results and other fields remain unchanged. + """ + hidden = self._hidden_scopes(item.get("scope") for item in items) + filtered: list[ValidationItem] = [ + {**item, "allowed": False} if item.get("scope") in hidden else item + for item in items + ] + return filtered, [] + + +class CourseAuthoringRoleAssignmentFilter(CourseAuthoringVisibilityFilter[RoleAssignmentItems]): + """ + Exclude hidden scopes from validated assignment data before writes. + + Input ``items`` contains a role, user identifiers, and one or more scopes:: + + { + "role": "course_staff", + "users": ["alice"], + "scopes": ["course-v1:DemoX+CS101+2024", "course-v1:DemoX+*"], + } + + Visible scopes remain in their original order. Each rejected user/scope pair + produces a ``scope_not_available`` error. + """ + + def _filter_items(self, items: RoleAssignmentItems) -> tuple[RoleAssignmentItems, list[dict]]: + """ + Remove hidden scopes from an assignment batch and report affected users. + + Args: + items (RoleAssignmentItems): Validated ``role``, ``users`` (usernames or + emails), and ``scopes``; see the class docstring for an input example. + + Returns: + tuple[RoleAssignmentItems, list[dict]]: Assignment data containing only + visible scopes, in order, and one ``scope_not_available`` error for + each rejected user/scope pair. + """ + scopes = items["scopes"] + hidden = self._hidden_scopes(scopes) + filtered: RoleAssignmentItems = {**items, "scopes": [scope for scope in scopes if scope not in hidden]} + errors = self._role_change_errors(items["users"], (scope for scope in scopes if scope in hidden)) + return filtered, errors + + +class CourseAuthoringRoleRemovalFilter(CourseAuthoringVisibilityFilter[RoleRemovalItems]): + """ + Exclude users from validated removal data when its scope is hidden. + + Input ``items`` contains a role, user identifiers, and a single scope:: + + { + "role": "course_staff", + "users": ["alice"], + "scope": "course-v1:DemoX+CS101+2024", + } + + A hidden scope clears ``users`` and produces a ``scope_not_available`` error + for each affected user. Visible scopes leave the data unchanged. + """ + + def _filter_items(self, items: RoleRemovalItems) -> tuple[RoleRemovalItems, list[dict]]: + """ + Clear the removal batch's users when its scope is hidden. + + Args: + items (RoleRemovalItems): Validated ``role``, ``users`` (usernames or + emails), and ``scope``; see the class docstring for an input example. + + Returns: + tuple[RoleRemovalItems, list[dict]]: Removal data with ``users`` cleared + and one ``scope_not_available`` error per user if the scope is hidden. + Otherwise, returns unchanged data and no errors. + """ + hidden = self._hidden_scopes([items["scope"]]) + filtered: RoleRemovalItems = {**items, "users": [] if hidden else items["users"]} + return filtered, self._role_change_errors(items["users"], hidden) diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index d3ddcfab..e571166c 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -20,6 +20,7 @@ from openedx_authz import api from openedx_authz.api.utils import get_user_map from openedx_authz.constants import permissions +from openedx_authz.filters import PermissionValidationRequested, RoleAssignmentRequested, RoleRemovalRequested from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus from openedx_authz.rest_api.decorators import authz_permissions, view_auth_classes from openedx_authz.rest_api.utils import ( @@ -152,6 +153,9 @@ def post(self, request: HttpRequest) -> Response: status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + response_data, _ = PermissionValidationRequested.run_filter( + items=response_data + ) serializer = PermissionValidationResponseSerializer(response_data, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -313,7 +317,10 @@ def put(self, request: HttpRequest) -> Response: serializer.is_valid(raise_exception=True) data = serializer.validated_data - completed, errors = [], [] + data, errors = RoleAssignmentRequested.run_filter( + items=data + ) + completed = [] for scope_value in data["scopes"]: for user_identifier in data["users"]: response_dict = {"user_identifier": user_identifier, "scope": scope_value} @@ -360,7 +367,10 @@ def delete(self, request: HttpRequest) -> Response: serializer.is_valid(raise_exception=True) data = serializer.validated_data - completed, errors = [], [] + data, errors = RoleRemovalRequested.run_filter( + items=data + ) + completed = [] for user_identifier in data["users"]: response_dict = {"user_identifier": user_identifier} try: diff --git a/openedx_authz/tests/rest_api/course_authoring/test_pipeline.py b/openedx_authz/tests/rest_api/course_authoring/test_pipeline.py new file mode 100644 index 00000000..52426f7a --- /dev/null +++ b/openedx_authz/tests/rest_api/course_authoring/test_pipeline.py @@ -0,0 +1,209 @@ +"""Unit tests for the course-authoring visibility pipeline step. + +The three-tier cascade (course override, else org override, else platform +default) is edx-platform's ``CourseWaffleFlag.is_enabled()``, not importable +in this repo's standalone test suite. ``CourseWaffleFlagMock`` stands in for +it, so the truth table can still be exercised end to end. +""" + +from unittest.mock import MagicMock, patch + +from ddt import data, ddt, unpack +from django.test import TestCase, override_settings + +from openedx_authz.api.data import ContentLibraryData, CourseOverviewData, OrgCourseOverviewGlobData +from openedx_authz.filters import PermissionValidationRequested, RoleAssignmentRequested, RoleRemovalRequested +from openedx_authz.rest_api.v1.course_authoring.pipeline import ( + CourseAuthoringPermissionValidationFilter, + CourseAuthoringRoleAssignmentFilter, + CourseAuthoringRoleRemovalFilter, + is_scope_visible, +) + +COURSE_SCOPE = "course-v1:Org1+COURSE1+2024" +OTHER_COURSE_SCOPE = "course-v1:Org1+COURSE2+2024" +LIB_SCOPE = "lib:Org1:LIB1" +ORG_GLOB_COURSE_SCOPE = OrgCourseOverviewGlobData.build_external_key("Org1") + + +class CourseWaffleFlagMock: + """Stand-in for edx-platform's ``CourseWaffleFlag``, not importable in this repo's standalone suite.""" + + def __init__(self, platform: bool, org_override: bool | None = None, course_override: bool | None = None): + self.platform = platform + self.org_override = org_override + self.course_override = course_override + + def __call__(self, course_key=None) -> bool: + if self.course_override is not None: + return self.course_override + if self.org_override is not None: + return self.org_override + return self.platform + + +@ddt +class TestIsScopeVisible(TestCase): + """Test is_scope_visible, dispatching to the right override tier depending on the scope's type.""" + + @data( + (False, None, None, False), + (True, None, None, True), + (False, True, None, True), + (True, False, None, False), + (False, None, True, True), + (True, None, False, False), + ) + @unpack + def test_course_scope_follows_the_truth_table( + self, platform: bool, org_override: bool | None, course_override: bool | None, expected: bool + ): + """Test is_scope_visible for a concrete course scope against override combinations. + + Expected result: + - The scope is visible exactly when course override wins, else org + override, else platform default. + """ + with patch( + "openedx_authz.rest_api.v1.course_authoring.pipeline.enable_authz_course_authoring", + CourseWaffleFlagMock(platform, org_override, course_override), + ): + self.assertEqual(is_scope_visible(CourseOverviewData(external_key=COURSE_SCOPE)), expected) + + def test_library_scope_is_always_visible_regardless_of_the_flag(self): + """Test is_scope_visible for a library scope. + + Expected result: + - The scope is always visible, since it isn't course-authoring-gated. + """ + with patch( + "openedx_authz.rest_api.v1.course_authoring.pipeline.enable_authz_course_authoring", return_value=False + ): + self.assertTrue(is_scope_visible(ContentLibraryData(external_key=LIB_SCOPE))) + + @data( + ("on", False, True), + ("off", True, False), + ("unset", True, True), + ) + @unpack + def test_org_glob_scope_org_override_takes_precedence( + self, override_choice: str, platform_default: bool, expected: bool + ): + """Test is_scope_visible for an org-glob scope against org/platform combinations. + + Expected result: + - The scope follows the org override when set, else the platform default. + """ + mock_org_model = MagicMock() + mock_org_model.ALL_CHOICES.on = "on" + mock_org_model.ALL_CHOICES.off = "off" + mock_org_model.override_value.return_value = override_choice + scope = OrgCourseOverviewGlobData(external_key=ORG_GLOB_COURSE_SCOPE) + + with patch( + "openedx_authz.rest_api.v1.course_authoring.pipeline.WaffleFlagOrgOverrideModel", mock_org_model + ), patch( + "openedx_authz.rest_api.v1.course_authoring.pipeline.AUTHZ_COURSE_AUTHORING_FLAG", + MagicMock(name="authz.enable_course_authoring"), + ), patch( + "openedx_authz.rest_api.v1.course_authoring.pipeline.enable_authz_course_authoring", + return_value=platform_default, + ): + self.assertEqual(is_scope_visible(scope), expected) + + +@ddt +class TestCourseAuthoringPermissionValidationFilter(TestCase): + """Test operation-specific course-authoring visibility steps.""" + + def test_marks_allowed_false_instead_of_dropping_when_the_item_has_an_allowed_key(self): + """Test run_filter with an item that carries an ``allowed`` key, mirroring PermissionValidationMeView. + + Expected result: + - The item survives, but with ``allowed`` flipped to ``False``. + """ + items = [{"scope": COURSE_SCOPE, "action": "view", "allowed": True}] + with patch( + "openedx_authz.rest_api.v1.course_authoring.pipeline.enable_authz_course_authoring", return_value=False + ): + result = CourseAuthoringPermissionValidationFilter( + filter_type="test", running_pipeline=[] + ).run_filter( + items=items, errors=[] + ) + + self.assertEqual( + result, + {"items": [{"scope": COURSE_SCOPE, "action": "view", "allowed": False}], "errors": []}, + ) + + def test_leaves_any_scope_items_untouched(self): + """Test run_filter with an item whose scope is None (an any-scope check). + + Expected result: + - The item survives unchanged; there's no single scope to check visibility against. + """ + items = [ + {"scope": None, "action": "view", "allowed": True}, + {"action": "view", "allowed": True}, + {"scope": "", "action": "view", "allowed": True}, + ] + with patch( + "openedx_authz.rest_api.v1.course_authoring.pipeline.enable_authz_course_authoring", return_value=False + ): + result = CourseAuthoringPermissionValidationFilter( + filter_type="test", running_pipeline=[] + ).run_filter( + items=items, errors=[] + ) + + self.assertEqual(result, {"items": items, "errors": []}) + + def test_assignment_keeps_visible_scopes_and_preserves_previous_errors(self): + """Partial rejection keeps available writes and earlier pipeline errors.""" + items = {"role": "course_staff", "users": ["alice", "bob"], "scopes": [COURSE_SCOPE, LIB_SCOPE]} + previous_errors = [{"error": "previous_step"}] + with patch( + "openedx_authz.rest_api.v1.course_authoring.pipeline.enable_authz_course_authoring", return_value=False + ): + result = CourseAuthoringRoleAssignmentFilter(filter_type="test", running_pipeline=[]).run_filter( + items=items, errors=previous_errors + ) + + self.assertEqual(result["items"], {**items, "scopes": [LIB_SCOPE]}) + self.assertEqual(result["errors"], [ + {"error": "previous_step"}, + {"user_identifier": "alice", "scope": COURSE_SCOPE, "error": "scope_not_available"}, + {"user_identifier": "bob", "scope": COURSE_SCOPE, "error": "scope_not_available"}, + ]) + self.assertEqual(previous_errors, [{"error": "previous_step"}]) + self.assertEqual(items["scopes"], [COURSE_SCOPE, LIB_SCOPE]) + + def test_removal_preserves_extra_fields(self): + """Additional metadata must not turn a removal into an assignment.""" + items = {"role": "course_staff", "users": ["alice"], "scope": COURSE_SCOPE, "scopes": [LIB_SCOPE]} + with patch( + "openedx_authz.rest_api.v1.course_authoring.pipeline.enable_authz_course_authoring", return_value=False + ): + result = CourseAuthoringRoleRemovalFilter(filter_type="test", running_pipeline=[]).run_filter( + items=items, errors=[] + ) + + self.assertEqual(result["items"], {**items, "users": []}) + self.assertEqual(result["errors"], [ + {"user_identifier": "alice", "scope": COURSE_SCOPE, "error": "scope_not_available"}, + ]) + + @data( + (PermissionValidationRequested, [{"action": "view", "allowed": True}]), + (RoleAssignmentRequested, {"role": "course_staff", "users": ["alice"], "scopes": [COURSE_SCOPE]}), + (RoleRemovalRequested, {"role": "course_staff", "users": ["alice"], "scope": COURSE_SCOPE}), + ) + @unpack + @override_settings(OPEN_EDX_FILTERS_CONFIG={}) + def test_unconfigured_hook_returns_original_data(self, filter_class, items): + """Each public hook passes through its payload when no pipeline is configured.""" + filtered, errors = filter_class.run_filter(items=items) + self.assertEqual(filtered, items) + self.assertEqual(errors, []) diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index 17baade7..9beb86a5 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -10,6 +10,7 @@ from ddt import data, ddt, unpack from django.contrib.auth import get_user_model +from django.test import override_settings from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient @@ -381,6 +382,46 @@ def test_permission_validation_exception_handling(self, exception: Exception, st self.assertEqual(response.status_code, status_code) self.assertEqual(response.data, {"message": message}) + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.authz.permission_validation.requested.v1": { + "pipeline": [ + "openedx_authz.rest_api.v1.course_authoring.pipeline.CourseAuthoringPermissionValidationFilter", + ], + "fail_silently": False, + }, + }, + ) + def test_permission_validation_marks_hidden_course_scope_as_disallowed(self): + """Test PermissionValidationMeView with the real course-authoring pipeline step configured. + + Expected result: + - Returns 200 OK status + - The course item is marked allowed=False, since the flag is disabled for its scope + - The library item is unaffected, since course-authoring visibility never gates it + """ + self.client.force_authenticate(user=self.regular_user) + assign_role_to_user_in_scope( + user_external_key=self.regular_user.username, + role_external_key=roles.COURSE_STAFF.external_key, + scope_external_key=COURSE_SCOPE_ORG1, + ) + request_data = [ + {"action": permissions.COURSES_VIEW_COURSE.identifier, "scope": COURSE_SCOPE_ORG1}, + {"action": permissions.VIEW_LIBRARY.identifier, "scope": LIB_SCOPE_ORG1}, + ] + + with patch( + "openedx_authz.rest_api.v1.course_authoring.pipeline.enable_authz_course_authoring", + return_value=False, + ): + response = self.client.post(self.url, data=request_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.data[0]["allowed"]) + self.assertTrue(response.data[1]["allowed"]) + + @ddt class TestRoleUserAPIView(ViewTestMixin): @@ -821,6 +862,46 @@ def test_add_users_to_role_course_permissions(self, username: str, status_code: self.assertEqual(response.status_code, status_code) + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.authz.role_assignment.requested.v1": { + "pipeline": [ + "openedx_authz.rest_api.v1.course_authoring.pipeline.CourseAuthoringRoleAssignmentFilter", + ], + "fail_silently": False, + }, + }, + ) + @patch.object(api, "assign_role_to_user_in_scope") + def test_add_users_to_role_reports_hidden_scope_without_writing(self, mock_assign_role_to_user_in_scope): + """A scope hidden by the configured filter is reported and never written.""" + self.client.force_authenticate(user=User.objects.get(username="course_admin")) + request_data = { + "role": roles.COURSE_STAFF.external_key, + "scope": COURSE_SCOPE_ORG1, + "users": ["regular_2"], + } + + with patch.object(api.CourseOverviewData, "exists", return_value=True), patch( + "openedx_authz.rest_api.v1.course_authoring.pipeline.enable_authz_course_authoring", + return_value=False, + ): + response = self.client.put(self.url, data=request_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) + self.assertEqual(response.data["completed"], []) + self.assertEqual( + response.data["errors"], + [ + { + "user_identifier": "regular_2", + "scope": COURSE_SCOPE_ORG1, + "error": "scope_not_available", + } + ], + ) + mock_assign_role_to_user_in_scope.assert_not_called() + @data( # With username ----------------------------- # Single user - success (admin user) @@ -995,6 +1076,46 @@ def test_remove_users_from_role_course_permissions(self, username: str, status_c self.assertEqual(response.status_code, status_code) + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.authz.role_removal.requested.v1": { + "pipeline": [ + "openedx_authz.rest_api.v1.course_authoring.pipeline.CourseAuthoringRoleRemovalFilter", + ], + "fail_silently": False, + }, + }, + ) + @patch.object(api, "unassign_role_from_user") + def test_remove_users_from_role_reports_hidden_scope_without_writing(self, mock_unassign_role_from_user): + """A scope hidden by the configured filter is reported and never written.""" + self.client.force_authenticate(user=User.objects.get(username="course_admin")) + query_params = { + "role": roles.COURSE_STAFF.external_key, + "scope": COURSE_SCOPE_ORG1, + "users": "regular_2", + } + + with patch.object(api.CourseOverviewData, "exists", return_value=True), patch( + "openedx_authz.rest_api.v1.course_authoring.pipeline.enable_authz_course_authoring", + return_value=False, + ): + response = self.client.delete(f"{self.url}?{urlencode(query_params)}") + + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) + self.assertEqual(response.data["completed"], []) + self.assertEqual( + response.data["errors"], + [ + { + "user_identifier": "regular_2", + "scope": COURSE_SCOPE_ORG1, + "error": "scope_not_available", + } + ], + ) + mock_unassign_role_from_user.assert_not_called() + @ddt class TestRoleUserAPIViewScopeStringValidation(ViewTestMixin): diff --git a/requirements/base.in b/requirements/base.in index 87a5de8a..24baac7d 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -15,4 +15,5 @@ edx-drf-extensions # Extensions for Django Rest Framework used by Open edx-organizations # Organizations library for Open edX django-waffle # Waffle library for feature flag management openedx-events # Open edX Events library for emitting events related to role assignment changes +openedx-filters # Open edX Filters library, used to let another domain filter REST API list results django-crum # Current Request User Middleware for Django diff --git a/requirements/base.txt b/requirements/base.txt index 425f8e2e..a6d0e189 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -104,6 +104,8 @@ openedx-atlas==0.7.0 # via -r requirements/base.in openedx-events==11.2.0 # via -r requirements/base.in +openedx-filters==3.8.0 + # via -r requirements/base.in packaging==26.2 # via drf-yasg pillow==12.3.0