Skip to content

Commit 7499a5f

Browse files
feat: authorize advanced settings endpoints via openedx-authz when fl… (openedx#38009)
1 parent 8b3c3fd commit 7499a5f

File tree

9 files changed

+206
-10
lines changed

9 files changed

+206
-10
lines changed

cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@
22
Tests for the course advanced settings API.
33
"""
44
import json
5+
import pkg_resources
6+
from unittest.mock import patch
57

8+
import casbin
69
import ddt
710
from django.test import override_settings
811
from django.urls import reverse
912
from milestones.tests.utils import MilestonesTestCaseMixin
13+
from rest_framework.test import APIClient
1014

1115
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
16+
from common.djangoapps.student.tests.factories import UserFactory
17+
from openedx.core import toggles as core_toggles
18+
from openedx_authz.api.users import assign_role_to_user_in_scope
19+
from openedx_authz.constants.roles import COURSE_STAFF
20+
from openedx_authz.engine.enforcer import AuthzEnforcer
21+
from openedx_authz.engine.utils import migrate_policy_between_enforcers
1222

1323

1424
@ddt.ddt
@@ -91,3 +101,105 @@ def test_disabled_fetch_all_query_param(self, setting, excluded_field):
91101
with override_settings(FEATURES={setting: False}):
92102
resp = self.client.get(self.url, {"fetch_all": 0})
93103
assert excluded_field not in resp.data
104+
105+
106+
@patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=True)
107+
class AdvancedSettingsAuthzTest(CourseTestCase):
108+
"""
109+
Tests for AdvancedCourseSettingsView authorization with openedx-authz.
110+
111+
These tests enable the AUTHZ_COURSE_AUTHORING_FLAG by default.
112+
"""
113+
114+
def setUp(self):
115+
super().setUp()
116+
self._seed_database_with_policies()
117+
self.url = reverse(
118+
"cms.djangoapps.contentstore:v0:course_advanced_settings",
119+
kwargs={"course_id": self.course.id},
120+
)
121+
122+
# Create test users
123+
self.authorized_user = UserFactory()
124+
self.unauthorized_user = UserFactory()
125+
126+
# Assign role to authorized user
127+
assign_role_to_user_in_scope(
128+
self.authorized_user.username,
129+
COURSE_STAFF.external_key,
130+
str(self.course.id)
131+
)
132+
AuthzEnforcer.get_enforcer().load_policy()
133+
134+
# Create API clients and force_authenticate
135+
self.authorized_client = APIClient()
136+
self.authorized_client.force_authenticate(user=self.authorized_user)
137+
self.unauthorized_client = APIClient()
138+
self.unauthorized_client.force_authenticate(user=self.unauthorized_user)
139+
140+
def tearDown(self):
141+
super().tearDown()
142+
AuthzEnforcer.get_enforcer().clear_policy()
143+
144+
@classmethod
145+
def _seed_database_with_policies(cls):
146+
"""Seed the database with policies from the policy file."""
147+
global_enforcer = AuthzEnforcer.get_enforcer()
148+
global_enforcer.load_policy()
149+
model_path = pkg_resources.resource_filename("openedx_authz.engine", "config/model.conf")
150+
policy_path = pkg_resources.resource_filename("openedx_authz.engine", "config/authz.policy")
151+
migrate_policy_between_enforcers(
152+
source_enforcer=casbin.Enforcer(model_path, policy_path),
153+
target_enforcer=global_enforcer,
154+
)
155+
156+
def test_authorized_for_specific_course(self, mock_flag):
157+
"""User authorized for specific course can access."""
158+
response = self.authorized_client.get(self.url)
159+
self.assertEqual(response.status_code, 200)
160+
161+
def test_unauthorized_for_specific_course(self, mock_flag):
162+
"""User without authorization for specific course cannot access."""
163+
response = self.unauthorized_client.get(self.url)
164+
self.assertEqual(response.status_code, 403)
165+
166+
def test_unauthorized_for_different_course(self, mock_flag):
167+
"""User authorized for one course cannot access another course."""
168+
other_course = self.store.create_course("OtherOrg", "OtherCourse", "Run", self.user.id)
169+
other_url = reverse(
170+
"cms.djangoapps.contentstore:v0:course_advanced_settings",
171+
kwargs={"course_id": other_course.id},
172+
)
173+
response = self.authorized_client.get(other_url)
174+
self.assertEqual(response.status_code, 403)
175+
176+
def test_staff_authorized_by_default(self, mock_flag):
177+
"""Staff users are authorized by default."""
178+
response = self.client.get(self.url)
179+
self.assertEqual(response.status_code, 200)
180+
181+
def test_superuser_authorized_by_default(self, mock_flag):
182+
"""Superusers are authorized by default."""
183+
superuser = UserFactory(is_superuser=True, is_staff=False)
184+
superuser_client = APIClient()
185+
superuser_client.force_authenticate(user=superuser)
186+
response = superuser_client.get(self.url)
187+
self.assertEqual(response.status_code, 200)
188+
189+
def test_patch_authorized_for_specific_course(self, mock_flag):
190+
"""User authorized for specific course can PATCH."""
191+
response = self.authorized_client.patch(
192+
self.url,
193+
{"display_name": {"value": "Test"}},
194+
content_type="application/json"
195+
)
196+
self.assertEqual(response.status_code, 200)
197+
198+
def test_patch_unauthorized_for_specific_course(self, mock_flag):
199+
"""User without authorization for specific course cannot PATCH."""
200+
response = self.unauthorized_client.patch(
201+
self.url,
202+
{"display_name": {"value": "Test"}},
203+
content_type="application/json"
204+
)
205+
self.assertEqual(response.status_code, 403)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
1313
from cms.djangoapps.contentstore.api.views.utils import get_bool_param
14-
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
14+
from common.djangoapps.student.auth import check_course_advanced_settings_access
1515
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
1616
from ..serializers import CourseAdvancedSettingsSerializer
1717
from ....views.course import update_course_advanced_settings
@@ -115,7 +115,7 @@ def get(self, request: Request, course_id: str):
115115
if not filter_query_data.is_valid():
116116
raise ValidationError(filter_query_data.errors)
117117
course_key = CourseKey.from_string(course_id)
118-
if not has_studio_read_access(request.user, course_key):
118+
if not check_course_advanced_settings_access(request.user, course_key, access_type='read'):
119119
self.permission_denied(request)
120120
course_block = modulestore().get_course(course_key)
121121
fetch_all = get_bool_param(request, 'fetch_all', True)
@@ -184,7 +184,7 @@ def patch(self, request: Request, course_id: str):
184184
along with all the course's settings similar to a ``GET`` request.
185185
"""
186186
course_key = CourseKey.from_string(course_id)
187-
if not has_studio_write_access(request.user, course_key):
187+
if not check_course_advanced_settings_access(request.user, course_key, access_type='write'):
188188
self.permission_denied(request)
189189
course_block = modulestore().get_course(course_key)
190190
updated_data = update_course_advanced_settings(course_block, request.data, request.user)

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,14 @@
1313
from cms.djangoapps.contentstore.views.course import get_course_and_check_access
1414
from cms.djangoapps.contentstore.utils import get_proctored_exam_settings_url
1515
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
16-
from common.djangoapps.student.auth import has_studio_advanced_settings_access
16+
from common.djangoapps.student.auth import check_course_advanced_settings_access
1717
from xmodule.course_block import (
1818
get_available_providers,
1919
get_requires_escalation_email_providers,
2020
) # lint-amnesty, pylint: disable=wrong-import-order
2121
from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled
2222
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
2323
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
24-
2524
from ..serializers import (
2625
LimitedProctoredExamSettingsSerializer,
2726
ProctoredExamConfigurationSerializer,
@@ -260,7 +259,9 @@ def get(self, request: Request, course_id: str) -> Response:
260259
```
261260
"""
262261
course_key = CourseKey.from_string(course_id)
263-
if not has_studio_advanced_settings_access(request.user):
262+
if not check_course_advanced_settings_access(
263+
request.user, course_key, access_type='feature_restricted'
264+
):
264265
self.permission_denied(request)
265266

266267
course_block = modulestore().get_course(course_key)

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase
1515
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
16+
from openedx.core import toggles as core_toggles
1617
from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA
1718
from xmodule.modulestore.django import (
1819
modulestore,
@@ -453,3 +454,39 @@ def test_disable_advanced_settings_feature(self, disable_advanced_settings):
453454
self.assertEqual(
454455
response.status_code, 403 if disable_advanced_settings else 200
455456
)
457+
458+
@patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=True)
459+
@patch('common.djangoapps.student.auth.authz_api.is_user_allowed')
460+
def test_authz_user_allowed(self, mock_is_user_allowed, mock_flag):
461+
"""User with authz permission can access proctoring errors."""
462+
mock_is_user_allowed.return_value = True
463+
response = self.non_staff_client.get(self.url)
464+
self.assertEqual(response.status_code, 200)
465+
mock_is_user_allowed.assert_called_once()
466+
467+
@patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=True)
468+
@patch('common.djangoapps.student.auth.authz_api.is_user_allowed')
469+
def test_authz_user_not_allowed(self, mock_is_user_allowed, mock_flag):
470+
"""User without authz permission cannot access proctoring errors."""
471+
mock_is_user_allowed.return_value = False
472+
response = self.non_staff_client.get(self.url)
473+
self.assertEqual(response.status_code, 403)
474+
mock_is_user_allowed.assert_called_once()
475+
476+
@patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=True)
477+
@patch('common.djangoapps.student.auth.authz_api.is_user_allowed')
478+
def test_authz_with_disable_advanced_settings_staff_allowed(self, mock_is_user_allowed, mock_flag):
479+
"""Staff user can access when DISABLE_ADVANCED_SETTINGS is enabled, bypassing authz."""
480+
with override_settings(FEATURES={"DISABLE_ADVANCED_SETTINGS": True}):
481+
response = self.client.get(self.url)
482+
self.assertEqual(response.status_code, 200)
483+
mock_is_user_allowed.assert_not_called()
484+
485+
@patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=True)
486+
@patch('common.djangoapps.student.auth.authz_api.is_user_allowed')
487+
def test_authz_with_disable_advanced_settings_non_staff_denied(self, mock_is_user_allowed, mock_flag):
488+
"""Non-staff user is denied when DISABLE_ADVANCED_SETTINGS is enabled, bypassing authz."""
489+
with override_settings(FEATURES={"DISABLE_ADVANCED_SETTINGS": True}):
490+
response = self.non_staff_client.get(self.url)
491+
self.assertEqual(response.status_code, 403)
492+
mock_is_user_allowed.assert_not_called()

common/djangoapps/student/auth.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
from django.conf import settings
1111
from django.core.exceptions import PermissionDenied
1212
from opaque_keys.edx.locator import LibraryLocator
13+
from openedx_authz import api as authz_api
14+
from openedx_authz.constants.permissions import COURSES_MANAGE_ADVANCED_SETTINGS
1315

16+
from openedx.core import toggles as core_toggles
1417
from common.djangoapps.student.roles import (
1518
CourseBetaTesterRole,
1619
CourseCreatorRole,
@@ -177,6 +180,49 @@ def has_studio_read_access(user, course_key):
177180
return bool(STUDIO_VIEW_CONTENT & get_user_permissions(user, course_key))
178181

179182

183+
def check_course_advanced_settings_access(user, course_key, access_type='read'):
184+
"""
185+
Check if user has access to advanced settings for a course.
186+
187+
Uses openedx-authz when AUTHZ_COURSE_AUTHORING_FLAG is enabled,
188+
otherwise falls back to legacy permission checks.
189+
190+
If the DISABLE_ADVANCED_SETTINGS feature flag is on, then authz will not be used for the
191+
permission check.
192+
193+
Args:
194+
user: Django user object
195+
course_key: CourseKey for the course
196+
access_type: Type of access to check. Options:
197+
- 'read': Check studio read access (default)
198+
- 'write': Check studio write access
199+
- 'feature_restricted': Check access based on the DISABLE_ADVANCED_SETTINGS feature
200+
201+
Returns:
202+
bool: True if user has permission, False otherwise
203+
"""
204+
if core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key):
205+
# For feature_restricted access type, check DISABLE_ADVANCED_SETTINGS feature
206+
if (
207+
access_type == 'feature_restricted'
208+
and settings.FEATURES.get('DISABLE_ADVANCED_SETTINGS', False)
209+
):
210+
# When feature is disabled, only staff/superuser can access (bypass authz)
211+
return user.is_staff or user.is_superuser
212+
# Otherwise check authz permission
213+
return authz_api.is_user_allowed(user.username, COURSES_MANAGE_ADVANCED_SETTINGS.identifier, str(course_key))
214+
215+
# Legacy permission checks
216+
if access_type == 'read':
217+
return has_studio_read_access(user, course_key)
218+
if access_type == 'feature_restricted':
219+
return has_studio_advanced_settings_access(user)
220+
if access_type == 'write':
221+
return has_studio_write_access(user, course_key)
222+
223+
raise ValueError(f"Invalid access_type: {access_type}")
224+
225+
180226
def is_content_creator(user, org):
181227
"""
182228
Check if the user has the role to create content.

requirements/edx/base.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,7 @@ openedx-atlas==0.7.0
804804
# enterprise-integrated-channels
805805
# openedx-authz
806806
# openedx-forum
807-
openedx-authz==0.20.0
807+
openedx-authz==0.22.0
808808
# via -r requirements/edx/kernel.in
809809
openedx-calc==4.0.3
810810
# via -r requirements/edx/kernel.in

requirements/edx/development.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1357,7 +1357,7 @@ openedx-atlas==0.7.0
13571357
# enterprise-integrated-channels
13581358
# openedx-authz
13591359
# openedx-forum
1360-
openedx-authz==0.20.0
1360+
openedx-authz==0.22.0
13611361
# via
13621362
# -r requirements/edx/doc.txt
13631363
# -r requirements/edx/testing.txt

requirements/edx/doc.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -980,7 +980,7 @@ openedx-atlas==0.7.0
980980
# enterprise-integrated-channels
981981
# openedx-authz
982982
# openedx-forum
983-
openedx-authz==0.20.0
983+
openedx-authz==0.22.0
984984
# via -r requirements/edx/base.txt
985985
openedx-calc==4.0.3
986986
# via -r requirements/edx/base.txt

requirements/edx/testing.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1030,7 +1030,7 @@ openedx-atlas==0.7.0
10301030
# enterprise-integrated-channels
10311031
# openedx-authz
10321032
# openedx-forum
1033-
openedx-authz==0.20.0
1033+
openedx-authz==0.22.0
10341034
# via -r requirements/edx/base.txt
10351035
openedx-calc==4.0.3
10361036
# via -r requirements/edx/base.txt

0 commit comments

Comments
 (0)