Object-level Permissions - python

I am sure that this is fairly straightforward, but I have scoured the documentation and I can't quite figure out how to do this.
I have extended my User class to have two ManyToMany relationships to other users: trainers and teammates.
If a user owns an object (defined by a user ForeignKey on the model), then that user should be able to GET, POST, PUT, PATCH, and DELETE. I have set up these endpoints with ModelViewSet. If a user is a trainer of the owner, they should have the same privileges. If a user is a teammate of the owner, they should only be able to GET.
In a list view of these objects, a user should only see the objects they own and the objects where they are a trainer or teammate of the owner. If they try and access a detail view of an object where they are not the friend or the teammate of the owner, it should return a 403.
I extended BasePermission as follows to try and create this behavior -- I then added it to the ModelViewSet where I wanted this behavior.
class TrainerAndOwnerOrTeammate(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
user = request.user
owner = obj.user
if user == owner:
return True
if user in owner.trainers.all():
return True
if user in owner.teammates.all():
return request.method in permissions.SAFE_METHODS
return False
Since the REST Framework documentation specifies that this isn't run on a per-object basis for list views, I overrode get_queryset to filter by the request user.
The issue is now I get a 404 error, not a 403, if I try and access a detail view I shouldn't have access to. I understand why that's happening, but is there a way to fix it?

I think one solution is to drop get_queryset() and define a custom filter. Filter classes let you filter the queryset based on the request and view being accessed. Another way is to split your viewset into multiple individual views. Another way is to define get_object.

I ended up overriding the list method of the ModelViewSet and keeping the permission class. The permission class handled the detail views and actions, and the list method handled the list view.

Related

How to prevent deletion of Django model from Django Admin, unless part of a cascade

I have a project using Django 2.2.4.
I have a Django model called Company.
I use a post_save signal to make sure that as soon as a new Company is created, a new model instance called "Billing" is created, which is associated with that Company. This contains the company's billing information. This works great.
Since my Billing object is associated with a Company, and I use on_delete=models.CASCADE, as soon as the Company is deleted, the Billing object associated with that company is automatically deleted as well. This works great too.
Since the Billing object for each company is now automatically created and deleted along with the Company, there's no need for admins using the Django Admin web interface to ever have to manually create, or delete Billing objects. I want to hide this functionality from them.
Normally, the common way to prevent Django Admin from allowing someone to add or delete an object is by adding this to that model's ModelAdmin in admin.py:
class BillingAdmin(admin.ModelAdmin):
...
# Prevent deletion from admin portal
def has_delete_permission(self, request, obj=None):
return False
# Prevent adding from admin portal
def has_add_permission(self, request, obj=None):
return False
This works, and does indeed hide the ability for Admins to create or delete instances of the Billing object manually. It does however have one negative side-effect: Django Admin users can no longer delete a company. When deleting a company, Django does a lookup for all associated objects that need to also be deleted, notices that the user isn't allowed to delete the associated Billing object, and prevents the user from deleting the Company.
While I don't want Django Admin users to be able to manually create or delete instances of the Billing model, I still want them to be able to delete an entire Company, which will result in the deletion of the instance of the Billing model associated with that Company.
In my case, preventing users from deleting an instance of the Billing model isn't so much a security feature, as it is intended to prevent confusion, by not letting the database end up in a state where a Company exists, but no billing object exists for it. Django would obviously not have a problem with this, but it would confuse users.
Is there a workaround for this?
Update:
With the has_delete_permission set, if you try to delete a company through Django Admin, you get this:
No exceptions are thrown. At least none that aren't caught, and appear in the Django logs.
My Models look like this:
class Company(Group):
...
class Billing(models.Model):
company = AutoOneToOneField('Company', on_delete=models.CASCADE, blank=False, null=False, related_name="billing")
monthly_rate = models.DecimalField(max_digits=10, decimal_places=2, default=0, blank=False, null=False)
# Create billing object for a company when it is first created
#receiver(post_save, sender=Company)
def create_billing_for_company(sender, instance, created, *args, **kwargs):
if created:
Billing.objects.create(company=instance)
The AutoOneToOneField is part of django-annoying. It makes sure that if you run MyCompany.billing, and an associated billing object doesn't exist yet, one will be created automatically, rather than an exception being raised. May not be required here, since I automatically create the object when the company is created, but it can't hurt, and ensures my code never needs to worry about the associated object not existing.
Also note that I have NOT overridden my Billing model's delete function.
Another option is to override specifically designed for this method get_deleted_objects in main Company ModelAdmin - to allow deleting all related objects when deleting company from admin web.
class CompanyAdmin(admin.ModelAdmin):
def get_deleted_objects(self, objs, request):
"""
Allow deleting related objects if their model is present in admin_site
and user does not have permissions to delete them from admin web
"""
deleted_objects, model_count, perms_needed, protected = \
super().get_deleted_objects(objs, request)
return deleted_objects, model_count, set(), protected
Here we replace perms_needed with empty set() - which is a set of permissions user failed to satisfy for deleting related objects via admin site.
When deleting objects via django admin it:
checks if user has permissions to delete main object
calculates list of other related objects that should be deleted as well
for these related objects if their model is registered in admin_site, django performs additional permission check
if the user has admin site permissions to delete these related objects as well
if user does not have permissions to delete related objects - these required permissions are added to list and shown as error page
To get the list of related objects to be deleted with main one utility method is used - get_deleted_objects
And since Django 2.1 there is more comfortable way to override it directly from ModelAdmin instance:
get_deleted_objects
After a little digging, it does appear that the ModelAdmin will simply call delete() on the object, meaning that it shouldn't look at your billing permissions for admin specifically. Looking at the model delete also confirms that it does not care what the admin permissions are.
I got curious, and wondered if maybe the has_delete_permission function looks at related objects. That also didn't appear to be the case. At this point, I'm curious if you have overridden your Billing model's delete function? That would prevent deletion, and if you have CASCADE set as your on_delete for the relation, it would not allow you to finish deleting the Company at that point because it was unable to cascade delete.
If you have a stack trace or explicit error message, please share it.
With that said, I don't know if I agree with the approach to this. I think it would make more sense to enforce this at the model level of Billing. When attempting a delete, you could check if there are no other Billing objects for the Company, and if so, raise a validation error notifying the user that a Company must have at least one Billing. I don't know your models since they aren't posted, so if it's a one-to-one relation, please ignore this. Here's a rough idea of how I would expect it to look otherwise:
def delete(self):
other_billing = Billing.objects.filter(company_id=self.company.id).exclude(id=self.id).first()
if not other_billing:
raise ValidationError({"message": "A company must have at least one Billing."})
super().delete()
Edit: Here's a method using ModelAdmin.delete_model() which won't raise an exception.
def delete_model(self, request, billing):
other_billing = Billing.objects.filter(company_id=billing.company.id).exclude(id=billing.id).first()
if not other_billing:
# from django.contrib import messages
messages.error(request, "A company must have at least one Billing.")
else:
super().delete_model(request, billing)
EDIT: I did find that you have access to the request, which seems to be the only reliable way via has_delete_permissions() to check whether you're on the admin change page for your model or not. For the record I think this way is hacky and I don't recommend it. However, it would allow for cascading deletes while not allowing delete via the change page (it will hide the button):
def has_delete_permissions(self, request, obj=None):
# If we have an object, it's been fetched for deletion or to check permission against it.
if isinstance(obj, Billing):
if request.path == reverse("admin:<APP_NAME>_billing_change", args=[obj.id]):
return False
return True

Add user profile to request.user

I have multiple User Types which I represent by user profiles in the form of a model:
Student
Teacher
I need access to the specific user profile on every request.
To avoid executing an extra query every time I would like to add directly a select_related to the request.user object.
I couldn't find anything about it in the docs. Does anyone know the best way to do that?
Interesting question. Looking at the source code of AuthenticationMiddleware and auth.get_user it seems that only thing you'll need to do will be to implement and use you own authentication backend. If you don't use any other custom backend features, you can subclass the ModelBackend, overriding only the get_user method to suit your needs:
class MyModelBackend(ModelBackend):
def get_user(self, user_id):
try:
user = UserModel._default_manager.select_related("profile").get(pk=user_id)
except UserModel.DoesNotExist:
return None
return user if self.user_can_authenticate(user) else None
Of course, you'll need to add it to your settings AUTHENTICATION_BACKENDS.

Django raw_id_fields read_only permission

I have this Django project that I'm working on, which won't allow users to select an entry (User entries) on the raw_id_fields popup, if they don't have change permissions (which they can't have at all). That's really weird cause that doesn't happen with the select tag list if I remove the raw_id_fields attribute on my ModelForm class at admins.py.
How can this permission behavior be consistent if it changes according to a different interface setting? I mean, user only have permission to select users on the form if they are displayed as a select tag. It seems to me that it's a big consistency failure with the way Django permissions was designed, which, in my opinion, should have native can_view permission, in addition to can_|add, change, delete.
While googling around I found a few topics discussing this matter, but all of them end up with some really painful solutions that don't seem straightforward to me. I wonder if something so simple could have a straightforward solution that won't require lots of workarounds.
Here is an example that looks like my actual code:
models.py
class Project(models.Model):
manager = models.ForeignKey(User)
...
admins.py
class ProjectAdmin(admin.ModelAdmin):
raw_id_fields = ['manager',]
...
As you've said, this is really weird. Thats why I've opted to "extend" the django admin to my specific requirements sometimes.
The easiest way to meet this goal is by overriding the has_change_permission of the referenced ModelAdmin
As you have the request object as an argument of the method, you can evaluate:
if the request comes from a raw_id_field or not
if the user has permissions to see that models or not
any other constraint you have
A simple prototype for the method:
def has_change_permission(self, request, obj=None):
if obj is None and '_popup' in request.GET:
return True
return super(MyAdmin, self).has_change_permission(request, obj)

