Skip to content

Commit fb04cbc

Browse files
authored
feat: add AuthZ permissions to course creation and outline (#38259)
1 parent 3bf7968 commit fb04cbc

14 files changed

Lines changed: 545 additions & 20 deletions

File tree

cms/djangoapps/contentstore/api/tests/test_validation.py

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@
1111
from django.contrib.auth import get_user_model
1212
from django.test.utils import override_settings
1313
from django.urls import reverse
14-
from openedx_authz.constants.roles import COURSE_DATA_RESEARCHER, COURSE_STAFF
14+
from openedx_authz.constants.roles import COURSE_DATA_RESEARCHER, COURSE_EDITOR, COURSE_STAFF
1515
from rest_framework import status
1616
from rest_framework.test import APIClient, APITestCase
1717

1818
from cms.djangoapps.contentstore.api.tests.base import BaseCourseViewTest
1919
from common.djangoapps.course_modes.models import CourseMode
2020
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
2121
from common.djangoapps.student.tests.factories import StaffFactory, UserFactory
22-
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthzTestMixin
22+
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin, CourseAuthzTestMixin
2323
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
2424
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
2525

@@ -247,7 +247,7 @@ def test_create_update_reference_success(self, mock_block, mock_user_task_status
247247

248248
mock_auth.assert_called_once()
249249

250-
@patch('cms.djangoapps.contentstore.api.views.utils.has_course_author_access')
250+
@patch('openedx.core.djangoapps.authz.decorators.user_has_course_permission')
251251
@patch('xmodule.library_content_block.LegacyLibraryContentBlock.is_ready_to_migrate_to_v2')
252252
def test_list_ready_to_update_reference_success(self, mock_block, mock_auth):
253253
"""
@@ -353,3 +353,83 @@ def test_non_staff_user_cannot_access(self):
353353

354354
resp = non_staff_client.get(self.get_url(self.course_key))
355355
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009
356+
357+
358+
class TestMigrationViewSetCreateAuthz(
359+
CourseAuthoringAuthzTestMixin,
360+
SharedModuleStoreTestCase,
361+
APITestCase,
362+
):
363+
"""
364+
AuthZ tests for:
365+
/api/courses/v1/migrate_legacy_content_blocks/<course_id>/
366+
"""
367+
368+
@classmethod
369+
def setUpClass(cls):
370+
super().setUpClass()
371+
372+
cls.course = CourseFactory.create(
373+
display_name='test course',
374+
run="Testing_course",
375+
)
376+
cls.course_key = cls.course.id
377+
378+
cls.initialize_course(cls.course)
379+
380+
@classmethod
381+
def initialize_course(cls, course):
382+
"""Sets up test course structure."""
383+
section = BlockFactory.create(
384+
parent_location=course.location,
385+
category="chapter",
386+
)
387+
subsection = BlockFactory.create(
388+
parent_location=section.location,
389+
category="sequential",
390+
)
391+
unit = BlockFactory.create(
392+
parent_location=subsection.location,
393+
category="vertical",
394+
)
395+
BlockFactory.create(
396+
parent_location=unit.location,
397+
category="library_content",
398+
)
399+
400+
def url(self):
401+
return f"/api/courses/v1/migrate_legacy_content_blocks/{self.course_key}/"
402+
403+
# ---- GET (list) ----
404+
405+
def test_authorized_user_can_list_blocks(self):
406+
"""Authorized user can list migratable blocks."""
407+
self.add_user_to_role_in_course(
408+
self.authorized_user,
409+
COURSE_EDITOR.external_key,
410+
self.course.id,
411+
)
412+
413+
response = self.authorized_client.get(self.url())
414+
415+
assert response.status_code == status.HTTP_200_OK
416+
417+
def test_unauthorized_user_cannot_list_blocks(self):
418+
"""Unauthorized user should receive 403."""
419+
response = self.unauthorized_client.get(self.url())
420+
421+
assert response.status_code == status.HTTP_403_FORBIDDEN
422+
423+
# ---- elevated users ----
424+
425+
def test_staff_user_can_access_without_authz_role(self):
426+
"""Staff user bypasses AuthZ."""
427+
response = self.staff_client.get(self.url())
428+
429+
assert response.status_code == status.HTTP_200_OK
430+
431+
def test_superuser_can_access_without_authz_role(self):
432+
"""Superuser bypasses AuthZ."""
433+
response = self.super_client.get(self.url())
434+
435+
assert response.status_code in [status.HTTP_200_OK, status.HTTP_201_CREATED]

cms/djangoapps/contentstore/api/views/course_validation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ class CourseLegacyLibraryContentSerializer(serializers.Serializer):
364364
usage_key = serializers.CharField()
365365

366366

367-
class CourseLegacyLibraryContentMigratorView(StatusViewSet):
367+
class CourseLegacyLibraryContentMigratorView(DeveloperErrorViewMixin, StatusViewSet):
368368
"""
369369
This endpoint is used for migrating legacy library content to the new item bank block library v2.
370370
"""
@@ -384,7 +384,7 @@ class CourseLegacyLibraryContentMigratorView(StatusViewSet):
384384
401: "The requester is not authenticated.",
385385
},
386386
)
387-
@course_author_access_required
387+
@authz_permission_required(COURSES_VIEW_COURSE.identifier, LegacyAuthoringPermission.WRITE)
388388
def list(self, _, course_key): # pylint: disable=arguments-differ
389389
"""
390390
Returns all legacy library content blocks ready to be migrated to new item bank block.

cms/djangoapps/contentstore/rest_api/v1/views/course_index.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import edx_api_doc_tools as apidocs
66
from django.conf import settings
77
from opaque_keys.edx.keys import CourseKey
8+
from openedx_authz.constants.permissions import COURSES_VIEW_COURSE
89
from rest_framework.fields import BooleanField
910
from rest_framework.request import Request
1011
from rest_framework.response import Response
@@ -22,7 +23,7 @@
2223
)
2324
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_xblock
2425
from cms.lib.xblock.upstream_sync import UpstreamLink
25-
from common.djangoapps.student.auth import has_studio_read_access
26+
from openedx.core.djangoapps.authz.decorators import LegacyAuthoringPermission, user_has_course_permission
2627
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
2728
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
2829
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):
101102
"""
102103

103104
course_key = CourseKey.from_string(course_id)
104-
if not has_studio_read_access(request.user, course_key):
105+
if not user_has_course_permission(
106+
request.user,
107+
COURSES_VIEW_COURSE.identifier,
108+
course_key,
109+
LegacyAuthoringPermission.READ
110+
):
105111
self.permission_denied(request)
106112
course_index_context = get_course_index_context(request, course_key)
107113
course_index_context.update({

cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.test import RequestFactory
66
from django.urls import reverse
77
from edx_toggles.toggles.testutils import override_waffle_flag
8+
from openedx_authz.constants.roles import COURSE_EDITOR
89
from rest_framework import status
910

1011
from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
@@ -13,6 +14,7 @@
1314
from cms.djangoapps.contentstore.utils import get_lms_link_for_item, get_pages_and_resources_url
1415
from cms.djangoapps.contentstore.views.course import _course_outline_json
1516
from common.djangoapps.student.tests.factories import UserFactory
17+
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin
1618
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES
1719
from xmodule.modulestore.tests.factories import BlockFactory, check_mongo_calls
1820

@@ -162,3 +164,63 @@ def test_number_of_calls_to_db(self):
162164
with self.assertNumQueries(34, table_ignorelist=WAFFLE_TABLES):
163165
with check_mongo_calls(3):
164166
self.client.get(self.url)
167+
168+
169+
class CourseIndexAuthzViewTest(CourseAuthoringAuthzTestMixin, CourseTestCase):
170+
"""
171+
Tests for CourseIndexView using AuthZ permissions.
172+
"""
173+
174+
def setUp(self):
175+
super().setUp()
176+
self.url = reverse(
177+
"cms.djangoapps.contentstore:v1:course_index",
178+
kwargs={"course_id": self.course.id},
179+
)
180+
181+
def test_authorized_user_can_access_course_index(self):
182+
"""Authorized user with COURSE_EDITOR role can access course index."""
183+
self.add_user_to_role_in_course(
184+
self.authorized_user,
185+
COURSE_EDITOR.external_key,
186+
self.course.id
187+
)
188+
189+
response = self.authorized_client.get(self.url)
190+
191+
assert response.status_code == status.HTTP_200_OK
192+
assert "course_structure" in response.data
193+
194+
def test_unauthorized_user_cannot_access_course_index(self):
195+
"""Unauthorized user should receive 403."""
196+
response = self.unauthorized_client.get(self.url)
197+
198+
assert response.status_code == status.HTTP_403_FORBIDDEN
199+
200+
def test_user_without_role_then_added_can_access(self):
201+
"""Validate dynamic role assignment works as expected."""
202+
response = self.unauthorized_client.get(self.url)
203+
assert response.status_code == status.HTTP_403_FORBIDDEN
204+
205+
self.add_user_to_role_in_course(
206+
self.unauthorized_user,
207+
COURSE_EDITOR.external_key,
208+
self.course.id
209+
)
210+
211+
response = self.unauthorized_client.get(self.url)
212+
assert response.status_code == status.HTTP_200_OK
213+
214+
def test_staff_user_can_access_without_authz_role(self):
215+
"""Django staff user should access without AuthZ role."""
216+
response = self.staff_client.get(self.url)
217+
218+
assert response.status_code == status.HTTP_200_OK
219+
assert "course_structure" in response.data
220+
221+
def test_superuser_can_access_without_authz_role(self):
222+
"""Superuser should access without AuthZ role."""
223+
response = self.super_client.get(self.url)
224+
225+
assert response.status_code == status.HTTP_200_OK
226+
assert "course_structure" in response.data

cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
from opaque_keys import InvalidKeyError
9191
from opaque_keys.edx.keys import CourseKey, UsageKey
9292
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
93+
from openedx_authz.constants.permissions import COURSES_VIEW_COURSE
9394
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
9495
from rest_framework.fields import BooleanField
9596
from rest_framework.request import Request
@@ -115,6 +116,7 @@
115116
from cms.lib.xblock.upstream_sync_block import fetch_customizable_fields_from_block
116117
from cms.lib.xblock.upstream_sync_container import fetch_customizable_fields_from_container
117118
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
119+
from openedx.core.djangoapps.authz.decorators import LegacyAuthoringPermission, user_has_course_permission
118120
from openedx.core.djangoapps.content_libraries import api as lib_api
119121
from openedx.core.djangoapps.video_config.transcripts_utils import clear_transcripts
120122
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):
302304
except InvalidKeyError as exc:
303305
raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc
304306

305-
if not has_studio_read_access(request.user, course_key):
307+
if not user_has_course_permission(
308+
request.user,
309+
COURSES_VIEW_COURSE.identifier,
310+
course_key,
311+
LegacyAuthoringPermission.READ
312+
):
306313
raise PermissionDenied
307314

308315
# Gets all links of the Course, using the

cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
from freezegun import freeze_time
1212
from opaque_keys.edx.keys import ContainerKey, UsageKey
1313
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
14+
from openedx_authz.constants.roles import COURSE_EDITOR
1415
from openedx_content import models_api as content_models
1516
from organizations.models import Organization
17+
from rest_framework import status
1618

1719
from cms.djangoapps.contentstore.helpers import StaticFileNotices
1820
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
@@ -22,6 +24,7 @@
2224
from common.djangoapps.student.auth import add_users
2325
from common.djangoapps.student.roles import CourseStaffRole
2426
from common.djangoapps.student.tests.factories import UserFactory
27+
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin
2528
from openedx.core.djangoapps.content_libraries import api as lib_api
2629
from xmodule.modulestore.django import modulestore
2730
from xmodule.modulestore.tests.django_utils import ImmediateOnCommitMixin, SharedModuleStoreTestCase
@@ -1517,6 +1520,68 @@ def test_200_summary(self):
15171520
self.assertListEqual(data, expected) # noqa: PT009
15181521

15191522

1523+
class GetDownstreamSummaryAuthzViewTest(
1524+
CourseAuthoringAuthzTestMixin,
1525+
_BaseDownstreamViewTestMixin,
1526+
ImmediateOnCommitMixin,
1527+
SharedModuleStoreTestCase,
1528+
):
1529+
"""
1530+
AuthZ tests for:
1531+
GET /api/contentstore/v2/downstreams/<course_id>/summary
1532+
"""
1533+
1534+
def call_api(self, client, course_id): # pylint: disable=arguments-differ
1535+
return client.get(f"/api/contentstore/v2/downstreams/{course_id}/summary")
1536+
1537+
def test_authorized_user_can_access_summary(self):
1538+
"""Authorized user with COURSE_EDITOR role can access summary."""
1539+
self.add_user_to_role_in_course(
1540+
self.authorized_user,
1541+
COURSE_EDITOR.external_key,
1542+
self.course.id
1543+
)
1544+
1545+
response = self.call_api(self.authorized_client, str(self.course.id))
1546+
1547+
assert response.status_code == status.HTTP_200_OK
1548+
assert isinstance(response.json(), list)
1549+
1550+
def test_unauthorized_user_cannot_access_summary(self):
1551+
"""Unauthorized user should receive 403."""
1552+
response = self.call_api(self.unauthorized_client, str(self.course.id))
1553+
1554+
assert response.status_code == status.HTTP_403_FORBIDDEN
1555+
1556+
def test_user_without_role_then_added_can_access(self):
1557+
"""Validate dynamic role assignment works."""
1558+
response = self.call_api(self.unauthorized_client, str(self.course.id))
1559+
assert response.status_code == status.HTTP_403_FORBIDDEN
1560+
1561+
self.add_user_to_role_in_course(
1562+
self.unauthorized_user,
1563+
COURSE_EDITOR.external_key,
1564+
self.course.id
1565+
)
1566+
1567+
response = self.call_api(self.unauthorized_client, str(self.course.id))
1568+
assert response.status_code == status.HTTP_200_OK
1569+
1570+
def test_staff_user_can_access_without_authz_role(self):
1571+
"""Staff user should access without explicit AuthZ role."""
1572+
response = self.call_api(self.staff_client, str(self.course.id))
1573+
1574+
assert response.status_code == status.HTTP_200_OK
1575+
assert isinstance(response.json(), list)
1576+
1577+
def test_superuser_can_access_without_authz_role(self):
1578+
"""Superuser should access without explicit AuthZ role."""
1579+
response = self.call_api(self.super_client, str(self.course.id))
1580+
1581+
assert response.status_code == status.HTTP_200_OK
1582+
assert isinstance(response.json(), list)
1583+
1584+
15201585
class GetDownstreamDeletedUpstream(
15211586
_BaseDownstreamViewTestMixin,
15221587
ImmediateOnCommitMixin,

0 commit comments

Comments
 (0)