Skip to content

Commit 69a3ea8

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

12 files changed

Lines changed: 618 additions & 45 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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 openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_LIMITED_STAFF, COURSE_STAFF
8+
from rest_framework.test import APIClient
9+
10+
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
11+
from common.djangoapps.student.tests.factories import UserFactory
12+
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin
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_limited_staff_cannot_list_tabs(self):
48+
self.add_user_to_role_in_course(self.authorized_user, COURSE_LIMITED_STAFF.external_key, self.course.id)
49+
resp = self.authorized_client.get(self.list_url)
50+
assert resp.status_code == 403
51+
52+
def test_unauthorized_cannot_list_tabs(self):
53+
resp = self.unauthorized_client.get(self.list_url)
54+
assert resp.status_code == 403
55+
56+
# --- CourseTabSettingsView (POST) - requires courses.manage_pages_and_resources ---
57+
58+
def test_staff_can_update_tab_settings(self):
59+
"""Asserts not-403 rather than 200 because the minimal payload may fail validation."""
60+
self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id)
61+
resp = self.authorized_client.post(
62+
f'{self.settings_url}?{urlencode({"tab_id": "wiki"})}',
63+
data={'is_hidden': True},
64+
format='json',
65+
)
66+
assert resp.status_code != 403
67+
68+
def test_auditor_cannot_update_tab_settings(self):
69+
self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id)
70+
resp = self.authorized_client.post(
71+
f'{self.settings_url}?{urlencode({"tab_id": "wiki"})}',
72+
data={'is_hidden': True},
73+
format='json',
74+
)
75+
assert resp.status_code == 403
76+
77+
def test_unauthorized_cannot_update_tab_settings(self):
78+
resp = self.unauthorized_client.post(
79+
f'{self.settings_url}?{urlencode({"tab_id": "wiki"})}',
80+
data={'is_hidden': True},
81+
format='json',
82+
)
83+
assert resp.status_code == 403
84+
85+
# --- CourseTabReorderView (POST) - requires courses.manage_pages_and_resources ---
86+
87+
def test_staff_can_reorder_tabs(self):
88+
"""Asserts not-403 rather than 200 because the empty tab list may fail validation."""
89+
self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.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_auditor_cannot_reorder_tabs(self):
94+
self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id)
95+
resp = self.authorized_client.post(self.reorder_url, data=[], format='json')
96+
assert resp.status_code == 403
97+
98+
def test_unauthorized_cannot_reorder_tabs(self):
99+
resp = self.unauthorized_client.post(self.reorder_url, data=[], format='json')
100+
assert resp.status_code == 403
101+
102+
# --- Superuser bypass ---
103+
104+
def test_superuser_can_list_tabs(self):
105+
superuser = UserFactory(is_superuser=True)
106+
client = APIClient()
107+
client.force_authenticate(user=superuser)
108+
resp = client.get(self.list_url)
109+
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
@@ -3,12 +3,17 @@
33
import edx_api_doc_tools as apidocs
44
from django.utils.translation import gettext_lazy as _
55
from opaque_keys.edx.keys import CourseKey
6+
from openedx_authz.constants.permissions import (
7+
COURSES_MANAGE_PAGES_AND_RESOURCES,
8+
COURSES_VIEW_PAGES_AND_RESOURCES,
9+
)
610
from rest_framework import status
711
from rest_framework.request import Request
812
from rest_framework.response import Response
913
from rest_framework.views import APIView
1014

