From 6217d3e0b630d544d696a298d22683cdf39b2166 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Tue, 31 Mar 2026 11:27:44 -0600 Subject: [PATCH 01/13] feat: add AuthZ permissions to course creation and outline --- .../contentstore/api/tests/test_validation.py | 87 ++++++++++++++++++- .../api/views/course_validation.py | 4 +- .../rest_api/v1/views/course_index.py | 11 ++- .../v1/views/tests/test_course_index.py | 62 +++++++++++++ .../rest_api/v2/views/downstreams.py | 9 +- .../v2/views/tests/test_downstreams.py | 65 ++++++++++++++ 6 files changed, 230 insertions(+), 8 deletions(-) diff --git a/cms/djangoapps/contentstore/api/tests/test_validation.py b/cms/djangoapps/contentstore/api/tests/test_validation.py index 012a5b00559d..99f04f666b6f 100644 --- a/cms/djangoapps/contentstore/api/tests/test_validation.py +++ b/cms/djangoapps/contentstore/api/tests/test_validation.py @@ -11,7 +11,7 @@ from django.contrib.auth import get_user_model from django.test.utils import override_settings from django.urls import reverse -from openedx_authz.constants.roles import COURSE_DATA_RESEARCHER, COURSE_STAFF +from openedx_authz.constants.roles import COURSE_DATA_RESEARCHER, COURSE_EDITOR, COURSE_STAFF from rest_framework import status from rest_framework.test import APIClient, APITestCase @@ -19,7 +19,7 @@ from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.student.tests.factories import StaffFactory, UserFactory -from openedx.core.djangoapps.authz.tests.mixins import CourseAuthzTestMixin +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin, CourseAuthzTestMixin from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory @@ -247,7 +247,7 @@ def test_create_update_reference_success(self, mock_block, mock_user_task_status mock_auth.assert_called_once() - @patch('cms.djangoapps.contentstore.api.views.utils.has_course_author_access') + @patch('openedx.core.djangoapps.authz.decorators.user_has_course_permission') @patch('xmodule.library_content_block.LegacyLibraryContentBlock.is_ready_to_migrate_to_v2') def test_list_ready_to_update_reference_success(self, mock_block, mock_auth): """ @@ -353,3 +353,84 @@ def test_non_staff_user_cannot_access(self): resp = non_staff_client.get(self.get_url(self.course_key)) self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009 + + +class TestMigrationViewSetCreateAuthz( + CourseAuthoringAuthzTestMixin, + SharedModuleStoreTestCase, + APITestCase, +): + """ + AuthZ tests for: + /api/courses/v1/migrate_legacy_content_blocks// + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.course = CourseFactory.create( + display_name='test course', + run="Testing_course", + ) + cls.course_key = cls.course.id + + cls.initialize_course(cls.course) + + @classmethod + def initialize_course(cls, course): + """Sets up test course structure.""" + section = BlockFactory.create( + parent_location=course.location, + category="chapter", + ) + subsection = BlockFactory.create( + parent_location=section.location, + category="sequential", + ) + unit = BlockFactory.create( + parent_location=subsection.location, + category="vertical", + ) + BlockFactory.create( + parent_location=unit.location, + category="library_content", + ) + + def url(self): + return f"/api/courses/v1/migrate_legacy_content_blocks/{self.course_key}/" + + # ---- GET (list) ---- + + def test_authorized_user_can_list_blocks(self): + """Authorized user can list migratable blocks.""" + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id, + ) + + response = self.authorized_client.get(self.url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsInstance(response.json(), list) + + def test_unauthorized_user_cannot_list_blocks(self): + """Unauthorized user should receive 403.""" + response = self.unauthorized_client.get(self.url()) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # ---- elevated users ---- + + def test_staff_user_can_access_without_authz_role(self): + """Staff user bypasses AuthZ.""" + response = self.staff_client.get(self.url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_superuser_can_access_without_authz_role(self): + """Superuser bypasses AuthZ.""" + response = self.super_client.get(self.url()) + + self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_201_CREATED]) diff --git a/cms/djangoapps/contentstore/api/views/course_validation.py b/cms/djangoapps/contentstore/api/views/course_validation.py index 56ff5fb0ed21..43246c10ff99 100644 --- a/cms/djangoapps/contentstore/api/views/course_validation.py +++ b/cms/djangoapps/contentstore/api/views/course_validation.py @@ -364,7 +364,7 @@ class CourseLegacyLibraryContentSerializer(serializers.Serializer): usage_key = serializers.CharField() -class CourseLegacyLibraryContentMigratorView(StatusViewSet): +class CourseLegacyLibraryContentMigratorView(DeveloperErrorViewMixin, StatusViewSet): """ This endpoint is used for migrating legacy library content to the new item bank block library v2. """ @@ -384,7 +384,7 @@ class CourseLegacyLibraryContentMigratorView(StatusViewSet): 401: "The requester is not authenticated.", }, ) - @course_author_access_required + @authz_permission_required(COURSES_VIEW_COURSE.identifier, LegacyAuthoringPermission.WRITE) def list(self, _, course_key): # pylint: disable=arguments-differ """ Returns all legacy library content blocks ready to be migrated to new item bank block. diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py index 1dbfc52548ba..94908eedda07 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py @@ -10,6 +10,8 @@ from rest_framework.response import Response from rest_framework.views import APIView +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE + from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin from cms.djangoapps.contentstore.rest_api.v1.serializers import ContainerChildrenSerializer, CourseIndexSerializer @@ -22,7 +24,7 @@ ) from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_xblock from cms.lib.xblock.upstream_sync import UpstreamLink -from common.djangoapps.student.auth import has_studio_read_access +from openedx.core.djangoapps.authz.decorators import LegacyAuthoringPermission, user_has_course_permission from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order @@ -101,7 +103,12 @@ def get(self, request: Request, course_id: str): """ course_key = CourseKey.from_string(course_id) - if not has_studio_read_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_COURSE.identifier, + course_key, + LegacyAuthoringPermission.READ + ): self.permission_denied(request) course_index_context = get_course_index_context(request, course_key) course_index_context.update({ diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py index 42b0d22995ac..bf6ddfe9c3a5 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py @@ -5,6 +5,7 @@ from django.test import RequestFactory from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag +from openedx_authz.constants.roles import COURSE_EDITOR from rest_framework import status from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES @@ -13,6 +14,7 @@ from cms.djangoapps.contentstore.utils import get_lms_link_for_item, get_pages_and_resources_url from cms.djangoapps.contentstore.views.course import _course_outline_json from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES from xmodule.modulestore.tests.factories import BlockFactory, check_mongo_calls @@ -162,3 +164,63 @@ def test_number_of_calls_to_db(self): with self.assertNumQueries(34, table_ignorelist=WAFFLE_TABLES): with check_mongo_calls(3): self.client.get(self.url) + + +class CourseIndexAuthzViewTest(CourseAuthoringAuthzTestMixin, CourseTestCase): + """ + Tests for CourseIndexView using AuthZ permissions. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:course_index", + kwargs={"course_id": self.course.id}, + ) + + def test_authorized_user_can_access_course_index(self): + """Authorized user with COURSE_EDITOR role can access course index.""" + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + response = self.authorized_client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("course_structure", response.data) + + def test_unauthorized_user_cannot_access_course_index(self): + """Unauthorized user should receive 403.""" + response = self.unauthorized_client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_user_without_role_then_added_can_access(self): + """Validate dynamic role assignment works as expected.""" + response = self.unauthorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + self.add_user_to_role_in_course( + self.unauthorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + response = self.unauthorized_client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_staff_user_can_access_without_authz_role(self): + """Django staff user should access without AuthZ role.""" + response = self.staff_client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("course_structure", response.data) + + def test_superuser_can_access_without_authz_role(self): + """Superuser should access without AuthZ role.""" + response = self.super_client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("course_structure", response.data) diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py index 6305fc0a6df2..8a669a782ed6 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -90,6 +90,7 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.fields import BooleanField from rest_framework.request import Request @@ -115,6 +116,7 @@ from cms.lib.xblock.upstream_sync_block import fetch_customizable_fields_from_block from cms.lib.xblock.upstream_sync_container import fetch_customizable_fields_from_container from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access +from openedx.core.djangoapps.authz.decorators import LegacyAuthoringPermission, user_has_course_permission from openedx.core.djangoapps.content_libraries import api as lib_api from openedx.core.djangoapps.video_config.transcripts_utils import clear_transcripts from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes @@ -302,7 +304,12 @@ def get(self, request: _AuthenticatedRequest, course_key_string: str): except InvalidKeyError as exc: raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc - if not has_studio_read_access(request.user, course_key): + if not user_has_course_permission( + request.user, + COURSES_VIEW_COURSE.identifier, + course_key, + LegacyAuthoringPermission.READ + ): raise PermissionDenied # Gets all links of the Course, using the diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index 96ad82451f1f..414c152a41b1 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -8,11 +8,13 @@ import ddt from django.conf import settings from django.urls import reverse +from rest_framework import status from freezegun import freeze_time from opaque_keys.edx.keys import ContainerKey, UsageKey from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_content import models_api as content_models from organizations.models import Organization +from openedx_authz.constants.roles import COURSE_EDITOR from cms.djangoapps.contentstore.helpers import StaticFileNotices from cms.djangoapps.contentstore.tests.utils import CourseTestCase @@ -23,6 +25,7 @@ from common.djangoapps.student.roles import CourseStaffRole from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.content_libraries import api as lib_api +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ImmediateOnCommitMixin, SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory @@ -1517,6 +1520,68 @@ def test_200_summary(self): self.assertListEqual(data, expected) # noqa: PT009 +class GetDownstreamSummaryAuthzViewTest( + CourseAuthoringAuthzTestMixin, + _BaseDownstreamViewTestMixin, + ImmediateOnCommitMixin, + SharedModuleStoreTestCase, +): + """ + AuthZ tests for: + GET /api/contentstore/v2/downstreams//summary + """ + + def call_api(self, client, course_id): # pylint: disable=arguments-differ + return client.get(f"/api/contentstore/v2/downstreams/{course_id}/summary") + + def test_authorized_user_can_access_summary(self): + """Authorized user with COURSE_EDITOR role can access summary.""" + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + response = self.call_api(self.authorized_client, str(self.course.id)) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsInstance(response.json(), list) + + def test_unauthorized_user_cannot_access_summary(self): + """Unauthorized user should receive 403.""" + response = self.call_api(self.unauthorized_client, str(self.course.id)) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_user_without_role_then_added_can_access(self): + """Validate dynamic role assignment works.""" + response = self.call_api(self.unauthorized_client, str(self.course.id)) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + self.add_user_to_role_in_course( + self.unauthorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + response = self.call_api(self.unauthorized_client, str(self.course.id)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_staff_user_can_access_without_authz_role(self): + """Staff user should access without explicit AuthZ role.""" + response = self.call_api(self.staff_client, str(self.course.id)) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsInstance(response.json(), list) + + def test_superuser_can_access_without_authz_role(self): + """Superuser should access without explicit AuthZ role.""" + response = self.call_api(self.super_client, str(self.course.id)) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsInstance(response.json(), list) + + class GetDownstreamDeletedUpstream( _BaseDownstreamViewTestMixin, ImmediateOnCommitMixin, From 9e6c609b6660820cac18dac00c5033357abda362 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Mon, 6 Apr 2026 19:42:53 -0600 Subject: [PATCH 02/13] fixup! feat: add AuthZ permissions to course creation and outline --- lms/djangoapps/course_api/tests/test_api.py | 138 ++++++++++++++++++++ lms/djangoapps/courseware/access.py | 23 +++- lms/djangoapps/courseware/courses.py | 5 + 3 files changed, 159 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/course_api/tests/test_api.py b/lms/djangoapps/course_api/tests/test_api.py index 8f134e829985..1b79923b1481 100644 --- a/lms/djangoapps/course_api/tests/test_api.py +++ b/lms/djangoapps/course_api/tests/test_api.py @@ -14,7 +14,10 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.test import APIRequestFactory +from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException +from openedx_authz.constants.roles import COURSE_EDITOR +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.django_utils import ( # lint-amnesty, pylint: disable=wrong-import-order @@ -108,6 +111,141 @@ def test_hidden_course_for_staff_as_honor(self): self._make_api_call(self.staff_user, self.honor_user, self.hidden_course.id) +class CourseDetailSeeAboutPermTestMixin(CourseApiTestMixin): + """ + Common functionality for course_detail tests + """ + ENABLED_SIGNALS = ['course_published'] + + def _make_api_call(self, requesting_user, target_user, course_key): + """ + Call the `course_detail` api endpoint to get information on the course + identified by `course_key`. + """ + mock_path = 'lms.djangoapps.course_api.api.get_permission_for_course_about' + with mock.patch(mock_path) as mock_get_permission: + mock_get_permission.return_value = "see_about_page" + request = Request(self.request_factory.get('/')) + request.user = requesting_user + with check_mongo_calls(0): + return course_detail(request, target_user.username, course_key) + + +class TestGetCourseDetailAuthz( + CourseAuthoringAuthzTestMixin, + CourseDetailSeeAboutPermTestMixin, + SharedModuleStoreTestCase, +): + """ + AuthZ-based tests for course_detail API function. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.course = cls.create_course() + cls.hidden_course = cls.create_course( + course='hidden', + visible_to_staff_only=True + ) + + cls.authorized_user = cls.create_user('authorized', is_staff=False) + cls.unauthorized_user = cls.create_user('unauthorized', is_staff=False) + cls.staff_user = cls.create_user('staff', is_staff=True) + + def test_get_existing_course_as_authorized_user(self): + """User with COURSE_EDITOR role can access course.""" + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + + course = self._make_api_call( + self.authorized_user, + self.authorized_user, + self.course.id + ) + + self.verify_course(course) + + def test_get_existing_course_as_unauthorized_user(self): + """User without role should be denied.""" + with pytest.raises(CoursewareAccessException): + self._make_api_call( + self.unauthorized_user, + self.unauthorized_user, + self.course.id + ) + + def test_get_nonexistent_course(self): + """Nonexistent course should raise 404.""" + course_key = CourseKey.from_string('edX/toy/nope') + + with pytest.raises(Http404): + self._make_api_call( + self.authorized_user, + self.authorized_user, + course_key + ) + + def test_hidden_course_for_staff(self): + """Staff can access hidden course.""" + course = self._make_api_call( + self.staff_user, + self.staff_user, + self.hidden_course.id + ) + + self.verify_course( + course, + course_id='course-v1:edX+hidden+2012_Fall' + ) + + def test_hidden_course_for_staff_as_unauthorized_user(self): + """ + Staff requesting data for another user without permissions + should not bypass visibility rules. + """ + with pytest.raises(Http404): + self._make_api_call( + self.staff_user, + self.unauthorized_user, + self.hidden_course.id + ) + + def test_user_gains_access_after_role_assignment(self): + """User initially denied, then allowed after role assignment.""" + with pytest.raises(CoursewareAccessException): + self._make_api_call( + self.unauthorized_user, + self.unauthorized_user, + self.course.id + ) + self.add_user_to_role_in_course( + self.unauthorized_user, + COURSE_EDITOR.external_key, + self.course.id + ) + course = self._make_api_call( + self.unauthorized_user, + self.unauthorized_user, + self.course.id + ) + self.verify_course(course) + + def test_staff_access_without_authz_role(self): + """Staff bypasses AuthZ roles.""" + course = self._make_api_call( + self.staff_user, + self.staff_user, + self.course.id + ) + + self.verify_course(course) + + class CourseListTestMixin(CourseApiTestMixin): """ Common behavior for list_courses tests diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index c49a52688020..ae831dc7f82b 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -18,6 +18,8 @@ from django.contrib.auth.models import AnonymousUser from edx_django_utils.monitoring import function_trace from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE +from openedx.core.djangoapps.authz.decorators import user_has_course_permission from xblock.core import XBlock from common.djangoapps.student import auth @@ -63,6 +65,7 @@ from lms.djangoapps.courseware.toggles import course_is_invitation_only from lms.djangoapps.mobile_api.models import IgnoreMobileAvailableFlagConfig from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core import toggles as core_toggles from openedx.features.course_duration_limits.access import check_course_expired from xmodule.course_block import ( # lint-amnesty, pylint: disable=wrong-import-order CATALOG_VISIBILITY_ABOUT, @@ -431,13 +434,7 @@ def can_see_in_catalog(): # can provide a meaningful error message instead of a generic 404. return catalog_response - @function_trace('can_see_about_page') - def can_see_about_page(): - """ - Implements the "can see course about page" logic if a course about page should be visible - In this case we use the catalog_visibility property on the course block - but also allow course staff to see this. - """ + def legacy_can_see_about_page(): both_response = _has_catalog_visibility(courselike, CATALOG_VISIBILITY_CATALOG_AND_ABOUT) if both_response: return ACCESS_GRANTED @@ -450,6 +447,18 @@ def can_see_about_page(): # can provide a meaningful error message instead of a generic 404. return both_response + @function_trace('can_see_about_page') + def can_see_about_page(): + """ + Implements the "can see course about page" logic if a course about page should be visible + In this case we use the catalog_visibility property on the course block + but also allow course staff to see this. + """ + if user and not user.is_anonymous and core_toggles.enable_authz_course_authoring(courselike.id): + is_authz_allowed = user_has_course_permission(user, COURSES_VIEW_COURSE.identifier, courselike.id) + return ACCESS_GRANTED if is_authz_allowed else ACCESS_DENIED + return legacy_can_see_about_page() + checkers = { 'load': can_load, 'load_mobile': lambda: can_load() and _can_load_course_on_mobile(user, courselike), diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index c4745dd1350c..b57759f250a8 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -69,6 +69,7 @@ from openedx.core.lib.api.view_utils import LazySequence from openedx.core.lib.cache_utils import request_cached from openedx.core.lib.courses import get_course_by_id +from openedx.core import toggles as core_toggles from openedx.features.course_duration_limits.access import AuditExpiredError from openedx.features.course_experience import RELATIVE_DATES_FLAG from openedx.features.course_experience.utils import is_block_structure_complete_for_assignments @@ -214,6 +215,10 @@ def _check_nonstaff_access(): return access_response non_staff_access_response = _check_nonstaff_access() + if core_toggles.enable_authz_course_authoring(course.id): + # If AuthZ is enabled for this course, it checks already + # permissions for staff. + return non_staff_access_response # User has course access OR access error is a priority error if non_staff_access_response or is_priority_access_error(non_staff_access_response): From a43e28e8f0092adfc5bf2d2e71b12c88c93ef526 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Mon, 6 Apr 2026 21:05:41 -0600 Subject: [PATCH 03/13] fixup! feat: add AuthZ permissions to course creation and outline --- .../contentstore/rest_api/v1/views/course_index.py | 3 +-- .../rest_api/v2/views/tests/test_downstreams.py | 6 +++--- lms/djangoapps/course_api/tests/test_api.py | 4 ++-- lms/djangoapps/courseware/access.py | 4 ++-- lms/djangoapps/courseware/courses.py | 2 +- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py index 94908eedda07..3ee7ffbcae7f 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py @@ -5,13 +5,12 @@ import edx_api_doc_tools as apidocs from django.conf import settings from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.permissions import COURSES_VIEW_COURSE from rest_framework.fields import BooleanField from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from openedx_authz.constants.permissions import COURSES_VIEW_COURSE - from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin from cms.djangoapps.contentstore.rest_api.v1.serializers import ContainerChildrenSerializer, CourseIndexSerializer diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index 414c152a41b1..293141f9569e 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -8,13 +8,13 @@ import ddt from django.conf import settings from django.urls import reverse -from rest_framework import status from freezegun import freeze_time from opaque_keys.edx.keys import ContainerKey, UsageKey from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from openedx_authz.constants.roles import COURSE_EDITOR from openedx_content import models_api as content_models from organizations.models import Organization -from openedx_authz.constants.roles import COURSE_EDITOR +from rest_framework import status from cms.djangoapps.contentstore.helpers import StaticFileNotices from cms.djangoapps.contentstore.tests.utils import CourseTestCase @@ -24,8 +24,8 @@ from common.djangoapps.student.auth import add_users from common.djangoapps.student.roles import CourseStaffRole from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.djangoapps.content_libraries import api as lib_api from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin +from openedx.core.djangoapps.content_libraries import api as lib_api from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ImmediateOnCommitMixin, SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory diff --git a/lms/djangoapps/course_api/tests/test_api.py b/lms/djangoapps/course_api/tests/test_api.py index 1b79923b1481..7c8dcf76a1cd 100644 --- a/lms/djangoapps/course_api/tests/test_api.py +++ b/lms/djangoapps/course_api/tests/test_api.py @@ -11,12 +11,12 @@ from django.http import Http404 from django.test import TestCase, override_settings from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.roles import COURSE_EDITOR from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.test import APIRequestFactory -from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException -from openedx_authz.constants.roles import COURSE_EDITOR +from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index ae831dc7f82b..9732821184dd 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -19,7 +19,6 @@ from edx_django_utils.monitoring import function_trace from opaque_keys.edx.keys import CourseKey, UsageKey from openedx_authz.constants.permissions import COURSES_VIEW_COURSE -from openedx.core.djangoapps.authz.decorators import user_has_course_permission from xblock.core import XBlock from common.djangoapps.student import auth @@ -64,8 +63,9 @@ from lms.djangoapps.courseware.masquerade import get_masquerade_role, is_masquerading_as_student from lms.djangoapps.courseware.toggles import course_is_invitation_only from lms.djangoapps.mobile_api.models import IgnoreMobileAvailableFlagConfig -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core import toggles as core_toggles +from openedx.core.djangoapps.authz.decorators import user_has_course_permission +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.course_duration_limits.access import check_course_expired from xmodule.course_block import ( # lint-amnesty, pylint: disable=wrong-import-order CATALOG_VISIBILITY_ABOUT, diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index b57759f250a8..86f24ea469eb 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -62,6 +62,7 @@ from lms.djangoapps.courseware.utils import is_empty_html from lms.djangoapps.grades.api import CourseGradeFactory from lms.djangoapps.survey.utils import SurveyRequiredAccessError, check_survey_required_and_unanswered +from openedx.core import toggles as core_toggles from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.enrollments.api import get_course_enrollment_details @@ -69,7 +70,6 @@ from openedx.core.lib.api.view_utils import LazySequence from openedx.core.lib.cache_utils import request_cached from openedx.core.lib.courses import get_course_by_id -from openedx.core import toggles as core_toggles from openedx.features.course_duration_limits.access import AuditExpiredError from openedx.features.course_experience import RELATIVE_DATES_FLAG from openedx.features.course_experience.utils import is_block_structure_complete_for_assignments From 4e97ee903735c80ccd15486e12b1792704d07198 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Tue, 7 Apr 2026 17:30:36 -0600 Subject: [PATCH 04/13] fixup! feat: add AuthZ permissions to course creation and outline --- .../tests/test_course_create_rerun.py | 215 ++++++++++++++++++ common/djangoapps/student/auth.py | 40 +++- 2 files changed, 254 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index 21c1ecc19f21..e58219f73297 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -14,6 +14,7 @@ from django.test.client import RequestFactory from django.urls import reverse from opaque_keys.edx.keys import CourseKey +from openedx_authz.constants.roles import COURSE_EDITOR from organizations.api import add_organization, get_course_organizations, get_organization_by_short_name from organizations.exceptions import InvalidOrganizationException from organizations.models import Organization @@ -25,6 +26,7 @@ from common.djangoapps.student.auth import update_org_role from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, OrgContentCreatorRole from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from xmodule.course_block import CourseFields from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -375,3 +377,216 @@ def test_default_enable_flexible_peer_openassessments_on_rerun( source_course.force_on_flexible_peer_openassessments, dest_course.force_on_flexible_peer_openassessments ) + + +@ddt.ddt +class TestCourseHandlerAuthz( + CourseAuthoringAuthzTestMixin, + ModuleStoreTestCase, +): + """ + AuthZ integration tests for course_handler using real RBAC (no mocks). + """ + + def setUp(self): + super().setUp() + + self.url = reverse("course_handler") + + # Create a base course to extract org + self.course = CourseFactory.create() + self.course_key = self.course.id + self.org = self.course_key.org + + # If your policy expects this format, keep it + self.org_key = f"course-v1:{self.org}+*" + + self.authorized_client = AjaxEnabledTestClient() + self.authorized_client.login( + username=self.authorized_user.username, + password=self.password, + ) + + self.unauthorized_client = AjaxEnabledTestClient() + self.unauthorized_client.login( + username=self.unauthorized_user.username, + password=self.password, + ) + self.authorized_staff_client = AjaxEnabledTestClient() + self.authorized_staff_client.login( + username=self.staff_user.username, + password=self.password, + ) + + # ------------------------------------------------------------ + # CREATE COURSE -- Non-staff users and existing Organization + # ------------------------------------------------------------ + + @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) + def test_create_course_authorized(self): + """ + User with proper AuthZ role can create course. + """ + + # Assign org-scoped role + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.org_key, + ) + + response = self.authorized_client.ajax_post(self.url, { + "org": self.org, + "number": "CS101", + "display_name": "Authz Course", + "run": "2026_T1", + }) + + self.assertEqual(response.status_code, 200) + + data = parse_json(response) + self.assertIn("course_key", data) + + @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) + def test_create_course_unauthorized(self): + """ + User without role cannot create course. + """ + + response = self.unauthorized_client.ajax_post(self.url, { + "org": self.org, + "number": "CS101", + "display_name": "Authz Course", + "run": "2026_T1", + }) + + self.assertEqual(response.status_code, 403) + + @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) + def test_create_course_unauthorized_with_role(self): + """ + User without role cannot create course. + """ + + self.add_user_to_role_in_course( + self.unauthorized_user, + COURSE_EDITOR.external_key, + "course-v1:someotherorg+*", + ) + + response = self.unauthorized_client.ajax_post(self.url, { + "org": self.org, + "number": "CS101", + "display_name": "Authz Course", + "run": "2026_T1", + }) + + self.assertEqual(response.status_code, 403) + + # ------------------------------------------------------------ + # CREATE COURSE -- Non-staff users and non-existing Organization + # ------------------------------------------------------------ + + @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) + def test_create_course_with_unknown_organization_success(self): + """ + Course creation with unknown organization should succeed and create + the organization if user has the role to create course. + """ + new_org = "orgX" + new_org_key = f"course-v1:{new_org}+*" + + # Assign org-scoped role for the new org even though the org doesn't exist yet, + # the role assignment should work with the org key format + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + new_org_key, + ) + + # Ensure the org doesn't exist in the system before course creation attempt + with self.assertRaises(InvalidOrganizationException): + get_organization_by_short_name(new_org) + + response = self.authorized_client.ajax_post(self.url, { + 'org': new_org, + 'number': 'CS101', + 'display_name': 'Course with web certs enabled', + 'run': '2015_T2' + }) + + self.assertEqual(response.status_code, 200) + + @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) + def test_create_course_with_unknown_organization_failure(self): + """ + Course creation with unknown organization should fail if + user doesn't have the role to create course. + """ + new_org = "orgX" + new_org_key = "course-v1:otherOrg+*" + + # Assign org-scoped role for a different org + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + new_org_key, + ) + + # Ensure the org doesn't exist in the system before course creation attempt + with self.assertRaises(InvalidOrganizationException): + get_organization_by_short_name(new_org) + + response = self.authorized_client.ajax_post(self.url, { + 'org': new_org, + 'number': 'CS101', + 'display_name': 'Course with web certs enabled', + 'run': '2015_T2' + }) + + self.assertEqual(response.status_code, 403) + + # ------------------------------------------------------------ + # CREATE COURSE -- Staff users + # ------------------------------------------------------------ + + @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) + def test_create_course_staff(self): + """ + Staff user can create course. + """ + response = self.authorized_staff_client.ajax_post(self.url, { + "org": self.org, + "number": "CS101", + "display_name": "Authz Course", + "run": "2026_T1", + }) + + # At the moment of implement new permissions for course creation, + # the staff user has no role and thus is unauthorized. + self.assertEqual(response.status_code, 403) + + # ------------------------------------------------------------ + # FEATURE FLAG + # ------------------------------------------------------------ + + @override_settings(FEATURES={"DISABLE_COURSE_CREATION": True}) + def test_create_course_disabled_by_flag(self): + """ + Even authorized users cannot create course if feature flag is off. + """ + + self.add_user_to_role_in_course( + self.authorized_user, + COURSE_EDITOR.external_key, + self.org_key, + ) + + response = self.authorized_client.ajax_post(self.url, { + "org": self.org, + "number": "CS101", + "display_name": "Authz Course", + "run": "2026_T1", + }) + + self.assertEqual(response.status_code, 403) diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py index 71a2cfeb5990..dcbd14b22d5c 100644 --- a/common/djangoapps/student/auth.py +++ b/common/djangoapps/student/auth.py @@ -11,7 +11,7 @@ from django.core.exceptions import PermissionDenied from opaque_keys.edx.locator import LibraryLocator from openedx_authz import api as authz_api -from openedx_authz.constants.permissions import COURSES_MANAGE_ADVANCED_SETTINGS +from openedx_authz.constants.permissions import COURSES_EDIT_COURSE_CONTENT, COURSES_MANAGE_ADVANCED_SETTINGS from common.djangoapps.student.roles import ( CourseBetaTesterRole, @@ -224,6 +224,44 @@ def check_course_advanced_settings_access(user, course_key, access_type='read'): def is_content_creator(user, org): + """ + Determine whether a user is allowed to create course content for a given organization. + + This function abstracts the permission check for course creation. Depending on the + state of the AuthZ feature flag, it delegates the evaluation to either the AuthZ-based + RBAC system or the legacy role-based permission system. + + Args: + user (User): The user whose permissions are being evaluated. + org (str): The organization identifier used as the permission scope. + + Returns: + bool: True if the user has permission to create course content in the given + organization, False otherwise. + + Notes: + - When AuthZ is enabled, this checks permissions via RBAC policies. + - When AuthZ is disabled, this falls back to legacy Django role checks. + - Course creation may still be blocked by global feature flags (e.g., + DISABLE_COURSE_CREATION), which are enforced downstream. + """ + if core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(): + return _has_content_creator_access(user, org) + return _has_legacy_content_creator_access(user, org) + + +def _has_content_creator_access(user, org): + if settings.FEATURES.get('DISABLE_COURSE_CREATION', False): + return False + org_scope_key = f"course-v1:{org}+*" + return authz_api.is_user_allowed( + user.username, + COURSES_EDIT_COURSE_CONTENT.identifier, + org_scope_key + ) + + +def _has_legacy_content_creator_access(user, org): """ Check if the user has the role to create content. From 2756f98012581e002e9277db22da7ee54d1199d4 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Wed, 8 Apr 2026 11:15:30 -0600 Subject: [PATCH 05/13] fixup! feat: add AuthZ permissions to course creation and outline --- lms/djangoapps/course_api/tests/test_api.py | 4 ---- lms/djangoapps/courseware/access.py | 2 +- lms/djangoapps/courseware/courses.py | 6 +++--- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lms/djangoapps/course_api/tests/test_api.py b/lms/djangoapps/course_api/tests/test_api.py index 7c8dcf76a1cd..676c13e8b183 100644 --- a/lms/djangoapps/course_api/tests/test_api.py +++ b/lms/djangoapps/course_api/tests/test_api.py @@ -150,10 +150,6 @@ def setUpClass(cls): visible_to_staff_only=True ) - cls.authorized_user = cls.create_user('authorized', is_staff=False) - cls.unauthorized_user = cls.create_user('unauthorized', is_staff=False) - cls.staff_user = cls.create_user('staff', is_staff=True) - def test_get_existing_course_as_authorized_user(self): """User with COURSE_EDITOR role can access course.""" self.add_user_to_role_in_course( diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 9732821184dd..a99949c5fdbb 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -456,7 +456,7 @@ def can_see_about_page(): """ if user and not user.is_anonymous and core_toggles.enable_authz_course_authoring(courselike.id): is_authz_allowed = user_has_course_permission(user, COURSES_VIEW_COURSE.identifier, courselike.id) - return ACCESS_GRANTED if is_authz_allowed else ACCESS_DENIED + return ACCESS_GRANTED if is_authz_allowed else CatalogVisibilityError() return legacy_can_see_about_page() checkers = { diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 86f24ea469eb..606db89fbd34 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -216,9 +216,9 @@ def _check_nonstaff_access(): non_staff_access_response = _check_nonstaff_access() if core_toggles.enable_authz_course_authoring(course.id): - # If AuthZ is enabled for this course, it checks already - # permissions for staff. - return non_staff_access_response + # If AuthZ is enabled for this course, it checks already + # permissions for staff. + return non_staff_access_response # User has course access OR access error is a priority error if non_staff_access_response or is_priority_access_error(non_staff_access_response): From ceeaed143b1cf66b79a508d944b60ede3e0e33e4 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Wed, 8 Apr 2026 11:39:25 -0600 Subject: [PATCH 06/13] fixup! feat: add AuthZ permissions to course creation and outline --- lms/djangoapps/course_api/tests/test_api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/course_api/tests/test_api.py b/lms/djangoapps/course_api/tests/test_api.py index 676c13e8b183..b84b920c4a47 100644 --- a/lms/djangoapps/course_api/tests/test_api.py +++ b/lms/djangoapps/course_api/tests/test_api.py @@ -16,7 +16,7 @@ from rest_framework.request import Request from rest_framework.test import APIRequestFactory -from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException +from lms.djangoapps.courseware.exceptions import CourseAccessRedirect from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order @@ -168,7 +168,7 @@ def test_get_existing_course_as_authorized_user(self): def test_get_existing_course_as_unauthorized_user(self): """User without role should be denied.""" - with pytest.raises(CoursewareAccessException): + with pytest.raises(CourseAccessRedirect): self._make_api_call( self.unauthorized_user, self.unauthorized_user, @@ -204,7 +204,7 @@ def test_hidden_course_for_staff_as_unauthorized_user(self): Staff requesting data for another user without permissions should not bypass visibility rules. """ - with pytest.raises(Http404): + with pytest.raises(CourseAccessRedirect): self._make_api_call( self.staff_user, self.unauthorized_user, @@ -213,7 +213,7 @@ def test_hidden_course_for_staff_as_unauthorized_user(self): def test_user_gains_access_after_role_assignment(self): """User initially denied, then allowed after role assignment.""" - with pytest.raises(CoursewareAccessException): + with pytest.raises(CourseAccessRedirect): self._make_api_call( self.unauthorized_user, self.unauthorized_user, From 108388fe07665f0bec5a4c83bce00f3028414e97 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Wed, 8 Apr 2026 17:27:41 -0600 Subject: [PATCH 07/13] fixup! feat: add AuthZ permissions to course creation and outline --- .../contentstore/tests/test_course_create_rerun.py | 7 +++---- common/djangoapps/student/auth.py | 7 +++++++ lms/djangoapps/courseware/courses.py | 5 ----- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index e58219f73297..a20ed4dac5a9 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -8,6 +8,7 @@ from unittest import mock import ddt +import pytest from django.contrib.admin.sites import AdminSite from django.http import HttpRequest from django.test import override_settings @@ -549,7 +550,7 @@ def test_create_course_with_unknown_organization_failure(self): # ------------------------------------------------------------ # CREATE COURSE -- Staff users # ------------------------------------------------------------ - + @pytest.mark.skip(reason="Temporarily disabled due to bug in API - see openedx-authz#244") @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) def test_create_course_staff(self): """ @@ -562,9 +563,7 @@ def test_create_course_staff(self): "run": "2026_T1", }) - # At the moment of implement new permissions for course creation, - # the staff user has no role and thus is unauthorized. - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 200) # ------------------------------------------------------------ # FEATURE FLAG diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py index dcbd14b22d5c..2ee03d5e89ef 100644 --- a/common/djangoapps/student/auth.py +++ b/common/djangoapps/student/auth.py @@ -251,9 +251,16 @@ def is_content_creator(user, org): def _has_content_creator_access(user, org): + """ + Check if the user has content creator access based on AuthZ permissions. + """ if settings.FEATURES.get('DISABLE_COURSE_CREATION', False): return False org_scope_key = f"course-v1:{org}+*" + + # TODO: We should be checking for the COURSES_CREATE_COURSE permission here, + # but since we don't have that linked to a role yet, we'll check for edit content + # permission for now, which is a superset of create course content permission. return authz_api.is_user_allowed( user.username, COURSES_EDIT_COURSE_CONTENT.identifier, diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 606db89fbd34..c4745dd1350c 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -62,7 +62,6 @@ from lms.djangoapps.courseware.utils import is_empty_html from lms.djangoapps.grades.api import CourseGradeFactory from lms.djangoapps.survey.utils import SurveyRequiredAccessError, check_survey_required_and_unanswered -from openedx.core import toggles as core_toggles from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.enrollments.api import get_course_enrollment_details @@ -215,10 +214,6 @@ def _check_nonstaff_access(): return access_response non_staff_access_response = _check_nonstaff_access() - if core_toggles.enable_authz_course_authoring(course.id): - # If AuthZ is enabled for this course, it checks already - # permissions for staff. - return non_staff_access_response # User has course access OR access error is a priority error if non_staff_access_response or is_priority_access_error(non_staff_access_response): From 1764d6a6a9fb3038260474ceaff6aea46944e6c4 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Thu, 9 Apr 2026 16:32:36 -0600 Subject: [PATCH 08/13] fixup! feat: add AuthZ permissions to course creation and outline --- .../tests/test_course_create_rerun.py | 95 +------------------ common/djangoapps/student/auth.py | 7 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 6 files changed, 8 insertions(+), 102 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index a20ed4dac5a9..03062adb1b4b 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -8,7 +8,6 @@ from unittest import mock import ddt -import pytest from django.contrib.admin.sites import AdminSite from django.http import HttpRequest from django.test import override_settings @@ -422,32 +421,6 @@ def setUp(self): # ------------------------------------------------------------ # CREATE COURSE -- Non-staff users and existing Organization # ------------------------------------------------------------ - - @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) - def test_create_course_authorized(self): - """ - User with proper AuthZ role can create course. - """ - - # Assign org-scoped role - self.add_user_to_role_in_course( - self.authorized_user, - COURSE_EDITOR.external_key, - self.org_key, - ) - - response = self.authorized_client.ajax_post(self.url, { - "org": self.org, - "number": "CS101", - "display_name": "Authz Course", - "run": "2026_T1", - }) - - self.assertEqual(response.status_code, 200) - - data = parse_json(response) - self.assertIn("course_key", data) - @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) def test_create_course_unauthorized(self): """ @@ -484,74 +457,11 @@ def test_create_course_unauthorized_with_role(self): self.assertEqual(response.status_code, 403) - # ------------------------------------------------------------ - # CREATE COURSE -- Non-staff users and non-existing Organization - # ------------------------------------------------------------ - - @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) - def test_create_course_with_unknown_organization_success(self): - """ - Course creation with unknown organization should succeed and create - the organization if user has the role to create course. - """ - new_org = "orgX" - new_org_key = f"course-v1:{new_org}+*" - - # Assign org-scoped role for the new org even though the org doesn't exist yet, - # the role assignment should work with the org key format - self.add_user_to_role_in_course( - self.authorized_user, - COURSE_EDITOR.external_key, - new_org_key, - ) - - # Ensure the org doesn't exist in the system before course creation attempt - with self.assertRaises(InvalidOrganizationException): - get_organization_by_short_name(new_org) - - response = self.authorized_client.ajax_post(self.url, { - 'org': new_org, - 'number': 'CS101', - 'display_name': 'Course with web certs enabled', - 'run': '2015_T2' - }) - - self.assertEqual(response.status_code, 200) - - @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) - def test_create_course_with_unknown_organization_failure(self): - """ - Course creation with unknown organization should fail if - user doesn't have the role to create course. - """ - new_org = "orgX" - new_org_key = "course-v1:otherOrg+*" - - # Assign org-scoped role for a different org - self.add_user_to_role_in_course( - self.authorized_user, - COURSE_EDITOR.external_key, - new_org_key, - ) - - # Ensure the org doesn't exist in the system before course creation attempt - with self.assertRaises(InvalidOrganizationException): - get_organization_by_short_name(new_org) - - response = self.authorized_client.ajax_post(self.url, { - 'org': new_org, - 'number': 'CS101', - 'display_name': 'Course with web certs enabled', - 'run': '2015_T2' - }) - - self.assertEqual(response.status_code, 403) - # ------------------------------------------------------------ # CREATE COURSE -- Staff users + # Only staff users can create course, and they can do it + # without an org role. # ------------------------------------------------------------ - @pytest.mark.skip(reason="Temporarily disabled due to bug in API - see openedx-authz#244") - @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) def test_create_course_staff(self): """ Staff user can create course. @@ -568,7 +478,6 @@ def test_create_course_staff(self): # ------------------------------------------------------------ # FEATURE FLAG # ------------------------------------------------------------ - @override_settings(FEATURES={"DISABLE_COURSE_CREATION": True}) def test_create_course_disabled_by_flag(self): """ diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py index 2ee03d5e89ef..297957713217 100644 --- a/common/djangoapps/student/auth.py +++ b/common/djangoapps/student/auth.py @@ -11,7 +11,7 @@ from django.core.exceptions import PermissionDenied from opaque_keys.edx.locator import LibraryLocator from openedx_authz import api as authz_api -from openedx_authz.constants.permissions import COURSES_EDIT_COURSE_CONTENT, COURSES_MANAGE_ADVANCED_SETTINGS +from openedx_authz.constants.permissions import COURSES_CREATE_COURSE, COURSES_MANAGE_ADVANCED_SETTINGS from common.djangoapps.student.roles import ( CourseBetaTesterRole, @@ -258,12 +258,9 @@ def _has_content_creator_access(user, org): return False org_scope_key = f"course-v1:{org}+*" - # TODO: We should be checking for the COURSES_CREATE_COURSE permission here, - # but since we don't have that linked to a role yet, we'll check for edit content - # permission for now, which is a superset of create course content permission. return authz_api.is_user_allowed( user.username, - COURSES_EDIT_COURSE_CONTENT.identifier, + COURSES_CREATE_COURSE.identifier, org_scope_key ) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 77a5f9e113a5..9150992f3de3 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -826,7 +826,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.2.0 +openedx-authz==1.5.0 # via -r requirements/edx/kernel.in openedx-calc==5.0.0 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 657ac94cc455..5aa5a7c2e017 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1375,7 +1375,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.2.0 +openedx-authz==1.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 0cc6b952a9b0..0e00278266c4 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1003,7 +1003,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.2.0 +openedx-authz==1.5.0 # via -r requirements/edx/base.txt openedx-calc==5.0.0 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 252d9f6ff777..e1984b1f3eb8 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1050,7 +1050,7 @@ openedx-atlas==0.7.0 # enterprise-integrated-channels # openedx-authz # openedx-forum -openedx-authz==1.2.0 +openedx-authz==1.5.0 # via -r requirements/edx/base.txt openedx-calc==5.0.0 # via From 1a0a0452b87f7fe7fefa7952bf49ef6076fca272 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Thu, 9 Apr 2026 17:05:39 -0600 Subject: [PATCH 09/13] fixup! feat: add AuthZ permissions to course creation and outline --- .../contentstore/api/tests/test_validation.py | 9 ++++----- .../v1/views/tests/test_course_index.py | 18 +++++++++--------- .../v2/views/tests/test_downstreams.py | 18 +++++++++--------- .../tests/test_course_create_rerun.py | 8 ++++---- 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/cms/djangoapps/contentstore/api/tests/test_validation.py b/cms/djangoapps/contentstore/api/tests/test_validation.py index 99f04f666b6f..a34faf516219 100644 --- a/cms/djangoapps/contentstore/api/tests/test_validation.py +++ b/cms/djangoapps/contentstore/api/tests/test_validation.py @@ -412,14 +412,13 @@ def test_authorized_user_can_list_blocks(self): response = self.authorized_client.get(self.url()) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIsInstance(response.json(), list) + assert response.status_code == status.HTTP_200_OK def test_unauthorized_user_cannot_list_blocks(self): """Unauthorized user should receive 403.""" response = self.unauthorized_client.get(self.url()) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + assert response.status_code == status.HTTP_403_FORBIDDEN # ---- elevated users ---- @@ -427,10 +426,10 @@ def test_staff_user_can_access_without_authz_role(self): """Staff user bypasses AuthZ.""" response = self.staff_client.get(self.url()) - self.assertEqual(response.status_code, status.HTTP_200_OK) + assert response.status_code == status.HTTP_200_OK def test_superuser_can_access_without_authz_role(self): """Superuser bypasses AuthZ.""" response = self.super_client.get(self.url()) - self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_201_CREATED]) + assert response.status_code in [status.HTTP_200_OK, status.HTTP_201_CREATED] diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py index bf6ddfe9c3a5..e12c3f6e7770 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py @@ -188,19 +188,19 @@ def test_authorized_user_can_access_course_index(self): response = self.authorized_client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn("course_structure", response.data) + assert response.status_code == status.HTTP_200_OK + assert "course_structure" in response.data def test_unauthorized_user_cannot_access_course_index(self): """Unauthorized user should receive 403.""" response = self.unauthorized_client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + assert response.status_code == status.HTTP_403_FORBIDDEN def test_user_without_role_then_added_can_access(self): """Validate dynamic role assignment works as expected.""" response = self.unauthorized_client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + assert response.status_code == status.HTTP_403_FORBIDDEN self.add_user_to_role_in_course( self.unauthorized_user, @@ -209,18 +209,18 @@ def test_user_without_role_then_added_can_access(self): ) response = self.unauthorized_client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) + assert response.status_code == status.HTTP_200_OK def test_staff_user_can_access_without_authz_role(self): """Django staff user should access without AuthZ role.""" response = self.staff_client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn("course_structure", response.data) + assert response.status_code == status.HTTP_200_OK + assert "course_structure" in response.data def test_superuser_can_access_without_authz_role(self): """Superuser should access without AuthZ role.""" response = self.super_client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn("course_structure", response.data) + assert response.status_code == status.HTTP_200_OK + assert "course_structure" in response.data diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index 293141f9569e..149732437215 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -1544,19 +1544,19 @@ def test_authorized_user_can_access_summary(self): response = self.call_api(self.authorized_client, str(self.course.id)) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIsInstance(response.json(), list) + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.json(), list) def test_unauthorized_user_cannot_access_summary(self): """Unauthorized user should receive 403.""" response = self.call_api(self.unauthorized_client, str(self.course.id)) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + assert response.status_code == status.HTTP_403_FORBIDDEN def test_user_without_role_then_added_can_access(self): """Validate dynamic role assignment works.""" response = self.call_api(self.unauthorized_client, str(self.course.id)) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + assert response.status_code == status.HTTP_403_FORBIDDEN self.add_user_to_role_in_course( self.unauthorized_user, @@ -1565,21 +1565,21 @@ def test_user_without_role_then_added_can_access(self): ) response = self.call_api(self.unauthorized_client, str(self.course.id)) - self.assertEqual(response.status_code, status.HTTP_200_OK) + assert response.status_code == status.HTTP_200_OK def test_staff_user_can_access_without_authz_role(self): """Staff user should access without explicit AuthZ role.""" response = self.call_api(self.staff_client, str(self.course.id)) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIsInstance(response.json(), list) + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.json(), list) def test_superuser_can_access_without_authz_role(self): """Superuser should access without explicit AuthZ role.""" response = self.call_api(self.super_client, str(self.course.id)) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIsInstance(response.json(), list) + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.json(), list) class GetDownstreamDeletedUpstream( diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index 03062adb1b4b..0871a0d89917 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -434,7 +434,7 @@ def test_create_course_unauthorized(self): "run": "2026_T1", }) - self.assertEqual(response.status_code, 403) + assert response.status_code == 403 @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) def test_create_course_unauthorized_with_role(self): @@ -455,7 +455,7 @@ def test_create_course_unauthorized_with_role(self): "run": "2026_T1", }) - self.assertEqual(response.status_code, 403) + assert response.status_code == 403 # ------------------------------------------------------------ # CREATE COURSE -- Staff users @@ -473,7 +473,7 @@ def test_create_course_staff(self): "run": "2026_T1", }) - self.assertEqual(response.status_code, 200) + assert response.status_code == 200 # ------------------------------------------------------------ # FEATURE FLAG @@ -497,4 +497,4 @@ def test_create_course_disabled_by_flag(self): "run": "2026_T1", }) - self.assertEqual(response.status_code, 403) + assert response.status_code == 403 From 698d2178af696f9c5b9ff561beeb0acd3d8545ea Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Thu, 9 Apr 2026 17:34:45 -0600 Subject: [PATCH 10/13] fixup! feat: add AuthZ permissions to course creation and outline --- requirements/edx/base.txt | 1 + requirements/edx/development.txt | 1 + requirements/edx/doc.txt | 1 + requirements/edx/testing.txt | 1 + 4 files changed, 4 insertions(+) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9150992f3de3..c2d88471fa40 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -427,6 +427,7 @@ edx-ccx-keys==2.0.2 # via # -r requirements/edx/kernel.in # lti-consumer-xblock + # openedx-authz # openedx-events edx-celeryutils==1.4.0 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 5aa5a7c2e017..462f74061ea9 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -698,6 +698,7 @@ edx-ccx-keys==2.0.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # lti-consumer-xblock + # openedx-authz # openedx-events edx-celeryutils==1.4.0 # via diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 0e00278266c4..fd9bc6c77bd2 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -518,6 +518,7 @@ edx-ccx-keys==2.0.2 # via # -r requirements/edx/base.txt # lti-consumer-xblock + # openedx-authz # openedx-events edx-celeryutils==1.4.0 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index e1984b1f3eb8..8d679c59e1c1 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -538,6 +538,7 @@ edx-ccx-keys==2.0.2 # via # -r requirements/edx/base.txt # lti-consumer-xblock + # openedx-authz # openedx-events edx-celeryutils==1.4.0 # via From 75925c31ac53d22ad56c07edd433a1cb979227b8 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Fri, 10 Apr 2026 10:34:35 -0600 Subject: [PATCH 11/13] fixup! feat: add AuthZ permissions to course creation and outline --- cms/djangoapps/contentstore/tests/test_course_create_rerun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index 0871a0d89917..431019d8caa2 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -439,7 +439,7 @@ def test_create_course_unauthorized(self): @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) def test_create_course_unauthorized_with_role(self): """ - User without role cannot create course. + User with role but without required permission cannot create course. """ self.add_user_to_role_in_course( From 2ad7b54ba7075eeda79e55525a601761ef49a7f1 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Fri, 10 Apr 2026 10:50:17 -0600 Subject: [PATCH 12/13] fixup! feat: add AuthZ permissions to course creation and outline --- .../contentstore/tests/test_course_create_rerun.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index 431019d8caa2..165349a8dc82 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -484,13 +484,7 @@ def test_create_course_disabled_by_flag(self): Even authorized users cannot create course if feature flag is off. """ - self.add_user_to_role_in_course( - self.authorized_user, - COURSE_EDITOR.external_key, - self.org_key, - ) - - response = self.authorized_client.ajax_post(self.url, { + response = self.authorized_staff_client.ajax_post(self.url, { "org": self.org, "number": "CS101", "display_name": "Authz Course", From ee03456dbc6318b1b33e8b2a2ededa568b6f5d05 Mon Sep 17 00:00:00 2001 From: Daniel Wong Date: Fri, 10 Apr 2026 13:54:02 -0600 Subject: [PATCH 13/13] fixup! feat: add AuthZ permissions to course creation and outline --- cms/djangoapps/contentstore/tests/test_course_create_rerun.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index 165349a8dc82..fbcf56067ac1 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -379,7 +379,6 @@ def test_default_enable_flexible_peer_openassessments_on_rerun( ) -@ddt.ddt class TestCourseHandlerAuthz( CourseAuthoringAuthzTestMixin, ModuleStoreTestCase,