Skip to content

Commit 9f22cf0

Browse files
Alam-2Unaincy128
authored andcommitted
fix: restrict mute privileges to discussion moderators only (#224)
Fixed discussion moderation permissions to restrict delete, ban, mute, and restore operations to discussion moderators only. Course staff and course instructors were incorrectly granted full moderation privileges when mute feature was added - they are authoring roles and should not have discussion moderation access.
1 parent 459697a commit 9f22cf0

File tree

7 files changed

+85
-127
lines changed

7 files changed

+85
-127
lines changed

lms/djangoapps/discussion/rest_api/forum_mute_views.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from lms.djangoapps.discussion.rest_api.permissions import (
2222
CanMuteUsers
2323
)
24-
from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole, GlobalStaff
2524
from lms.djangoapps.discussion.rest_api.serializers import (
2625
MuteRequestSerializer,
2726
UnmuteRequestSerializer,
@@ -79,12 +78,10 @@ def _get_target_user_and_data(request_data, course_id):
7978

8079

8180
def _is_privileged_user(user, course_key):
82-
"""Check if user has privileged permissions."""
81+
"""Check if user has discussion moderation privileges."""
8382
return (
8483
has_discussion_privileges(user, course_key) or
85-
GlobalStaff().has_user(user) or
86-
CourseStaffRole(course_key).has_user(user) or
87-
CourseInstructorRole(course_key).has_user(user)
84+
user.is_staff
8885
)
8986

9087

lms/djangoapps/discussion/rest_api/permissions.py

Lines changed: 10 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,15 @@ def get_editable_fields(cc_content: Union[Thread, Comment], context: Dict) -> Se
105105
# Flagging/un-flagging is always available.
106106
return {"abuse_flagged"}
107107

108+
is_author = _is_author(cc_content, context)
109+
108110
# Map each field to the condition in which it's editable.
109111
editable_fields = {
110112
"abuse_flagged": True,
111113
"closed": is_thread and has_moderation_privilege,
112114
"close_reason_code": is_thread and has_moderation_privilege,
113115
"pinned": is_thread and (has_moderation_privilege or is_staff_or_admin),
114-
"muted": is_thread and (has_moderation_privilege or is_staff_or_admin),
116+
"muted": is_thread and has_moderation_privilege and not is_author,
115117
"read": is_thread,
116118
}
117119
if is_thread:
@@ -121,7 +123,6 @@ def get_editable_fields(cc_content: Union[Thread, Comment], context: Dict) -> Se
121123
# Return only editable fields
122124
return _filter_fields(editable_fields)
123125

124-
is_author = _is_author(cc_content, context)
125126
editable_fields.update({
126127
"voted": has_moderation_privilege or not is_author or is_staff_or_admin,
127128
"raw_body": has_moderation_privilege or is_author,
@@ -193,13 +194,6 @@ def can_take_action_on_spam(user, course_id):
193194
"""
194195
Returns if the user has access to take action against forum spam posts.
195196
196-
Grants access to:
197-
- Global Staff (user.is_staff or GlobalStaff role)
198-
- Course Staff for the specific course
199-
- Course Instructors for the specific course
200-
- Forum Moderators for the specific course
201-
- Forum Administrators for the specific course
202-
203197
Parameters:
204198
user: User object
205199
course_id: CourseKey or string of course_id
@@ -214,14 +208,6 @@ def can_take_action_on_spam(user, course_id):
214208
if isinstance(course_id, str):
215209
course_id = CourseKey.from_string(course_id)
216210

217-
# Check if user is Course Staff or Instructor for this specific course
218-
if CourseStaffRole(course_id).has_user(user):
219-
return True
220-
221-
if CourseInstructorRole(course_id).has_user(user):
222-
return True
223-
224-
# Check forum moderator/administrator roles for this specific course
225211
user_roles = set(
226212
Role.objects.filter(
227213
users=user,
@@ -236,18 +222,6 @@ def can_take_action_on_spam(user, course_id):
236222
class IsAllowedToBulkDelete(permissions.BasePermission):
237223
"""
238224
Permission that checks if the user is allowed to perform bulk delete and ban operations.
239-
240-
Grants access to:
241-
- Global Staff (superusers)
242-
- Course Staff
243-
- Course Instructors
244-
- Forum Moderators
245-
- Forum Administrators
246-
247-
Denies access to:
248-
- Unauthenticated users
249-
- Regular students
250-
- Community TAs (they can moderate individual posts but not bulk delete)
251225
"""
252226

253227
def has_permission(self, request, view):
@@ -279,45 +253,39 @@ def has_permission(self, request, view):
279253
class CanMuteUsers(permissions.BasePermission):
280254
"""
281255
Permission class for all mute/unmute operations.
282-
Handles muting, unmuting, and basic course access permissions.
283256
"""
284257

285258
@staticmethod
286259
def _is_privileged_user(user, course_id):
287260
"""
288-
Check if user has discussion privileges.
261+
Check if user has discussion moderation privileges.
289262
"""
290263
return (
291264
has_discussion_privileges(user, course_id) or
292-
GlobalStaff().has_user(user) or
293-
CourseStaffRole(course_id).has_user(user) or
294-
CourseInstructorRole(course_id).has_user(user)
265+
GlobalStaff().has_user(user)
295266
)
296267

297268
def has_permission(self, request, view):
298-
"""Check basic mute permissions - same logic as IsStaffOrCourseTeamOrEnrolled"""
269+
"""
270+
Check if user has permission to access mute/unmute endpoints.
271+
"""
299272
if not request.user.is_authenticated:
300273
return False
301274

302-
# Get course_id from URL kwargs first (where it's actually passed)
303275
course_id = view.kwargs.get("course_id")
304276
if not course_id:
305277
return False
306278

307-
# Convert course_id to CourseKey if it's a string
308279
if isinstance(course_id, str):
309280
try:
310281
course_id = CourseKey.from_string(course_id)
311282
except InvalidKeyError:
312283
return False
313284

314-
# Use same permission logic as IsStaffOrCourseTeamOrEnrolled
315285
return (
316286
GlobalStaff().has_user(request.user) or
317-
CourseStaffRole(course_id).has_user(request.user) or
318-
CourseInstructorRole(course_id).has_user(request.user) or
319-
CourseEnrollment.is_enrolled(request.user, course_id) or
320-
has_discussion_privileges(request.user, course_id)
287+
has_discussion_privileges(request.user, course_id) or
288+
CourseEnrollment.is_enrolled(request.user, course_id)
321289
)
322290

323291
@staticmethod
@@ -415,19 +383,6 @@ def can_unmute(requesting_user, target_user, course_id, scope='personal'):
415383
class IsAllowedToRestore(permissions.BasePermission):
416384
"""
417385
Permission that checks if the user has privileges to restore individual deleted content.
418-
419-
This permission is intentionally more permissive than IsAllowedToBulkDelete because:
420-
- Restoring individual content is a less risky operation than bulk deletion
421-
- Users who can see deleted content should be able to restore it
422-
- Course-level moderation staff need this capability for day-to-day moderation
423-
424-
Allowed users (course-level permissions):
425-
- Global staff (platform-wide)
426-
- Course instructors
427-
- Course staff
428-
- Discussion moderators (course-specific)
429-
- Discussion community TAs (course-specific)
430-
- Discussion administrators (course-specific)
431386
"""
432387

433388
def has_permission(self, request, view):
@@ -449,12 +404,6 @@ def has_permission(self, request, view):
449404
except InvalidKeyError:
450405
return False
451406

452-
# Check if user is course staff or instructor
453-
if CourseStaffRole(course_key).has_user(request.user) or \
454-
CourseInstructorRole(course_key).has_user(request.user):
455-
return True
456-
457-
# Check if user has discussion privileges (moderator, community TA, administrator)
458407
if has_discussion_privileges(request.user, course_key):
459408
return True
460409

lms/djangoapps/discussion/rest_api/serializers.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def get_context(course, request, thread=None):
8989
cc_requester["course_id"] = course.id
9090
course_discussion_settings = CourseDiscussionSettings.get(course.id)
9191
is_global_staff = GlobalStaff().has_user(requester)
92-
all_privileged_ids = set(moderator_user_ids) | set(ta_user_ids) | set(course_staff_user_ids)
92+
all_privileged_ids = set(moderator_user_ids) | set(ta_user_ids)
9393
has_moderation_privilege = requester.id in all_privileged_ids or is_global_staff
9494
return {
9595
"course": course,
@@ -242,14 +242,22 @@ def _validate_non_updatable(self, value):
242242

243243
def _is_user_privileged(self, user_id):
244244
"""
245-
Returns a boolean indicating whether the given user_id identifies a
246-
privileged user.
245+
Returns a boolean indicating whether the given user_id identifies a privileged user.
247246
"""
248-
return (
247+
is_privileged = (
249248
user_id in self.context["moderator_user_ids"]
250249
or user_id in self.context["ta_user_ids"]
251250
)
252251

252+
if not is_privileged:
253+
try:
254+
user = User.objects.get(id=user_id)
255+
is_privileged = GlobalStaff().has_user(user)
256+
except User.DoesNotExist:
257+
pass
258+
259+
return is_privileged
260+
253261
def _is_anonymous(self, obj):
254262
"""
255263
Returns a boolean indicating whether the content should be anonymous to
@@ -271,16 +279,22 @@ def get_author(self, obj):
271279

272280
def _get_user_label(self, user_id):
273281
"""
274-
Returns the role label (i.e. "Staff", "Moderator" or "Community TA") for the user
275-
with the given id.
282+
Returns the role label for the user with the given id.
276283
"""
277-
is_staff = user_id in self.context["course_staff_user_ids"]
278284
is_moderator = user_id in self.context["moderator_user_ids"]
279285
is_ta = user_id in self.context["ta_user_ids"]
280286

287+
is_global_staff = False
288+
if not (is_moderator or is_ta):
289+
try:
290+
user = User.objects.get(id=user_id)
291+
is_global_staff = GlobalStaff().has_user(user)
292+
except User.DoesNotExist:
293+
pass
294+
281295
return (
282296
"Staff"
283-
if is_staff
297+
if is_global_staff
284298
else "Moderator" if is_moderator else "Community TA" if is_ta else None
285299
)
286300

lms/djangoapps/discussion/rest_api/tests/test_api_v2.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,6 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit):
378378
"closed",
379379
"copy_link",
380380
"following",
381-
"muted",
382381
"pinned",
383382
"raw_body",
384383
"read",

lms/djangoapps/discussion/rest_api/tests/test_moderation_permissions.py

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,17 @@ def test_global_staff_role_has_permission(self):
3535
GlobalStaff().add_users(user)
3636
self.assertTrue(can_take_action_on_spam(user, self.course_key))
3737

38-
def test_course_staff_has_permission(self):
39-
"""Course staff should have permission for their course."""
38+
def test_course_staff_no_permission(self):
39+
"""Course staff should NOT have permission (authoring role only)."""
4040
user = UserFactory.create()
4141
CourseStaffRole(self.course_key).add_users(user)
42-
self.assertTrue(can_take_action_on_spam(user, self.course_key))
42+
self.assertFalse(can_take_action_on_spam(user, self.course_key))
4343

44-
def test_course_instructor_has_permission(self):
45-
"""Course instructors should have permission for their course."""
44+
def test_course_instructor_no_permission(self):
45+
"""Course instructors should NOT have permission (authoring role only)."""
4646
user = UserFactory.create()
4747
CourseInstructorRole(self.course_key).add_users(user)
48-
self.assertTrue(can_take_action_on_spam(user, self.course_key))
48+
self.assertFalse(can_take_action_on_spam(user, self.course_key))
4949

5050
def test_forum_moderator_has_permission(self):
5151
"""Forum moderators should have permission for their course."""
@@ -74,16 +74,18 @@ def test_community_ta_no_permission(self):
7474
self.assertFalse(can_take_action_on_spam(user, self.course_key))
7575

7676
def test_staff_different_course_no_permission(self):
77-
"""Staff from a different course should not have permission."""
77+
"""Discussion moderators from a different course should not have permission."""
7878
other_course = CourseFactory.create(org='OtherX', number='CS201', run='2024')
7979
user = UserFactory.create()
80-
CourseStaffRole(other_course.id).add_users(user)
80+
role = Role.objects.create(name='Moderator', course_id=other_course.id)
81+
role.users.add(user)
8182
self.assertFalse(can_take_action_on_spam(user, self.course_key))
8283

8384
def test_accepts_string_course_id(self):
8485
"""Function should accept string course_id and convert it."""
8586
user = UserFactory.create()
86-
CourseStaffRole(self.course_key).add_users(user)
87+
role = Role.objects.create(name='Moderator', course_id=self.course_key)
88+
role.users.add(user)
8789
self.assertTrue(can_take_action_on_spam(user, str(self.course_key)))
8890

8991

@@ -130,23 +132,23 @@ def test_global_staff_with_course_id_in_data(self):
130132

131133
self.assertTrue(self.permission.has_permission(request, view))
132134

133-
def test_course_staff_with_course_id_in_data(self):
134-
"""Course staff should have permission when course_id is in request data."""
135+
def test_course_staff_denied(self):
136+
"""Course staff should NOT have permission (authoring role only)."""
135137
user = UserFactory.create()
136138
CourseStaffRole(self.course.id).add_users(user)
137139
request = self._create_request_with_data(user, self.course_key)
138140
view = self._create_view_with_kwargs()
139141

140-
self.assertTrue(self.permission.has_permission(request, view))
142+
self.assertFalse(self.permission.has_permission(request, view))
141143

142-
def test_course_instructor_with_course_id_in_data(self):
143-
"""Course instructors should have permission when course_id is in request data."""
144+
def test_course_instructor_denied(self):
145+
"""Course instructors should NOT have permission (authoring role only)."""
144146
user = UserFactory.create()
145147
CourseInstructorRole(self.course.id).add_users(user)
146148
request = self._create_request_with_data(user, self.course_key)
147149
view = self._create_view_with_kwargs()
148150

149-
self.assertTrue(self.permission.has_permission(request, view))
151+
self.assertFalse(self.permission.has_permission(request, view))
150152

151153
def test_forum_moderator_with_course_id_in_data(self):
152154
"""Forum moderators should have permission when course_id is in request data."""
@@ -169,7 +171,8 @@ def test_regular_student_denied(self):
169171
def test_course_id_in_url_kwargs(self):
170172
"""Permission should work when course_id is in URL kwargs."""
171173
user = UserFactory.create()
172-
CourseStaffRole(self.course.id).add_users(user)
174+
role = Role.objects.create(name='Moderator', course_id=self.course.id)
175+
role.users.add(user)
173176
request = self.factory.get('/api/discussion/v1/moderation/banned-users/')
174177
request.user = user
175178
request.data = {}
@@ -193,10 +196,11 @@ def test_no_course_id_only_global_staff_allowed(self):
193196
self.assertFalse(self.permission.has_permission(request, view))
194197

195198
def test_staff_different_course_denied(self):
196-
"""Staff from different course should be denied."""
199+
"""Discussion moderators from different course should be denied."""
197200
other_course = CourseFactory.create(org='OtherX', number='CS201', run='2024')
198201
user = UserFactory.create()
199-
CourseStaffRole(other_course.id).add_users(user)
202+
role = Role.objects.create(name='Moderator', course_id=other_course.id)
203+
role.users.add(user)
200204
request = self._create_request_with_data(user, self.course_key)
201205
view = self._create_view_with_kwargs()
202206

0 commit comments

Comments
 (0)