Skip to content

feat: grant staff access to superusers automatically#38848

Open
Abdul-Muqadim-Arbisoft wants to merge 1 commit into
openedx:masterfrom
edly-io:feat/superuser-inherits-staff
Open

feat: grant staff access to superusers automatically#38848
Abdul-Muqadim-Arbisoft wants to merge 1 commit into
openedx:masterfrom
edly-io:feat/superuser-inherits-staff

Conversation

@Abdul-Muqadim-Arbisoft

Copy link
Copy Markdown
Contributor

Description

Django's is_superuser and is_staff are independent flags. A superuser bypasses Django's permission checks, but it is is_staff that actually gates the Django admin and a number of staff-only Studio/LMS views. As a result, a superuser can end up unable to reach the admin or those views until someone also sets is_staff, which is confusing and offers no real protection, since a superuser can grant itself is_staff at any time.

This change makes every superuser a staff user automatically:

  • pre_save signal (common/djangoapps/student/signals/receivers.py), grant_staff_access_to_superusers sets is_staff = True on any user being saved with is_superuser = True. It runs before the DB write, so there is no
    extra query and no signal recursion. Fixture (raw) loads are skipped.
  • Data migration (student/migrations/0050_grant_staff_to_existing_superusers.py), backfills existing superusers (is_superuser=True, is_staff=False → is_staff=True) in a single bulk update, since the signal only affects future saves. The
    reverse operation is intentionally a no-op.

Behavior is one-directional by design: it grants staff to superusers but does not remove staff when a user stops being a superuser.

Supporting information

Forum discussion: https://discuss.openedx.org/t/shouldnt-superuser-automatically-inherit-staff-access-in-open-edx/18657

Feanil suggested "a small signal handler or something that would just grant all superusers the staff role as well", this implements exactly that, plus a one-time backfill for existing installs.

Testing instructions

  1. Create a superuser via the Django admin or shell with is_superuser=True and is_staff=False, save it, and confirm is_staff is now True.
  2. Promote an existing non-staff user to superuser and confirm they gain staff access.
  3. Confirm a regular (non-superuser) user's is_staff value is left untouched.
  4. On an existing install with a non-staff superuser, run migrations and confirm that superuser becomes staff.

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
user.refresh_from_db()
assert user.is_staff is True

def test_non_superuser_is_not_forced_to_staff(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You never call .save() or trigger the signal here

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes Django superusers automatically gain staff access (is_staff=True) to avoid the confusing (and security-meaningless) state where a superuser cannot access Django admin or other staff-gated views until is_staff is manually set.

Changes:

  • Add a pre_save signal receiver that forces is_staff=True whenever is_superuser=True (skipping raw fixture loads).
  • Add a one-time data migration that bulk-updates existing is_superuser=True, is_staff=False users to is_staff=True.
  • Add unit tests covering the receiver’s behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
common/djangoapps/student/tests/test_receivers.py Adds tests for the new pre_save receiver that grants staff to superusers.
common/djangoapps/student/signals/receivers.py Adds the pre_save receiver that grants is_staff to superusers on save.
common/djangoapps/student/migrations/0050_grant_staff_to_existing_superusers.py Backfills is_staff=True for existing superusers via a bulk update.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +71 to +73
"""
Keep Django's ``is_staff`` flag in sync with ``is_superuser``.

Comment on lines +4 to +7
A new ``pre_save`` signal keeps ``is_staff`` in sync with ``is_superuser`` for
any user saved from now on, but that does not touch superusers that already
exist in the database. This one-time data migration grants staff access to
those accounts so the behaviour is consistent for old and new superusers alike.
Comment on lines +100 to +103
"""
Tests for the ``grant_staff_access_to_superusers`` pre_save receiver, which
keeps ``is_staff`` in sync with ``is_superuser``.
"""
Comment on lines +131 to +136
def test_existing_staff_superuser_unchanged(self):
""" A superuser that is already staff stays staff. """
user = UserFactory(is_superuser=True, is_staff=True)
user.save()
user.refresh_from_db()
assert user.is_staff is True
if kwargs.get('raw'):
return

if instance.is_superuser and not instance.is_staff:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic will run on every save() call, so with a high volume of user updates it can introduce unnecessary overhead. It’s not a major issue, but worth noting as a potential optimization point.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants