Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6707925
chore: upgrade openedx-authz==1.23.0 (#39001)
BryanttV Oct 6, 2026
6aeb002
revert: Cannot rerun courses - authz role assignment expects rerun to…
efortish Aug 19, 2026
2ee9dd1
feat: split read vs write authz checks for advanced settings (#39007)
wgu-taylor-payne Aug 28, 2026
780ca83
feat: split read vs write authz checks for certificates (#39008)
wgu-taylor-payne Aug 28, 2026
facdfeb
fix: Course Auditor gets 403 navigating to a course unit (#38986)
efortish Aug 28, 2026
b219bf3
fix: RoleCache legacy compat layer ignores platform-wide glob role as…
efortish Sep 3, 2026
8182080
fix: platform-wide glob role assignments crash get_orgs_for_user/has_…
efortish Sep 8, 2026
47dd8eb
fix: course editor gets 403 copying a unit to the clipboard (#39075)
efortish Sep 10, 2026
6d1ad84
feat: split read vs write authz checks for library updates (#39009)
wgu-taylor-payne Sep 7, 2026
e2e81b8
fix: allow Course Admin/Staff/Editor to sync library updates into a c…
carlos-marquez-wgu Sep 10, 2026
8e3dab5
feat: authz edit permission gate for XBlock component card action men…
jacobo-dominguez-wgu Sep 4, 2026
325e25f
feat: make can_tag_object's course check authorization-service aware …
alezconsultant Sep 22, 2026
4f21a40
fix: gate xblock action menu on authz edit/manage_tags permissions (…
jacobo-dominguez-wgu Sep 30, 2026
422525c
feat: add authz validation to course optimizer tool (#39113)
dcoa Oct 1, 2026
fc4c79c
fix: align reindex link with authz permissions (#39144)
BryanttV Oct 1, 2026
a717165
feat: split read vs write authz checks for group configurations (#39010)
wgu-taylor-payne Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from django.urls import reverse
from milestones.tests.utils import MilestonesTestCaseMixin
from openedx_authz.api.users import assign_role_to_user_in_scope
from openedx_authz.constants.roles import COURSE_STAFF
from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_EDITOR, COURSE_STAFF
from openedx_authz.engine.enforcer import AuthzEnforcer
from openedx_authz.engine.utils import migrate_policy_between_enforcers
from rest_framework.test import APIClient
Expand Down Expand Up @@ -103,6 +103,7 @@ def test_disabled_fetch_all_query_param(self, setting, excluded_field):
assert excluded_field not in resp.data


@ddt.ddt
@patch.object(core_toggles.AUTHZ_COURSE_AUTHORING_FLAG, 'is_enabled', return_value=True)
class AdvancedSettingsAuthzTest(CourseTestCase):
"""
Expand Down Expand Up @@ -203,3 +204,37 @@ def test_patch_unauthorized_for_specific_course(self, mock_flag):
content_type="application/json"
)
self.assertEqual(response.status_code, 403) # noqa: PT009

@ddt.data(COURSE_EDITOR, COURSE_AUDITOR)
def test_view_only_role_can_read(self, role, mock_flag):
"""Course editor/auditor (view-only for advanced settings) can GET."""
view_only_user = UserFactory()
assign_role_to_user_in_scope(
view_only_user.username,
role.external_key,
str(self.course.id),
)

view_only_client = APIClient()
view_only_client.force_authenticate(user=view_only_user)
response = view_only_client.get(self.url)
assert response.status_code == 200

@ddt.data(COURSE_EDITOR, COURSE_AUDITOR)
def test_view_only_role_cannot_write(self, role, mock_flag):
"""Course editor/auditor cannot PATCH advanced settings (requires manage permission)."""
view_only_user = UserFactory()
assign_role_to_user_in_scope(
view_only_user.username,
role.external_key,
str(self.course.id),
)

view_only_client = APIClient()
view_only_client.force_authenticate(user=view_only_user)
response = view_only_client.patch(
self.url,
{"display_name": {"value": "Test"}},
content_type="application/json",
)
assert response.status_code == 403
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""
Integration tests verifying authz permissions for v0 course optimizer REST API views.
"""
from unittest.mock import patch

from django.urls import reverse
from openedx_authz.constants.roles import COURSE_AUDITOR, COURSE_EDITOR, COURSE_STAFF

from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin

VIEWS_MODULE = 'cms.djangoapps.contentstore.rest_api.v0.views.course_optimizer'


class CourseOptimizerV0AuthzTest(CourseAuthoringAuthzTestMixin, CourseTestCase):
"""
Integration tests for v0 course optimizer API authz permissions.

All endpoints require courses.edit_course_content.
"""

def setUp(self):
super().setUp()
self.link_check_url = reverse(
'cms.djangoapps.contentstore:v0:link_check',
kwargs={'course_id': self.course.id},
)
self.link_check_status_url = reverse(
'cms.djangoapps.contentstore:v0:link_check_status',
kwargs={'course_id': self.course.id},
)
self.rerun_link_update_url = reverse(
'cms.djangoapps.contentstore:v0:rerun_link_update',
kwargs={'course_id': self.course.id},
)
self.rerun_link_update_status_url = reverse(
'cms.djangoapps.contentstore:v0:rerun_link_update_status',
kwargs={'course_id': self.course.id},
)

prev_run_links_patcher = patch(
f'{VIEWS_MODULE}.enable_course_optimizer_check_prev_run_links',
return_value=True,
)
prev_run_links_patcher.start()
self.addCleanup(prev_run_links_patcher.stop)

# --- LinkCheckView (POST) ---

@patch(f'{VIEWS_MODULE}.check_broken_links')
def test_editor_can_start_link_check(self, mock_task):
"""Test that a course editor can start a link check."""
self.add_user_to_role_in_course(self.authorized_user, COURSE_EDITOR.external_key, self.course.id)
resp = self.authorized_client.post(self.link_check_url)
assert resp.status_code == 200
mock_task.delay.assert_called_once()

@patch(f'{VIEWS_MODULE}.check_broken_links')
def test_staff_can_start_link_check(self, mock_task):
"""Test that course staff can start a link check."""
self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id)
resp = self.authorized_client.post(self.link_check_url)
assert resp.status_code == 200
mock_task.delay.assert_called_once()

@patch(f'{VIEWS_MODULE}.check_broken_links')
def test_auditor_cannot_start_link_check(self, mock_task):
"""Test that a course auditor cannot start a link check."""
self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id)
resp = self.authorized_client.post(self.link_check_url)
assert resp.status_code == 403
mock_task.delay.assert_not_called()

@patch(f'{VIEWS_MODULE}.check_broken_links')
def test_unauthorized_cannot_start_link_check(self, mock_task):
"""Test that a user without a role in the course cannot start a link check."""
resp = self.unauthorized_client.post(self.link_check_url)
assert resp.status_code == 403
mock_task.delay.assert_not_called()

# --- LinkCheckStatusView (GET) ---

def test_editor_can_get_link_check_status(self):
"""Test that a course editor can read the link check status."""
self.add_user_to_role_in_course(self.authorized_user, COURSE_EDITOR.external_key, self.course.id)
resp = self.authorized_client.get(self.link_check_status_url)
assert resp.status_code == 200

def test_auditor_cannot_get_link_check_status(self):
"""Test that a course auditor cannot read the link check status."""
self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id)
resp = self.authorized_client.get(self.link_check_status_url)
assert resp.status_code == 403

def test_unauthorized_cannot_get_link_check_status(self):
"""Test that a user without a role in the course cannot read the link check status."""
resp = self.unauthorized_client.get(self.link_check_status_url)
assert resp.status_code == 403

# --- RerunLinkUpdateView (POST) ---

@patch(f'{VIEWS_MODULE}.update_course_rerun_links')
def test_editor_can_start_rerun_link_update(self, mock_task):
"""Test that a course editor can start a rerun link update."""
self.add_user_to_role_in_course(self.authorized_user, COURSE_EDITOR.external_key, self.course.id)
resp = self.authorized_client.post(self.rerun_link_update_url, data={'action': 'all'}, format='json')
assert resp.status_code == 200
mock_task.delay.assert_called_once()

@patch(f'{VIEWS_MODULE}.update_course_rerun_links')
def test_auditor_cannot_start_rerun_link_update(self, mock_task):
"""Test that a course auditor cannot start a rerun link update."""
self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id)
resp = self.authorized_client.post(self.rerun_link_update_url, data={'action': 'all'}, format='json')
assert resp.status_code == 403
mock_task.delay.assert_not_called()

@patch(f'{VIEWS_MODULE}.update_course_rerun_links')
def test_unauthorized_cannot_start_rerun_link_update(self, mock_task):
"""Test that a user without a role in the course cannot start a rerun link update."""
resp = self.unauthorized_client.post(self.rerun_link_update_url, data={'action': 'all'}, format='json')
assert resp.status_code == 403
mock_task.delay.assert_not_called()

# --- RerunLinkUpdateStatusView (GET) ---

def test_editor_can_get_rerun_link_update_status(self):
"""Test that a course editor can read the rerun link update status."""
self.add_user_to_role_in_course(self.authorized_user, COURSE_EDITOR.external_key, self.course.id)
resp = self.authorized_client.get(self.rerun_link_update_status_url)
assert resp.status_code == 200

def test_auditor_cannot_get_rerun_link_update_status(self):
"""Test that a course auditor cannot read the rerun link update status."""
self.add_user_to_role_in_course(self.authorized_user, COURSE_AUDITOR.external_key, self.course.id)
resp = self.authorized_client.get(self.rerun_link_update_status_url)
assert resp.status_code == 403

def test_unauthorized_cannot_get_rerun_link_update_status(self):
"""Test that a user without a role in the course cannot read the rerun link update status."""
resp = self.unauthorized_client.get(self.rerun_link_update_status_url)
assert resp.status_code == 403

# --- Superuser bypass ---

@patch(f'{VIEWS_MODULE}.check_broken_links')
def test_superuser_can_start_link_check(self, mock_task):
"""Test that a superuser bypasses the permission check and can start a link check."""
resp = self.super_client.post(self.link_check_url)
assert resp.status_code == 200
mock_task.delay.assert_called_once()
34 changes: 27 additions & 7 deletions cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import edx_api_doc_tools as apidocs
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from openedx_authz.constants.permissions import COURSES_EDIT_COURSE_CONTENT
from rest_framework import status
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -21,8 +22,9 @@
)
from cms.djangoapps.contentstore.tasks import check_broken_links, update_course_rerun_links
from cms.djangoapps.contentstore.toggles import enable_course_optimizer_check_prev_run_links
from common.djangoapps.student.auth import has_course_author_access, has_studio_read_access
from common.djangoapps.util.json_request import JsonResponse
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes


Expand Down Expand Up @@ -58,7 +60,12 @@ def post(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_EDIT_COURSE_CONTENT.identifier,
course_key,
LegacyAuthoringPermission.READ,
):
self.permission_denied(request)

check_broken_links.delay(request.user.id, course_id, request.LANGUAGE_CODE)
Expand Down Expand Up @@ -206,7 +213,12 @@ def get(self, request: Request, course_id: str):
}
"""
course_key = CourseKey.from_string(course_id)
if not has_course_author_access(request.user, course_key):
if not user_has_course_permission(
request.user,
COURSES_EDIT_COURSE_CONTENT.identifier,
course_key,
LegacyAuthoringPermission.WRITE,
):
self.permission_denied(request)

link_check_data = get_link_check_data(request, course_id)
Expand Down Expand Up @@ -280,8 +292,12 @@ def post(self, request: Request, course_id: str):
status=status.HTTP_404_NOT_FOUND,
)

# Check course author permissions
if not has_course_author_access(request.user, course_key):
if not user_has_course_permission(
request.user,
COURSES_EDIT_COURSE_CONTENT.identifier,
course_key,
LegacyAuthoringPermission.WRITE,
):
self.permission_denied(request)

if not enable_course_optimizer_check_prev_run_links(course_key):
Expand Down Expand Up @@ -401,8 +417,12 @@ def get(self, request: Request, course_id: str):
status=status.HTTP_404_NOT_FOUND,
)

# Check course author permissions
if not has_course_author_access(request.user, course_key):
if not user_has_course_permission(
request.user,
COURSES_EDIT_COURSE_CONTENT.identifier,
course_key,
LegacyAuthoringPermission.WRITE,
):
self.permission_denied(request)

if not enable_course_optimizer_check_prev_run_links(course_key):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ class CourseCertificatesSerializer(serializers.Serializer):
course_number = serializers.CharField(source="context_course.number")
course_title = serializers.CharField(source="context_course.display_name_with_default")
course_number_override = serializers.CharField(source="context_course.display_coursenumber")
can_manage = serializers.BooleanField(default=False, read_only=True)
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ class CourseGroupConfigurationsSerializer(serializers.Serializer):
)
should_show_enrollment_track = serializers.BooleanField()
should_show_experiment_groups = serializers.BooleanField()
can_manage = serializers.BooleanField(default=False, read_only=True)
17 changes: 13 additions & 4 deletions cms/djangoapps/contentstore/rest_api/v1/views/certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import edx_api_doc_tools as apidocs
from opaque_keys.edx.keys import CourseKey
from openedx_authz.constants.permissions import COURSES_MANAGE_CERTIFICATES
from openedx_authz.constants.permissions import COURSES_MANAGE_CERTIFICATES, COURSES_VIEW_CERTIFICATES
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
Expand Down Expand Up @@ -85,7 +85,8 @@ def get(self, request: Request, course_id: str):
"mfe_proctored_exam_settings_url": "",
"course_number": "DemoX",
"course_title": "Demonstration Course",
"course_number_override": "Course Number Display String"
"course_number_override": "Course Number Display String",
"can_manage": true
}
```
"""
Expand All @@ -94,14 +95,22 @@ def get(self, request: Request, course_id: str):

if not user_has_course_permission(
request.user,
COURSES_MANAGE_CERTIFICATES.identifier,
COURSES_VIEW_CERTIFICATES.identifier,
course_key,
LegacyAuthoringPermission.WRITE
LegacyAuthoringPermission.READ
):
self.permission_denied(request)

can_manage = user_has_course_permission(
request.user,
COURSES_MANAGE_CERTIFICATES.identifier,
course_key,
LegacyAuthoringPermission.WRITE
)

with store.bulk_operations(course_key):
course = modulestore().get_course(course_key)
certificates_context = get_certificates_context(course, request.user)
certificates_context['can_manage'] = can_manage
serializer = CourseCertificatesSerializer(certificates_context)
return Response(serializer.data)
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

import edx_api_doc_tools as apidocs
from opaque_keys.edx.keys import CourseKey
from openedx_authz.constants.permissions import COURSES_MANAGE_GROUP_CONFIGURATIONS
from openedx_authz.constants.permissions import COURSES_MANAGE_GROUP_CONFIGURATIONS, COURSES_VIEW_GROUP_CONFIGURATIONS
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView

from cms.djangoapps.contentstore.rest_api.v1.serializers import CourseGroupConfigurationsSerializer
from cms.djangoapps.contentstore.utils import get_group_configurations_context
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
from openedx.core.djangoapps.authz.decorators import authz_permission_required
from openedx.core.djangoapps.authz.decorators import authz_permission_required, 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

Expand All @@ -36,7 +36,7 @@ class CourseGroupConfigurationsView(DeveloperErrorViewMixin, APIView):
)
@verify_course_exists()
@authz_permission_required(
authz_permission=COURSES_MANAGE_GROUP_CONFIGURATIONS.identifier,
authz_permission=COURSES_VIEW_GROUP_CONFIGURATIONS.identifier,
legacy_permission=LegacyAuthoringPermission.READ
)
def get(self, request: Request, course_key: CourseKey):
Expand Down Expand Up @@ -144,5 +144,11 @@ def get(self, request: Request, course_key: CourseKey):
with store.bulk_operations(course_key):
course = modulestore().get_course(course_key)
group_configurations_context = get_group_configurations_context(course, store)
group_configurations_context['can_manage'] = user_has_course_permission(
request.user,
COURSES_MANAGE_GROUP_CONFIGURATIONS.identifier,
course_key,
LegacyAuthoringPermission.WRITE
)
serializer = CourseGroupConfigurationsSerializer(group_configurations_context)
return Response(serializer.data)
Loading
Loading