add support for fine-grained field permissions for user model
diff --git a/planetstack/core/admin.py b/planetstack/core/admin.py
index 94e8453..f4d6f8f 100644
--- a/planetstack/core/admin.py
+++ b/planetstack/core/admin.py
@@ -8,7 +8,7 @@
from django.utils.safestring import mark_safe
from django.contrib.auth.admin import UserAdmin
from django.contrib.admin.widgets import FilteredSelectMultiple
-from django.contrib.auth.forms import ReadOnlyPasswordHashField
+from django.contrib.auth.forms import ReadOnlyPasswordHashField, AdminPasswordChangeForm
from django.contrib.auth.signals import user_logged_in
from django.utils import timezone
from django.contrib.contenttypes import generic
@@ -16,6 +16,14 @@
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse, NoReverseMatch
+# this block of stuff is needed for UserAdmin
+from django.db import transaction
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_protect
+from django.views.decorators.debug import sensitive_post_parameters
+csrf_protect_m = method_decorator(csrf_protect)
+sensitive_post_parameters_m = method_decorator(sensitive_post_parameters())
+
import django_evolution
def backend_icon(obj): # backend_status, enacted, updated):
@@ -43,7 +51,8 @@
value = ''
return mark_safe(str(value) + super(PlainTextWidget, self).render(name, value, attrs))
-class ReadOnlyAwareAdmin(admin.ModelAdmin):
+class PermissionCheckingAdmin(admin.ModelAdmin):
+ # call save_by_user and delete_by_user instead of save and delete
def has_add_permission(self, request, obj=None):
return (not self.__user_is_readonly(request))
@@ -53,13 +62,42 @@
def save_model(self, request, obj, form, change):
if self.__user_is_readonly(request):
+ # this 'if' might be redundant if save_by_user is implemented right
raise PermissionDenied
- #pass
- else:
- return super(ReadOnlyAwareAdmin, self).save_model(request, obj, form, change)
+
+ obj.caller = request.user
+ # update openstack connection to use this site/tenant
+ obj.save_by_user(request.user)
+
+ def delete_model(self, request, obj):
+ obj.delete_by_user(request.user)
+
+ def save_formset(self, request, form, formset, change):
+ instances = formset.save(commit=False)
+ for instance in instances:
+ instance.save_by_user(request.user)
+
+ # BUG in django 1.7? Objects are not deleted by formset.save if
+ # commit is False. So let's delete them ourselves.
+ #
+ # code from forms/models.py save_existing_objects()
+ try:
+ forms_to_delete = formset.deleted_forms
+ except AttributeError:
+ forms_to_delete = []
+ if formset.initial_forms:
+ for form in formset.initial_forms:
+ obj = form.instance
+ if form in forms_to_delete:
+ if obj.pk is None:
+ continue
+ formset.deleted_objects.append(obj)
+ obj.delete()
+
+ formset.save_m2m()
def get_actions(self,request):
- actions = super(ReadOnlyAwareAdmin,self).get_actions(request)
+ actions = super(PermissionCheckingAdmin,self).get_actions(request)
if self.__user_is_readonly(request):
if 'delete_selected' in actions:
@@ -85,13 +123,13 @@
self.inlines = self.inlines_save
try:
- return super(ReadOnlyAwareAdmin, self).change_view(request, object_id, extra_context=extra_context)
+ return super(PermissionCheckingAdmin, self).change_view(request, object_id, extra_context=extra_context)
except PermissionDenied:
pass
if request.method == 'POST':
raise PermissionDenied
request.readonly = True
- return super(ReadOnlyAwareAdmin, self).change_view(request, object_id, extra_context=extra_context)
+ return super(PermissionCheckingAdmin, self).change_view(request, object_id, extra_context=extra_context)
def __user_is_readonly(self, request):
return request.user.isReadOnlyUser()
@@ -103,6 +141,11 @@
return mark_safe(backend_icon(obj))
backend_status_icon.short_description = ""
+class ReadOnlyAwareAdmin(PermissionCheckingAdmin):
+ pass
+
+class PlanetStackBaseAdmin(ReadOnlyAwareAdmin):
+ save_on_top = False
class SingletonAdmin (ReadOnlyAwareAdmin):
def has_add_permission(self, request):
@@ -115,7 +158,6 @@
else:
return True
-
class PlStackTabularInline(admin.TabularInline):
def __init__(self, *args, **kwargs):
super(PlStackTabularInline, self).__init__(*args, **kwargs)
@@ -404,41 +446,6 @@
fields = ['backend_status_icon', 'image', 'deployment', 'glance_image_id']
readonly_fields = ['backend_status_icon', 'glance_image_id']
-class PlanetStackBaseAdmin(ReadOnlyAwareAdmin):
- save_on_top = False
-
- def save_model(self, request, obj, form, change):
- obj.caller = request.user
- # update openstack connection to use this site/tenant
- obj.save_by_user(request.user)
-
- def delete_model(self, request, obj):
- obj.delete_by_user(request.user)
-
- def save_formset(self, request, form, formset, change):
- instances = formset.save(commit=False)
- for instance in instances:
- instance.save_by_user(request.user)
-
- # BUG in django 1.7? Objects are not deleted by formset.save if
- # commit is False. So let's delete them ourselves.
- #
- # code from forms/models.py save_existing_objects()
- try:
- forms_to_delete = formset.deleted_forms
- except AttributeError:
- forms_to_delete = []
- if formset.initial_forms:
- for form in formset.initial_forms:
- obj = form.instance
- if form in forms_to_delete:
- if obj.pk is None:
- continue
- formset.deleted_objects.append(obj)
- obj.delete()
-
- formset.save_m2m()
-
class SliceRoleAdmin(PlanetStackBaseAdmin):
model = SliceRole
pass
@@ -1004,13 +1011,17 @@
suit_classes = 'suit-tab suit-tab-dashboards'
fields = ['user', 'dashboardView', 'order']
-class UserAdmin(UserAdmin):
+class UserAdmin(PlanetStackBaseAdmin):
class Meta:
app_label = "core"
+ add_form_template = 'admin/auth/user/add_form.html'
+ change_user_password_template = None
+
# The forms to add and change user instances
form = UserChangeForm
add_form = UserCreationForm
+ change_password_form = AdminPasswordChangeForm
# The fields to be used in displaying the User model.
# These override the definitions on the base UserAdmin
@@ -1019,7 +1030,7 @@
list_filter = ('site',)
inlines = [SlicePrivilegeInline,SitePrivilegeInline,DeploymentPrivilegeInline,UserDashboardViewInline]
- fieldListLoginDetails = ['email','site','password','is_active','is_readonly','is_admin','public_key']
+ fieldListLoginDetails = ['backend_status_text', 'email','site','password','is_active','is_readonly','is_admin','public_key']
fieldListContactInfo = ['firstname','lastname','phone','timezone']
fieldsets = (
@@ -1054,61 +1065,135 @@
return super(UserAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
- def has_add_permission(self, request, obj=None):
- return (not self.__user_is_readonly(request))
-
- def has_delete_permission(self, request, obj=None):
- return (not self.__user_is_readonly(request))
-
- def get_actions(self,request):
- actions = super(UserAdmin,self).get_actions(request)
-
- if self.__user_is_readonly(request):
- if 'delete_selected' in actions:
- del actions['delete_selected']
-
- return actions
-
- def change_view(self,request,object_id, extra_context=None):
-
- if self.__user_is_readonly(request):
- if not hasattr(self, "readonly_save"):
- # save the original readonly fields
- self.readonly_save = self.readonly_fields
- self.inlines_save = self.inlines
- if hasattr(self, "user_readonly_fields"):
- self.readonly_fields=self.user_readonly_fields
- if hasattr(self, "user_readonly_inlines"):
- self.inlines = self.user_readonly_inlines
- else:
- if hasattr(self, "readonly_save"):
- # restore the original readonly fields
- self.readonly_fields = self.readonly_save
- self.inlines = self.inlines_save
-
- try:
- return super(UserAdmin, self).change_view(request, object_id, extra_context=extra_context)
- except PermissionDenied:
- pass
- if request.method == 'POST':
- raise PermissionDenied
- request.readonly = True
- return super(UserAdmin, self).change_view(request, object_id, extra_context=extra_context)
-
- def __user_is_readonly(self, request):
- #groups = [x.name for x in request.user.groups.all() ]
- #return "readonly" in groups
- return request.user.isReadOnlyUser()
-
def queryset(self, request):
return User.select_by_user(request.user)
- def backend_status_text(self, obj):
- return mark_safe(backend_text(obj))
+ # ------------------------------------------------------------------------
+ # stuff copied from ModelAdmin.UserAdmin
+ # ------------------------------------------------------------------------
+ def get_fieldsets(self, request, obj=None):
+ if not obj:
+ return self.add_fieldsets
+ return super(UserAdmin, self).get_fieldsets(request, obj)
- def backend_status_icon(self, obj):
- return mark_safe(backend_icon(obj))
- backend_status_icon.short_description = ""
+ def get_form(self, request, obj=None, **kwargs):
+ """
+ Use special form during user creation
+ """
+ defaults = {}
+ if obj is None:
+ defaults['form'] = self.add_form
+ defaults.update(kwargs)
+ return super(UserAdmin, self).get_form(request, obj, **defaults)
+
+ def get_urls(self):
+ from django.conf.urls import patterns
+ return patterns('',
+ (r'^(\d+)/password/$',
+ self.admin_site.admin_view(self.user_change_password))
+ ) + super(UserAdmin, self).get_urls()
+
+ def lookup_allowed(self, lookup, value):
+ # See #20078: we don't want to allow any lookups involving passwords.
+ if lookup.startswith('password'):
+ return False
+ return super(UserAdmin, self).lookup_allowed(lookup, value)
+
+ @sensitive_post_parameters_m
+ @csrf_protect_m
+ @transaction.atomic
+ def add_view(self, request, form_url='', extra_context=None):
+ # It's an error for a user to have add permission but NOT change
+ # permission for users. If we allowed such users to add users, they
+ # could create superusers, which would mean they would essentially have
+ # the permission to change users. To avoid the problem entirely, we
+ # disallow users from adding users if they don't have change
+ # permission.
+ if not self.has_change_permission(request):
+ if self.has_add_permission(request) and settings.DEBUG:
+ # Raise Http404 in debug mode so that the user gets a helpful
+ # error message.
+ raise Http404(
+ 'Your user does not have the "Change user" permission. In '
+ 'order to add users, Django requires that your user '
+ 'account have both the "Add user" and "Change user" '
+ 'permissions set.')
+ raise PermissionDenied
+ if extra_context is None:
+ extra_context = {}
+ username_field = self.model._meta.get_field(self.model.USERNAME_FIELD)
+ defaults = {
+ 'auto_populated_fields': (),
+ 'username_help_text': username_field.help_text,
+ }
+ extra_context.update(defaults)
+ return super(UserAdmin, self).add_view(request, form_url,
+ extra_context)
+
+ @sensitive_post_parameters_m
+ def user_change_password(self, request, id, form_url=''):
+ if not self.has_change_permission(request):
+ raise PermissionDenied
+ user = get_object_or_404(self.get_queryset(request), pk=id)
+ if request.method == 'POST':
+ form = self.change_password_form(user, request.POST)
+ if form.is_valid():
+ form.save()
+ change_message = self.construct_change_message(request, form, None)
+ self.log_change(request, user, change_message)
+ msg = ugettext('Password changed successfully.')
+ messages.success(request, msg)
+ update_session_auth_hash(request, form.user)
+ return HttpResponseRedirect('..')
+ else:
+ form = self.change_password_form(user)
+
+ fieldsets = [(None, {'fields': list(form.base_fields)})]
+ adminForm = admin.helpers.AdminForm(form, fieldsets, {})
+
+ context = {
+ 'title': _('Change password: %s') % escape(user.get_username()),
+ 'adminForm': adminForm,
+ 'form_url': form_url,
+ 'form': form,
+ 'is_popup': (IS_POPUP_VAR in request.POST or
+ IS_POPUP_VAR in request.GET),
+ 'add': True,
+ 'change': False,
+ 'has_delete_permission': False,
+ 'has_change_permission': True,
+ 'has_absolute_url': False,
+ 'opts': self.model._meta,
+ 'original': user,
+ 'save_as': False,
+ 'show_save': True,
+ }
+ context.update(admin.site.each_context())
+ return TemplateResponse(request,
+ self.change_user_password_template or
+ 'admin/auth/user/change_password.html',
+ context, current_app=self.admin_site.name)
+
+ def response_add(self, request, obj, post_url_continue=None):
+ """
+ Determines the HttpResponse for the add_view stage. It mostly defers to
+ its superclass implementation but is customized because the User model
+ has a slightly different workflow.
+ """
+ # We should allow further modification of the user just added i.e. the
+ # 'Save' button should behave like the 'Save and continue editing'
+ # button except in two scenarios:
+ # * The user has pressed the 'Save and add another' button
+ # * We are adding a user in a popup
+ if '_addanother' not in request.POST and IS_POPUP_VAR not in request.POST:
+ request.POST['_continue'] = 1
+ return super(UserAdmin, self).response_add(request, obj,
+ post_url_continue)
+
+ # ------------------------------------------------------------------------
+ # end stuff copied from ModelAdmin.UserAdmin
+ # ------------------------------------------------------------------------
+
class DashboardViewAdmin(PlanetStackBaseAdmin):
fieldsets = [('Dashboard View Details',
diff --git a/planetstack/core/models/__init__.py b/planetstack/core/models/__init__.py
index f3991dd..2070e16 100644
--- a/planetstack/core/models/__init__.py
+++ b/planetstack/core/models/__init__.py
@@ -1,4 +1,4 @@
-from .plcorebase import PlCoreBase,PlCoreBaseManager,PlCoreBaseDeletionManager
+from .plcorebase import PlCoreBase,PlCoreBaseManager,PlCoreBaseDeletionManager,DiffModelMixIn
from .project import Project
from .singletonmodel import SingletonModel
from .service import Service
diff --git a/planetstack/core/models/plcorebase.py b/planetstack/core/models/plcorebase.py
index b9692c6..51049a4 100644
--- a/planetstack/core/models/plcorebase.py
+++ b/planetstack/core/models/plcorebase.py
@@ -48,7 +48,43 @@
def get_query_set(self):
return self.get_queryset()
-class PlCoreBase(models.Model):
+class DiffModelMixIn:
+ # Provides useful methods for computing which objects in a model have
+ # changed. Make sure to do self._initial = self._dict in the __init__
+ # method.
+
+ # This is broken out of PlCoreBase into a Mixin so the User model can
+ # also make use of it.
+
+ @property
+ def _dict(self):
+ return model_to_dict(self, fields=[field.name for field in
+ self._meta.fields])
+
+ @property
+ def diff(self):
+ d1 = self._initial
+ d2 = self._dict
+ diffs = [(k, (v, d2[k])) for k, v in d1.items() if v != d2[k]]
+ return dict(diffs)
+
+ @property
+ def has_changed(self):
+ return bool(self.diff)
+
+ @property
+ def changed_fields(self):
+ return self.diff.keys()
+
+ @property
+ def has_field_changed(self, field_name):
+ return field_name in self.diff.keys()
+
+ def get_field_diff(self, field_name):
+ return self.diff.get(field_name, None)
+
+
+class PlCoreBase(models.Model, DiffModelMixIn):
objects = PlCoreBaseManager()
deleted_objects = PlCoreBaseDeletionManager()
@@ -69,27 +105,9 @@
def __init__(self, *args, **kwargs):
super(PlCoreBase, self).__init__(*args, **kwargs)
- self.__initial = self._dict
+ self._initial = self._dict # for DiffModelMixIn
self.silent = False
- @property
- def diff(self):
- d1 = self.__initial
- d2 = self._dict
- diffs = [(k, (v, d2[k])) for k, v in d1.items() if v != d2[k]]
- return dict(diffs)
-
- @property
- def has_changed(self):
- return bool(self.diff)
-
- @property
- def changed_fields(self):
- return self.diff.keys()
-
- def get_field_diff(self, field_name):
- return self.diff.get(field_name, None)
-
def can_update(self, user):
if user.is_readonly:
return False
@@ -97,6 +115,11 @@
return True
return False
+ def can_update_field(self, user, fieldName):
+ # Give us the opportunity to implement fine-grained permission checking.
+ # Default to True, and let can_update() permit or deny the whole object.
+ return True
+
def delete(self, *args, **kwds):
# so we have something to give the observer
purge = kwds.get('purge',False)
@@ -131,6 +154,11 @@
def save_by_user(self, user, *args, **kwds):
if not self.can_update(user):
raise PermissionDenied("You do not have permission to update %s objects" % self.__class__.__name__)
+
+ for fieldName in self.changed_fields:
+ if not self.can_update_field(user, fieldName):
+ raise PermissionDenied("You do not have permission to update field %s in object %s" % (fieldName, self.__class__.__name__))
+
self.save(*args, **kwds)
def delete_by_user(self, user, *args, **kwds):
@@ -138,10 +166,6 @@
raise PermissionDenied("You do not have permission to delete %s objects" % self.__class__.__name__)
self.delete(*args, **kwds)
- @property
- def _dict(self):
- return model_to_dict(self, fields=[field.name for field in
- self._meta.fields])
diff --git a/planetstack/core/models/user.py b/planetstack/core/models/user.py
index 9a62e34..9b54da9 100644
--- a/planetstack/core/models/user.py
+++ b/planetstack/core/models/user.py
@@ -3,7 +3,7 @@
from collections import defaultdict
from django.db import models
from django.db.models import F, Q
-from core.models import PlCoreBase,Site, DashboardView
+from core.models import PlCoreBase,Site, DashboardView, DiffModelMixIn
from core.models.site import Deployment
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
from timezones.fields import TimeZoneField
@@ -11,6 +11,7 @@
from django.core.mail import EmailMultiAlternatives
from core.middleware import get_request
import model_policy
+from django.core.exceptions import PermissionDenied
# Create your models here.
class UserManager(BaseUserManager):
@@ -55,7 +56,7 @@
def get_query_set(self):
return self.get_queryset()
-class User(AbstractBaseUser):
+class User(AbstractBaseUser, DiffModelMixIn):
class Meta:
app_label = "core"
@@ -99,6 +100,10 @@
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['firstname', 'lastname']
+ def __init__(self, *args, **kwargs):
+ super(User, self).__init__(*args, **kwargs)
+ self._initial = self._dict # for DiffModelMixIn
+
def isReadOnlyUser(self):
return self.is_readonly
@@ -182,6 +187,8 @@
self.username = self.email
super(User, self).save(*args, **kwds)
+ self._initial = self._dict
+
def send_temporary_password(self):
password = User.objects.make_random_password()
self.set_password(password)
@@ -193,6 +200,41 @@
msg.attach_alternative(html_content, "text/html")
msg.send()
+ def can_update_field(self, user, fieldName):
+ from core.models import SitePrivilege
+ if (user.is_admin):
+ # admin can update anything
+ return True
+
+ # fields that a site PI can update
+ if fieldName in ["is_active", "is_readonly"]:
+ site_privs = SitePrivilege.objects.filter(user=user, site=self.site)
+ for site_priv in site_privs:
+ if site_priv.role.role == 'pi':
+ return True
+
+ # fields that a user cannot update in his/her own record
+ if fieldName in ["is_admin", "is_active", "site", "is_staff", "is_readonly"]:
+ return False
+
+ return True
+
+ def can_update(self, user):
+ from core.models import SitePrivilege
+ if user.is_readonly:
+ return False
+ if user.is_admin:
+ return True
+ if (user.id == self.id):
+ return True
+ # site pis can update
+ site_privs = SitePrivilege.objects.filter(user=user, site=self.site)
+ for site_priv in site_privs:
+ if site_priv.role.role == 'pi':
+ return True
+
+ return False
+
@staticmethod
def select_by_user(user):
if user.is_admin:
@@ -208,6 +250,21 @@
qs = User.objects.filter(Q(site__in=sites) | Q(id__in=user_ids))
return qs
+ def save_by_user(self, user, *args, **kwds):
+ if not self.can_update(user):
+ raise PermissionDenied("You do not have permission to update %s objects" % self.__class__.__name__)
+
+ for fieldName in self.changed_fields:
+ if not self.can_update_field(user, fieldName):
+ raise PermissionDenied("You do not have permission to update field %s in object %s" % (fieldName, self.__class__.__name__))
+
+ self.save(*args, **kwds)
+
+ def delete_by_user(self, user, *args, **kwds):
+ if not self.can_update(user):
+ raise PermissionDenied("You do not have permission to delete %s objects" % self.__class__.__name__)
+ self.delete(*args, **kwds)
+
class UserDashboardView(PlCoreBase):
user = models.ForeignKey(User, related_name="dashboardViews")
dashboardView = models.ForeignKey(DashboardView, related_name="dashboardViews")