Skip to content

Commit 15104f1

Browse files
wgu-taylor-payneKiro
andcommitted
feat: enforce authz permissions for Pages & Resources endpoints
Co-authored-by: Kiro <kiro@amazon.com>
1 parent fa8b3a5 commit 15104f1

12 files changed

Lines changed: 584 additions & 45 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""
2+
Integration tests verifying authz permissions for v0 tabs REST API views.
3+
"""
4+
from urllib.parse import urlencode
5+
6+
from django.urls import reverse
7+
from rest_framework.test import APIClient
8+
9+
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
10+
from common.djangoapps.student.tests.factories import UserFactory
11+
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin
12+
from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_STAFF
13+
14+
15+
class TabsV0AuthzTest(CourseAuthoringAuthzTestMixin, CourseTestCase):
16+
"""
17+
Integration tests for v0 tabs API authz permissions.
18+
"""
19+
20+
def setUp(self):
21+
super().setUp()
22+
self.list_url = reverse(
23+
'cms.djangoapps.contentstore:v0:course_tab_list',
24+
kwargs={'course_id': self.course.id},
25+
)
26+
self.settings_url = reverse(
27+
'cms.djangoapps.contentstore:v0:course_tab_settings',
28+
kwargs={'course_id': self.course.id},
29+
)
30+
self.reorder_url = reverse(
31+
'cms.djangoapps.contentstore:v0:course_tab_reorder',
32+
kwargs={'course_id': self.course.id},
33+
)
34+
35+
# --- CourseTabListView (GET) - requires courses.view_pages_and_resources ---
36+
37+
def test_staff_can_list_tabs(self):
38+
self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id)
39+
resp = self.authorized_client.get(self.list_url)
40+
assert resp.status_code == 200
41+
42+
def test_auditor_can_list_tabs(self):
43+
self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id)
44+
resp = self.authorized_client.get(self.list_url)
45+
assert resp.status_code == 200
46+
47+
def test_unauthorized_cannot_list_tabs(self):
48+
resp = self.unauthorized_client.get(self.list_url)
49+
assert resp.status_code == 403
50+
51+
# --- CourseTabSettingsView (POST) - requires courses.manage_pages_and_resources ---
52+
53+
def test_staff_can_update_tab_settings(self):
54+
"""Asserts not-403 rather than 200 because the minimal payload may fail validation."""
55+
self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id)
56+
resp = self.authorized_client.post(
57+
f'{self.settings_url}?{urlencode({"tab_id": "wiki"})}',
58+
data={'is_hidden': True},
59+
format='json',
60+
)
61+
assert resp.status_code != 403
62+
63+
def test_auditor_cannot_update_tab_settings(self):
64+
self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id)
65+
resp = self.authorized_client.post(
66+
f'{self.settings_url}?{urlencode({"tab_id": "wiki"})}',
67+
data={'is_hidden': True},
68+
format='json',
69+
)
70+
assert resp.status_code == 403
71+
72+
def test_unauthorized_cannot_update_tab_settings(self):
73+
resp = self.unauthorized_client.post(
74+
f'{self.settings_url}?{urlencode({"tab_id": "wiki"})}',
75+
data={'is_hidden': True},
76+
format='json',
77+
)
78+
assert resp.status_code == 403
79+
80+
# --- CourseTabReorderView (POST) - requires courses.manage_pages_and_resources ---
81+
82+
def test_staff_can_reorder_tabs(self):
83+
"""Asserts not-403 rather than 200 because the empty tab list may fail validation."""
84+
self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id)
85+
resp = self.authorized_client.post(self.reorder_url, data=[], format='json')
86+
assert resp.status_code != 403
87+
88+
def test_auditor_cannot_reorder_tabs(self):
89+
self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id)
90+
resp = self.authorized_client.post(self.reorder_url, data=[], format='json')
91+
assert resp.status_code == 403
92+
93+
def test_unauthorized_cannot_reorder_tabs(self):
94+
resp = self.unauthorized_client.post(self.reorder_url, data=[], format='json')
95+
assert resp.status_code == 403
96+
97+
# --- Superuser bypass ---
98+
99+
def test_superuser_can_list_tabs(self):
100+
superuser = UserFactory(is_superuser=True)
101+
client = APIClient()
102+
client.force_authenticate(user=superuser)
103+
resp = client.get(self.list_url)
104+
assert resp.status_code == 200

cms/djangoapps/contentstore/rest_api/v0/views/tabs.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
from xmodule.modulestore.django import modulestore
1111
from xmodule.modulestore.exceptions import ItemNotFoundError
1212

13-
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
13+
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
14+
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
15+
from openedx_authz.constants.permissions import (
16+
COURSES_MANAGE_PAGES_AND_RESOURCES,
17+
COURSES_VIEW_PAGES_AND_RESOURCES,
18+
)
1419
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
1520
from ..serializers import CourseTabSerializer, CourseTabUpdateSerializer, TabIDLocatorSerializer
1621
from ....views.tabs import edit_tab_handler, get_course_tabs, reorder_tabs_handler
@@ -78,7 +83,12 @@ def get(self, request: Request, course_id: str) -> Response:
7883
```
7984
"""
8085
course_key = CourseKey.from_string(course_id)
81-
if not has_studio_read_access(request.user, course_key):
86+
if not user_has_course_permission(
87+
request.user,
88+
COURSES_VIEW_PAGES_AND_RESOURCES.identifier,
89+
course_key,
90+
LegacyAuthoringPermission.READ,
91+
):
8292
self.permission_denied(request)
8393

