Skip to content

Commit 8eccf6e

Browse files
Abdul MuqadimAbdul Muqadim
authored andcommitted
feat: grant staff access to superusers automatically
Django's is_superuser and is_staff are independent flags, so a superuser does not get the is_staff access that gates the Django admin and various staff-only Studio/LMS views. Since a superuser can grant itself is_staff at any time, that split offers no protection and is only surprising. Add a pre_save signal on the User model that marks any superuser as staff, and a data migration to backfill existing superusers. Discussion: https://discuss.openedx.org/t/shouldnt-superuser-automatically-inherit-staff-access-in-open-edx/18657
1 parent c097370 commit 8eccf6e

3 files changed

Lines changed: 103 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
Backfill ``is_staff`` for existing superusers.
3+
4+
A new ``pre_save`` signal keeps ``is_staff`` in sync with ``is_superuser`` for
5+
any user saved from now on, but that does not touch superusers that already
6+
exist in the database. This one-time data migration grants staff access to
7+
those accounts so the behaviour is consistent for old and new superusers alike.
8+
"""
9+
10+
from django.conf import settings
11+
from django.db import migrations
12+
13+
14+
def grant_staff_to_superusers(apps, schema_editor):
15+
"""Mark every existing superuser as staff."""
16+
User = apps.get_model(*settings.AUTH_USER_MODEL.split('.', 1))
17+
User.objects.filter(is_superuser=True, is_staff=False).update(is_staff=True)
18+
19+
20+
def noop(apps, schema_editor):
21+
"""
22+
Reverse is a no-op.
23+
24+
We cannot know which superusers were intentionally non-staff before this
25+
migration ran, so removing ``is_staff`` on reverse would be unsafe.
26+
"""
27+
28+
29+
class Migration(migrations.Migration):
30+
31+
dependencies = [
32+
('student', '0049_manualenrollmentaudit_statetransition_typo'),
33+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
34+
]
35+
36+
operations = [
37+
migrations.RunPython(grant_staff_to_superusers, noop),
38+
]

common/djangoapps/student/signals/receivers.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@ def on_user_updated(sender, instance, **kwargs):
6666
)
6767

6868

69+
@receiver(pre_save, sender=get_user_model())
70+
def grant_staff_access_to_superusers(sender, instance, **kwargs):
71+
"""
72+
Keep Django's ``is_staff`` flag in sync with ``is_superuser``.
73+
74+
A superuser bypasses Django's permission checks and can grant itself the
75+
``is_staff`` flag at any time, so the state where a user is a superuser but
76+
not staff is confusing and offers no real protection: it just hides the
77+
Django admin and various staff-only Studio/LMS views from that account for
78+
no good reason. To avoid that surprise, ensure every superuser is also
79+
marked as staff.
80+
81+
See:
82+
https://discuss.openedx.org/t/shouldnt-superuser-automatically-inherit-staff-access-in-open-edx/18657
83+
"""
84+
# Don't interfere with objects being loaded verbatim from a fixture.
85+
if kwargs.get('raw'):
86+
return
87+
88+
if instance.is_superuser and not instance.is_staff:
89+
instance.is_staff = True
90+
91+
6992
@receiver(post_save, sender=CourseEnrollment)
7093
def create_course_enrollment_celebration(sender, instance, created, **kwargs):
7194
"""

common/djangoapps/student/tests/test_receivers.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from unittest import skipUnless
44
from unittest.mock import patch
55

6+
from django.contrib.auth import get_user_model
7+
from django.test import TestCase
68
from edx_toggles.toggles.testutils import override_waffle_flag
79

810
from common.djangoapps.student.models import CourseEnrollmentCelebration, PendingNameChange, UserProfile
@@ -92,3 +94,43 @@ def test_listen_for_user_email_changed(self, mock_get_email_client):
9294

9395
assert mock_get_email_client.called
9496
assert request.session.get('email', None) == user.email
97+
98+
99+
class GrantStaffToSuperusersTest(TestCase):
100+
"""
101+
Tests for the ``grant_staff_access_to_superusers`` pre_save receiver, which
102+
keeps ``is_staff`` in sync with ``is_superuser``.
103+
"""
104+
105+
def test_new_superuser_is_marked_staff(self):
106+
""" A user created as a superuser is automatically marked as staff. """
107+
user = get_user_model().objects.create(
108+
username='super', email='super@test.com', is_superuser=True,
109+
)
110+
user.refresh_from_db()
111+
assert user.is_superuser is True
112+
assert user.is_staff is True
113+
114+
def test_promoting_to_superuser_grants_staff(self):
115+
""" Promoting an existing non-staff user to superuser grants staff access. """
116+
user = UserFactory(is_superuser=False, is_staff=False)
117+
assert user.is_staff is False
118+
119+
user.is_superuser = True
120+
user.save()
121+
122+
user.refresh_from_db()
123+
assert user.is_staff is True
124+
125+
def test_non_superuser_is_not_forced_to_staff(self):
126+
""" A regular, non-superuser user keeps its (non-)staff status untouched. """
127+
user = UserFactory(is_superuser=False, is_staff=False)
128+
user.refresh_from_db()
129+
assert user.is_staff is False
130+
131+
def test_existing_staff_superuser_unchanged(self):
132+
""" A superuser that is already staff stays staff. """
133+
user = UserFactory(is_superuser=True, is_staff=True)
134+
user.save()
135+
user.refresh_from_db()
136+
assert user.is_staff is True

0 commit comments

Comments
 (0)