Skip to content

feat: grant staff access to superusers automatically#38848

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

feat: grant staff access to superusers automatically#38848
Abdul-Muqadim-Arbisoft wants to merge 2 commits 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added .save() to make sure the signal is triggered

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 thread common/djangoapps/student/signals/receivers.py
Comment thread common/djangoapps/student/migrations/0050_grant_staff_to_existing_superusers.py Outdated
Comment thread common/djangoapps/student/tests/test_receivers.py
Comment thread common/djangoapps/student/tests/test_receivers.py
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agree, for a normal (non-superuser) user the instance.is_superuser check is false, so it short-circuits right there. Just one in-memory attribute read, no DB hit, no query.
and also It's actually way lighter than the user_pre_save_callback we already have on this same pre_save signal, which pulls the old row from the DB via get_changed_fields_dict() on every single save.

- Reword the receiver, migration, and test docstrings to describe the
  behaviour as grant-only rather than keeping is_staff "in sync" with
  is_superuser, since demoting a superuser never revokes is_staff.
- Exercise the pre_save receiver with an explicit save() in the
  non-superuser test instead of relying only on factory creation.
- Add test_demotion_does_not_remove_staff to lock in the one-directional
  contract.
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