8494
course_block = modulestore().get_course(course_key)
@@ -149,7 +159,12 @@ def post(self, request: Request, course_id: str) -> Response:
149159
without any content.
150160
"""
151161
course_key = CourseKey.from_string(course_id)
152-
if not has_studio_write_access(request.user, course_key):
162+
if not user_has_course_permission(
163+
request.user,
164+
COURSES_MANAGE_PAGES_AND_RESOURCES.identifier,
165+
course_key,
166+
LegacyAuthoringPermission.WRITE,
167+
):
153168
self.permission_denied(request)
154169

155170
tab_id_locator = TabIDLocatorSerializer(data=request.query_params)
@@ -221,7 +236,12 @@ def post(self, request: Request, course_id: str) -> Response:
221236
without any content.
222237
"""
223238
course_key = CourseKey.from_string(course_id)
224-
if not has_studio_write_access(request.user, course_key):
239+
if not user_has_course_permission(
240+
request.user,
241+
COURSES_MANAGE_PAGES_AND_RESOURCES.identifier,
242+
course_key,
243+
LegacyAuthoringPermission.WRITE,
244+
):
225245
self.permission_denied(request)
226246

227247
course_block = modulestore().get_course(course_key)

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

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
Unit tests for the course's textbooks.
33
"""
44
from django.urls import reverse
5-
from rest_framework import status
5+
from rest_framework.test import APIClient
66

77
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
8+
from common.djangoapps.student.tests.factories import UserFactory
9+
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin
10+
from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_STAFF
811

912
from ...mixins import PermissionAccessMixin
1013

@@ -39,5 +42,39 @@ def test_success_response(self):
3942
self.save_course()
4043

4144
response = self.client.get(self.url)
42-
self.assertEqual(response.status_code, status.HTTP_200_OK)
45+
self.assertEqual(response.status_code, 200)
4346
self.assertEqual(response.data["textbooks"], expected_textbook)
47+
48+
49+
class CourseTextbooksAuthzTest(CourseAuthoringAuthzTestMixin, CourseTestCase):
50+
"""
51+
Integration tests for CourseTextbooksView authz permissions.
52+
"""
53+
54+
def setUp(self):
55+
super().setUp()
56+
self.url = reverse(
57+
"cms.djangoapps.contentstore:v1:textbooks",
58+
kwargs={"course_id": self.course.id},
59+
)
60+
61+
def test_staff_can_view_textbooks(self):
62+
self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id)
63+
resp = self.authorized_client.get(self.url)
64+
assert resp.status_code == 200
65+
66+
def test_auditor_can_view_textbooks(self):
67+
self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id)
68+
resp = self.authorized_client.get(self.url)
69+
assert resp.status_code == 200
70+
71+
def test_unauthorized_cannot_view_textbooks(self):
72+
resp = self.unauthorized_client.get(self.url)
73+
assert resp.status_code == 403
74+
75+
def test_superuser_can_view_textbooks(self):
76+
superuser = UserFactory(is_superuser=True)
77+
client = APIClient()
78+
client.force_authenticate(user=superuser)
79+
resp = client.get(self.url)
80+
assert resp.status_code == 200

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from cms.djangoapps.contentstore.rest_api.v1.serializers import (
1111
CourseTextbooksSerializer,
1212
)
13-
from common.djangoapps.student.auth import has_studio_read_access
13+
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
14+
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
15+
from openedx_authz.constants.permissions import COURSES_VIEW_PAGES_AND_RESOURCES
1416
from openedx.core.lib.api.view_utils import (
1517
DeveloperErrorViewMixin,
1618
verify_course_exists,
@@ -80,7 +82,12 @@ def get(self, request: Request, course_id: str):
8082
course_key = CourseKey.from_string(course_id)
8183
store = modulestore()
8284

83-
if not has_studio_read_access(request.user, course_key):
85+
if not user_has_course_permission(
86+
request.user,
87+
COURSES_VIEW_PAGES_AND_RESOURCES.identifier,
88+
course_key,
89+
LegacyAuthoringPermission.READ,
90+
):
8491
self.permission_denied(request)
8592

8693
with store.bulk_operations(course_key):

cms/djangoapps/contentstore/views/course.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@
6060
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
6161
from openedx_authz.constants.permissions import (
6262
COURSES_MANAGE_COURSE_UPDATES,
63+
COURSES_MANAGE_PAGES_AND_RESOURCES,
6364
COURSES_VIEW_COURSE_UPDATES,
65+
COURSES_VIEW_PAGES_AND_RESOURCES,
6466
COURSES_MANAGE_GROUP_CONFIGURATIONS,
6567
)
6668
from common.djangoapps.student.roles import (
@@ -1483,16 +1485,21 @@ def textbooks_list_handler(request, course_key_string):
14831485
"""
14841486
course_key = CourseKey.from_string(course_key_string)
14851487
if "application/json" not in request.META.get('HTTP_ACCEPT', 'text/html'):
1486-
# return HTML page
1487-
# We don't need to do an access check here because
1488-
# that is done when the endpoint for the actual content of the page.
1489-
# This is just to handle redirecting anyone that has bookmarked the old
1490-
# textbooks page.
14911488
return redirect(get_textbooks_url(course_key))
14921489

