diff --git a/cms/djangoapps/contentstore/api/tests/test_validation.py b/cms/djangoapps/contentstore/api/tests/test_validation.py index 012a5b00559d..a34faf516219 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,83 @@ 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()) + + 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()) + + assert 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()) + + 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()) + + assert response.status_code in [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..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,6 +5,7 @@ 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 @@ -22,7 +23,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 +102,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..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 @@ -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) + + 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) + + 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) + assert 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) + 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) + + 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) + + assert response.status_code == status.HTTP_200_OK + assert "course_structure" in 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..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 @@ -11,8 +11,10 @@ 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 rest_framework import status from cms.djangoapps.contentstore.helpers import StaticFileNotices from cms.djangoapps.contentstore.tests.utils import CourseTestCase @@ -22,6 +24,7 @@ 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.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 @@ -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)) + + 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)) + + 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)) + assert 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)) + 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)) + + 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)) + + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.json(), list) + + class GetDownstreamDeletedUpstream( _BaseDownstreamViewTestMixin, ImmediateOnCommitMixin, diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index 21c1ecc19f21..fbcf56067ac1 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,117 @@ def test_default_enable_flexible_peer_openassessments_on_rerun( source_course.force_on_flexible_peer_openassessments, dest_course.force_on_flexible_peer_openassessments ) + + +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_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", + }) + + assert response.status_code == 403 + + @override_settings(FEATURES={"DISABLE_COURSE_CREATION": False}) + def test_create_course_unauthorized_with_role(self): + """ + User with role but without required permission 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", + }) + + assert response.status_code == 403 + + # ------------------------------------------------------------ + # CREATE COURSE -- Staff users + # Only staff users can create course, and they can do it + # without an org role. + # ------------------------------------------------------------ + 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", + }) + + assert response.status_code == 200 + + # ------------------------------------------------------------ + # 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. + """ + + response = self.authorized_staff_client.ajax_post(self.url, { + "org": self.org, + "number": "CS101", + "display_name": "Authz Course", + "run": "2026_T1", + }) + + assert response.status_code == 403 diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py index 71a2cfeb5990..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_MANAGE_ADVANCED_SETTINGS +from openedx_authz.constants.permissions import COURSES_CREATE_COURSE, COURSES_MANAGE_ADVANCED_SETTINGS from common.djangoapps.student.roles import ( CourseBetaTesterRole, @@ -224,6 +224,48 @@ 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): + """ + 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}+*" + + return authz_api.is_user_allowed( + user.username, + COURSES_CREATE_COURSE.identifier, + org_scope_key + ) + + +def _has_legacy_content_creator_access(user, org): """ Check if the user has the role to create content. diff --git a/lms/djangoapps/course_api/tests/test_api.py b/lms/djangoapps/course_api/tests/test_api.py index 8f134e829985..b84b920c4a47 100644 --- a/lms/djangoapps/course_api/tests/test_api.py +++ b/lms/djangoapps/course_api/tests/test_api.py @@ -11,10 +11,13 @@ 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.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 from xmodule.modulestore.tests.django_utils import ( # lint-amnesty, pylint: disable=wrong-import-order @@ -108,6 +111,137 @@ 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 + ) + + 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(CourseAccessRedirect): + 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(CourseAccessRedirect): + 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(CourseAccessRedirect): + 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..a99949c5fdbb 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -18,6 +18,7 @@ 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 xblock.core import XBlock from common.djangoapps.student import auth @@ -62,6 +63,8 @@ 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 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 @@ -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 CatalogVisibilityError() + 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/requirements/edx/base.txt b/requirements/edx/base.txt index 77a5f9e113a5..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 @@ -826,7 +827,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..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 @@ -1375,7 +1376,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..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 @@ -1003,7 +1004,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..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 @@ -1050,7 +1051,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