How do I prevent permission escalation in Django admin when granting "user change" permission?
they gain the ability to set the is_superuser flag on any account, including their own. (!!!)
Not only this, they also gain the ability to give themselves any permissions one-by-one, same effect...
I'm sure it involves subclassing django.contrib.auth.forms.UserChangeForm
Well, not necessarily. The form you see in the change page of django's admin is dynamically created by the admin application, and based on UserChangeForm
, but this class barely adds regex validation to the username
field.
and hooking it into my already-custom UserAdmin object...
A custom UserAdmin
is the way to go here. Basically, you want to change the fieldsets
property to something like that :
class MyUserAdmin(UserAdmin): fieldsets = ( (None, {'fields': ('username', 'password')}), (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), # Removing the permission part # (_('Permissions'), {'fields': ('is_staff', 'is_active', 'is_superuser', 'user_permissions')}), (_('Important dates'), {'fields': ('last_login', 'date_joined')}), # Keeping the group parts? Ok, but they shouldn't be able to define # their own groups, up to you... (_('Groups'), {'fields': ('groups',)}), )
But the problem here is that this restriction will apply to all users. If this is not what you want, you could for example override change_view
to behave differently depending on the permission of the users. Code snippet :
class MyUserAdmin(UserAdmin): staff_fieldsets = ( (None, {'fields': ('username', 'password')}), (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), # No permissions (_('Important dates'), {'fields': ('last_login', 'date_joined')}), (_('Groups'), {'fields': ('groups',)}), ) def change_view(self, request, *args, **kwargs): # for non-superuser if not request.user.is_superuser: try: self.fieldsets = self.staff_fieldsets response = super(MyUserAdmin, self).change_view(request, *args, **kwargs) finally: # Reset fieldsets to its original value self.fieldsets = UserAdmin.fieldsets return response else: return super(MyUserAdmin, self).change_view(request, *args, **kwargs)
The below part of the accepted answer has a race condition where if two staff users try to access the admin form at the same time, one of them may get the superuser form.
try: self.readonly_fields = self.staff_self_readonly_fields response = super(MyUserAdmin, self).change_view(request, object_id, form_url, extra_context, *args, **kwargs)finally: # Reset fieldsets to its original value self.fieldsets = UserAdmin.fieldsets
To avoid this race condition (and in my opinion improve the overall quality of the solution), we can override the get_fieldsets()
and get_readonly_fields()
methods directly:
class UserAdmin(BaseUserAdmin): staff_fieldsets = ( (None, {'fields': ('username')}), ('Personal info', {'fields': ('first_name', 'last_name', 'email')}), # No permissions ('Important dates', {'fields': ('last_login', 'date_joined')}), ) staff_readonly_fields = ('username', 'first_name', 'last_name', 'email', 'last_login', 'date_joined') def get_fieldsets(self, request, obj=None): if not request.user.is_superuser: return self.staff_fieldsets else: return super(UserAdmin, self).get_fieldsets(request, obj) def get_readonly_fields(self, request, obj=None): if not request.user.is_superuser: return self.staff_readonly_fields else: return super(UserAdmin, self).get_readonly_fields(request, obj)
Great thanks to Clément. What I came up with when doing the same for my site is that I needed additionally to make all fields readonly for users you other than self. So basing on Clément's answer I addeed readonly fields and password field hiding when viewing not self
class MyUserAdmin(UserAdmin): model = User staff_self_fieldsets = ( (None, {'fields': ('username', 'password')}), (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), # No permissions (_('Important dates'), {'fields': ('last_login', 'date_joined')}), ) staff_other_fieldsets = ( (None, {'fields': ('username', )}), (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), # No permissions (_('Important dates'), {'fields': ('last_login', 'date_joined')}), ) staff_self_readonly_fields = ('last_login', 'date_joined') def change_view(self, request, object_id, form_url='', extra_context=None, *args, **kwargs): # for non-superuser if not request.user.is_superuser: try: if int(object_id) != request.user.id: self.readonly_fields = User._meta.get_all_field_names() self.fieldsets = self.staff_other_fieldsets else: self.readonly_fields = self.staff_self_readonly_fields self.fieldsets = self.staff_self_fieldsets response = super(MyUserAdmin, self).change_view(request, object_id, form_url, extra_context, *args, **kwargs) except: logger.error('Admin change view error. Returned all readonly fields') self.fieldsets = self.staff_other_fieldsets self.readonly_fields = ('first_name', 'last_name', 'email', 'username', 'password', 'last_login', 'date_joined') response = super(MyUserAdmin, self).change_view(request, object_id, form_url, extra_context, *args, **kwargs) finally: # Reset fieldsets to its original value self.fieldsets = UserAdmin.fieldsets self.readonly_fields = UserAdmin.readonly_fields return response else: return super(MyUserAdmin, self).change_view(request, object_id, form_url, extra_context, *args, **kwargs)