1490+
if request.method == 'GET':
1491+
authz_perm = COURSES_VIEW_PAGES_AND_RESOURCES.identifier
1492+
legacy_perm = LegacyAuthoringPermission.READ
1493+
else:
1494+
authz_perm = COURSES_MANAGE_PAGES_AND_RESOURCES.identifier
1495+
legacy_perm = LegacyAuthoringPermission.WRITE
1496+
1497+
if not user_has_course_permission(request.user, authz_perm, course_key, legacy_perm):
1498+
raise PermissionDenied()
1499+
14931500
store = modulestore()
14941501
with store.bulk_operations(course_key):
1495-
course = get_course_and_check_access(course_key, request.user)
1502+
course = _get_course_block(course_key)
14961503

14971504
# from here on down, we know the client has requested JSON
14981505
if request.method == 'GET':
@@ -1555,9 +1562,20 @@ def textbooks_detail_handler(request, course_key_string, textbook_id):
15551562
json: remove textbook
15561563
"""
15571564
course_key = CourseKey.from_string(course_key_string)
1565+
1566+
if request.method == 'GET':
1567+
authz_perm = COURSES_VIEW_PAGES_AND_RESOURCES.identifier
1568+
legacy_perm = LegacyAuthoringPermission.READ
1569+
else:
1570+
authz_perm = COURSES_MANAGE_PAGES_AND_RESOURCES.identifier
1571+
legacy_perm = LegacyAuthoringPermission.WRITE
1572+
1573+
if not user_has_course_permission(request.user, authz_perm, course_key, legacy_perm):
1574+
raise PermissionDenied()
1575+
15581576
store = modulestore()
15591577
with store.bulk_operations(course_key):
1560-
course_block = get_course_and_check_access(course_key, request.user)
1578+
course_block = _get_course_block(course_key)
15611579
matching_id = [tb for tb in course_block.pdf_textbooks
15621580
if str(tb.get("id")) == str(textbook_id)]
15631581
if matching_id:

0 commit comments

Comments
 (0)