11-
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
15+
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
16+
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
1217
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
1318
from xmodule.modulestore.django import modulestore
1419
from xmodule.modulestore.exceptions import ItemNotFoundError
@@ -79,7 +84,12 @@ def get(self, request: Request, course_id: str) -> Response:
7984
```
8085
"""
8186
course_key = CourseKey.from_string(course_id)
82-
if not has_studio_read_access(request.user, course_key):
87+
if not user_has_course_permission(
88+
request.user,
89+
COURSES_VIEW_PAGES_AND_RESOURCES.identifier,
90+
course_key,
91+
LegacyAuthoringPermission.READ,
92+
):
8393
self.permission_denied(request)
8494

8595
course_block = modulestore().get_course(course_key)
@@ -150,7 +160,12 @@ def post(self, request: Request, course_id: str) -> Response:
150160
without any content.
151161
"""
152162
course_key = CourseKey.from_string(course_id)
153-
if not has_studio_write_access(request.user, course_key):
163+
if not user_has_course_permission(
164+
request.user,
165+
COURSES_MANAGE_PAGES_AND_RESOURCES.identifier,
166+
course_key,
167+
LegacyAuthoringPermission.WRITE,
168+
):
154169
self.permission_denied(request)
155170

156171
tab_id_locator = TabIDLocatorSerializer(data=request.query_params)
@@ -222,7 +237,12 @@ def post(self, request: Request, course_id: str) -> Response:
222237
without any content.
223238
"""
224239
course_key = CourseKey.from_string(course_id)
225-
if not has_studio_write_access(request.user, course_key):
240+
if not user_has_course_permission(
241+
request.user,
242+
COURSES_MANAGE_PAGES_AND_RESOURCES.identifier,
243+
course_key,
244+
LegacyAuthoringPermission.WRITE,
245+
):
226246
self.permission_denied(request)
227247

228248
course_block = modulestore().get_course(course_key)

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

Lines changed: 44 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 openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_LIMITED_STAFF, COURSE_STAFF
6+
from rest_framework.test import APIClient
67

78
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
9+
from common.djangoapps.student.tests.factories import UserFactory
10+
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin
811

912
from ...mixins import PermissionAccessMixin
1013

@@ -39,5 +42,44 @@ 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_limited_staff_cannot_view_textbooks(self):
72+
self.add_user_to_role_in_course(self.authorized_user, COURSE_LIMITED_STAFF.external_key, self.course.id)
73+
resp = self.authorized_client.get(self.url)
74+
assert resp.status_code == 403
75+
76+
def test_unauthorized_cannot_view_textbooks(self):
77+
resp = self.unauthorized_client.get(self.url)
78+
assert resp.status_code == 403
79+
80+
def test_superuser_can_view_textbooks(self):
81+
superuser = UserFactory(is_superuser=True)
82+
client = APIClient()
83+
client.force_authenticate(user=superuser)
84+
resp = client.get(self.url)
85+
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
@@ -2,13 +2,15 @@
22

33
import edx_api_doc_tools as apidocs
44
from opaque_keys.edx.keys import CourseKey
5+
from openedx_authz.constants.permissions import COURSES_VIEW_PAGES_AND_RESOURCES
56
from rest_framework.request import Request
67
from rest_framework.response import Response
78
from rest_framework.views import APIView
89

910
from cms.djangoapps.contentstore.rest_api.v1.serializers import CourseTextbooksSerializer
1011
from cms.djangoapps.contentstore.utils import get_textbooks_context
11-
from common.djangoapps.student.auth import has_studio_read_access
12+
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
13+
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
1214
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
1315
from xmodule.modulestore.django import modulestore
1416

@@ -74,7 +76,12 @@ def get(self, request: Request, course_id: str):
7476
course_key = CourseKey.from_string(course_id)
7577
store = modulestore()
7678

77-
if not has_studio_read_access(request.user, course_key):
79+
if not user_has_course_permission(
80+
request.user,
81+
COURSES_VIEW_PAGES_AND_RESOURCES.identifier,
82+
course_key,
83+
LegacyAuthoringPermission.READ,
84+
):
7885
self.permission_denied(request)
7986

8087
with store.bulk_operations(course_key):

cms/djangoapps/contentstore/views/course.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@
3333
from openedx_authz.constants.permissions import (
3434
COURSES_MANAGE_COURSE_UPDATES,
3535
COURSES_MANAGE_GROUP_CONFIGURATIONS,
36+
COURSES_MANAGE_PAGES_AND_RESOURCES,
3637
COURSES_VIEW_COURSE,
3738
COURSES_VIEW_COURSE_UPDATES,
39+
COURSES_VIEW_PAGES_AND_RESOURCES,
3840
)
3941
from organizations.api import add_organization_course, ensure_organization
4042
from organizations.exceptions import InvalidOrganizationException
@@ -1653,16 +1655,23 @@ def textbooks_list_handler(request, course_key_string):
16531655
"""
16541656
course_key = CourseKey.from_string(course_key_string)
16551657
if "application/json" not in request.META.get('HTTP_ACCEPT', 'text/html'):
1656-
# return HTML page
1657-
# We don't need to do an access check here because
1658-
# that is done when the endpoint for the actual content of the page.
1659-
# This is just to handle redirecting anyone that has bookmarked the old
1660-
# textbooks page.
1658+
# Legacy HTML bookmark redirect — no data is exposed here.
1659+
# Access is enforced when the MFE fetches data from the textbooks API.
16611660
return redirect(get_textbooks_url(course_key))
16621661

1662+
if request.method == 'GET':
1663+
authz_perm = COURSES_VIEW_PAGES_AND_RESOURCES.identifier
1664+
legacy_perm = LegacyAuthoringPermission.READ
1665+
else:
1666+
authz_perm = COURSES_MANAGE_PAGES_AND_RESOURCES.identifier
1667+
legacy_perm = LegacyAuthoringPermission.WRITE
1668+
1669+
if not user_has_course_permission(request.user, authz_perm, course_key, legacy_perm):
1670+
raise PermissionDenied()
1671+
16631672
store = modulestore()
16641673
with store.bulk_operations(course_key):
1665-
course = get_course_and_check_access(course_key, request.user)
1674+
course = _get_course_block(course_key)
16661675

16671676
# from here on down, we know the client has requested JSON
16681677
if request.method == 'GET':
@@ -1725,9 +1734,20 @@ def textbooks_detail_handler(request, course_key_string, textbook_id):
17251734
json: remove textbook
17261735
"""
17271736
course_key = CourseKey.from_string(course_key_string)
1737+
1738+
if request.method == 'GET':
1739+
authz_perm = COURSES_VIEW_PAGES_AND_RESOURCES.identifier
1740+
legacy_perm = LegacyAuthoringPermission.READ
1741+
else:
1742+
authz_perm = COURSES_MANAGE_PAGES_AND_RESOURCES.identifier
1743+
legacy_perm = LegacyAuthoringPermission.WRITE
1744+
1745+
if not user_has_course_permission(request.user, authz_perm, course_key, legacy_perm):
1746+
raise PermissionDenied()
1747+
17281748
store = modulestore()
17291749
with store.bulk_operations(course_key):
1730-
course_block = get_course_and_check_access(course_key, request.user)
1750+
course_block = _get_course_block(course_key)
17311751
matching_id = [tb for tb in course_block.pdf_textbooks
17321752
if str(tb.get("id")) == str(textbook_id)]
17331753
if matching_id:

0 commit comments

Comments
 (0)