unregister or register models conditionally in django admin

Is it possible to conditionally register or unregister models in django admin?
I want some models to appear in django admin, only if request satisfies some conditions. In my specific case I only need to check if the logged in user belongs to a particular group, and not show the model if the user (even if superuser) is not in the group. I can not use permissions here because, superusers can not be ruled out using permissions.
Or, is there a way to revoke permission from even superusers on model.
Permissions on a model can be managed dynamically in ModelAdmin.
Override the methods has_add_permission, has_change_permission and has_delete_permission.
class MyModelAdmin(admin.ModelAdmin):
def has_add_permission(self,request):
# if request satisfies conditions:
# return True
#else:
# return False
Same goes for other two methods. This works for superusers also.
If you revoke all three permissions MyModel will not be listed on admin site.
If you only require to hide model entry from admin site, simply override
get_model_perms method. You don't have to override permission methods.
def get_model_perms(self, request):
return {}
However, this method does not revoke permissions from the model. Even if the model is not listed on admin site, it can be accessed by entering url.
I've tried a couple of approaches locally, including overriding an AdminSite, but given the fact that all admin-related code is loaded when the app is initialized, the simplest approach would be to rely on permissions (and not give everyone superuser access).

Multiple versions of django admin page for the same model

In my django admin section, I'd like to show different versions of the admin page depending on what kind of user is currently logged in. I can think of a couple ways this might work, but haven't figured out how to do any of them.
Perhaps I could put logic into the admin.ModelAdmin to look at the current user and change the 'exclude' field dynamically. Does that work? Or maybe run different custom templates based on who's logged in, and have the templates include / exclude the fields as appropriate.
I could register two versions of the admin.ModelAdmin class, one for each type of user, and maybe restrict access through permissions? But the permissions system seems to believe fairly deeply in one set of permissions per model class so I'm not sure how to change that.
I could grab a couple of the widgets that are used in rendering the admin page templates, and include them in my own page that does the one specific job I need powerful users to be able to do.
I could set up multiple AdminSites and restrict access to them through the url / view system. But then I'm not sure how to register different admin.ModelAdmin classes with the different AdminSites.
Any advice on this would be appreciated.
Answer
Thanks for the hint. Here's how I did it...
def get_form(self, request, obj=None, **kwargs):
"""This dynamically inserts the "owners" field into the exclude list
if the current user is not superuser.
"""
if not request.user.is_superuser:
if self.exclude:
self.exclude.append('owners')
else:
self.exclude = ['owners']
else:
# Necessary since Admin objects outlive requests
try:
self.exclude.remove('owners')
except:
pass
return super(OwnersModelAdmin,self).get_form(request, obj=None, **kwargs)
There are quite a few hooks provided in the ModelAdmin class for this sort of thing.
One possibility would be to override the get_form method. This takes the request, as well as the object being edited, so you could get the current user from there, and return different ModelForms dependent on the user.
It's worth looking at the source for ModelAdmin - it's in django.contrib.admin.options - to see if overriding this or any other other methods might meet your needs.

Categories