8

Please consider a simple Django app containing a central model called Project. Other resources of this app are always tied to a specific Project.

Exemplary code:

class Project(models.Model):
    pass

class Page(models.Model):
    project = models.ForeignKey(Project)

I'd like to leverage Django's permission system to set granular permissions per existing project. In the example's case, a user should be able to have a view_page permission for some project instances, and don't have it for others.

In the end, I would like to have a function like has_perm that takes the permission codename and a project as input and returns True if the current user has the permission in the given project.

Is there a way to extend or replace Django's authorization system to achieve something like this?

I could extend the user's Group model to include a link to Project and check both, the group's project and its permissions. But that's not elegant and doesn't allow for assigning permissions to single users.


Somewhat related questions on the Django forum can be found here:

Related StackOverflow questions:

Sören Weber
  • 601
  • 6
  • 25
  • Object level permissions can NOT be done via Django authorisation system. For your use case , you need to maintain a database table containing list of projects that a user is authorised to view. – Neeraj Oct 17 '21 at 01:27
  • Thanks for your suggestion! I'm not really looking for object-level permission (I guess), just for a way to include projects into the authorization process. In my eyes, that's far less granular than having object-level permission, right? I guess your suggestion wouldn't solve my problem because I need to have other permissions specific to a project as well (e.g. permissions for creating or changing pages should be specific to projects, too). – Sören Weber Oct 17 '21 at 07:22
  • "Class Project" will store details of every project and hence evey project is an object in your database , secondly you don't need to set permissions for creating or changing pages. whenever a user to trying to do anything with the project like add a new page, you only need check one table whether user has permission for that particular project or not. – Neeraj Oct 17 '21 at 13:01
  • I didn't explain my scenario well then, sorry. I *do need* permissions for creating, changing (and other custom actions) that are specific to individual projects. This is for an existing software project that uses Django's permissions system for granular (i.e. for individual actions) authorization. This works well while managing a single project but now I'd like to introduce the possibility to manage multiple projects while keeping the granular permissions. – Sören Weber Oct 17 '21 at 13:31
  • 1
    Can you show how you expect to use the permissions? – aaron Oct 17 '21 at 17:23
  • @aaron, I can't really do that since I'm not sure about the solution space here. In the end, I would like to have a function like `has_perm` that takes the permission codename and a project as input and returns `True` if the current user has the permission under the given project. I would like to stay as close/compatible to Django's existing permission system as possible, though I'm not sure that's possible. – Sören Weber Oct 17 '21 at 17:59

3 Answers3

2

Orignal answer: Use django-guardian

Edit. As discussed in the comments, I think the django-guardian offers the easiest and cleanest way to achieve this. However, another solution is to create a custom user.

  1. Create a custom user model. how-to
  2. Override the has_perm method in your new user model.
from django.db import models
from my_app import Project

class CustomUser(...)
    projects = models.ManyToManyField(Project)

    def has_perm(self, perm, obj=None):
        if isinstance(obj, Project):
            if not obj in self.projects.objects.all():
                return False
        return super().has_perm(perm, obj)
Felix Eklöf
  • 3,253
  • 2
  • 10
  • 27
  • Thanks a lot for your answer, Felix! I think my main problem here is: How do I create the project-specific permissions and keep them up-to-date. As far as I can see, I would need them as well in `DjangoObjectPermissions` and `django-guardian`, right? Thank you for your pointers and additional thoughts! – Sören Weber Oct 20 '21 at 06:56
  • I don't understand exactly what you mean. Could you give an example and maybe I can help you further? It would be helpful to know how you plan to create and access the Page and other objects. – Felix Eklöf Oct 20 '21 at 07:25
  • Of course, I'll try to give a concrete example of what I'm trying to achieve: I create two projects in the admin interface. I want to assign the permission to view pages of the first project to an existing user and prevent them from viewing pages of the other project. How/where do I store the information about the page permission and the project it's referring to? To change your first example a bit: How do I get to the point that I can ask `request.user.has_perm('view_page', project)`? (Projects will be created in the admin interface, pages will be created by users in custom views.) – Sören Weber Oct 20 '21 at 07:43
  • I think you just have to install django-guardian, then check in the docs how you enable it in admin that I linked. Then you will be able to assign users to projects. – Felix Eklöf Oct 20 '21 at 09:18
  • I've added an edit to the steps you should try. I haven't used django-guardian a lot so I not sure how it looks in the admin panel. But I know that you will be able to assign permissions to objects. – Felix Eklöf Oct 20 '21 at 09:24
  • Thanks, Felix. As far as I understand, `django-guardian` uses Django's existing permission system but extends it so that permissions can be assigned to single objects. In my opinion, that doesn't really help in my use-case - since the central problem remains: How can I create a permission that is specific to a single project? You could certainly build the same behavior with `django-guardian` but it seems like way too much overhead to maintain those object-level permissions for all project-related resources. – Sören Weber Oct 20 '21 at 09:48
  • I edited and added an alternative – Felix Eklöf Oct 20 '21 at 10:51
  • Please correct me if I'm wrong but in my case using `django-guardian`, I would have to assign every relevant permission (e.g. `view_page`, `add_page`) on every project-related resource (`Page`, in my example) to every relevant user/group based on the project the resource instance belongs to. I would also need to maintain and update those permissions, for example when adding a new page. This feels too granular and error-prone to me, especially when compared to @HB21's proposal of dynamically creating permissions that are project-specific. – Sören Weber Oct 20 '21 at 11:23
  • Your second proposal is too coarse, though, I'm afraif. If I understand it correctly, there would be no way for someone to have `view_page` and not `add_page` for project 1 and to not have `view_page` and have `add_page` for project 2. – Sören Weber Oct 20 '21 at 11:25
2

I wasn't quite happy with the answers that were (thankfully!) proposed because they seemed to introduce overhead, either in complexity or maintenance. For django-guardian in particular I would have needed a way to keep those object-level permissions up-to-date while potentially suffering from (slight) performance loss. The same is true for dynamically creating permissions; I would have needed a way to keep those up-to-date and would deviate from the standard way of defining permissions (only) in the models.

But both answers actually encouraged me to take a more detailed look at Django's authentication and authorization system. That's when I realized that it's quite feasible to extend it to my needs (as it is so often with Django).


I solved this by introducing a new model, ProjectPermission, that links a Permission to a project and can be assigned to users and groups. This model represents the fact that a user or group has a permission for a specific project.

To utilize this model, I extended ModelBackend and introduced a parallel permission check, has_project_perm, that checks if a user has a permission for a specific project. The code is mostly analogous to the default path of has_perm as defined in ModelBackend.

By leveraging the default permission check, has_project_perm will return True if the user either has the project-specific permission or has the permission in the old-fashioned way (that I termed "global"). Doing so allows me to assign permissions that are valid for all projects without stating them explicitly.

Lastly, I extended my custom user model to access the new permission check by introducing a new method, has_project_perm.


# models.py

from django.contrib import auth
from django.contrib.auth.models import AbstractUser, Group, Permission
from django.core.exceptions import PermissionDenied
from django.db import models

from showbase.users.models import User


class ProjectPermission(models.Model):
    """A permission that is valid for a specific project."""

    project = models.ForeignKey(Project, on_delete=models.CASCADE)
    base_permission = models.ForeignKey(
        Permission, on_delete=models.CASCADE, related_name="project_permission"
    )
    users = models.ManyToManyField(User, related_name="user_project_permissions")
    groups = models.ManyToManyField(Group, related_name="project_permissions")

    class Meta:
        indexes = [models.Index(fields=["project", "base_permission"])]
        unique_together = ["project", "base_permission"]


def _user_has_project_perm(user, perm, project):
    """
    A backend can raise `PermissionDenied` to short-circuit permission checking.
    """
    for backend in auth.get_backends():
        if not hasattr(backend, "has_project_perm"):
            continue
        try:
            if backend.has_project_perm(user, perm, project):
                return True
        except PermissionDenied:
            return False
    return False


class User(AbstractUser):
    def has_project_perm(self, perm, project):
        """Return True if the user has the specified permission in a project."""
        # Active superusers have all permissions.
        if self.is_active and self.is_superuser:
            return True

        # Otherwise we need to check the backends.
        return _user_has_project_perm(self, perm, project)
# auth_backends.py

from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import Permission


class ProjectBackend(ModelBackend):
    """A backend that understands project-specific authorization."""

    def _get_user_project_permissions(self, user_obj, project):
        return Permission.objects.filter(
            project_permission__users=user_obj, project_permission__project=project
        )

    def _get_group_project_permissions(self, user_obj, project):
        user_groups_field = get_user_model()._meta.get_field("groups")
        user_groups_query = (
            "project_permission__groups__%s" % user_groups_field.related_query_name()
        )
        return Permission.objects.filter(
            **{user_groups_query: user_obj}, project_permission__project=project
        )

    def _get_project_permissions(self, user_obj, project, from_name):
        if not user_obj.is_active or user_obj.is_anonymous:
            return set()

        perm_cache_name = f"_{from_name}_project_{project.pk}_perm_cache"
        if not hasattr(user_obj, perm_cache_name):
            if user_obj.is_superuser:
                perms = Permission.objects.all()
            else:
                perms = getattr(self, "_get_%s_project_permissions" % from_name)(
                    user_obj, project
                )
            perms = perms.values_list("content_type__app_label", "codename").order_by()
            setattr(
                user_obj, perm_cache_name, {"%s.%s" % (ct, name) for ct, name in perms}
            )
        return getattr(user_obj, perm_cache_name)

    def get_user_project_permissions(self, user_obj, project):
        return self._get_project_permissions(user_obj, project, "user")

    def get_group_project_permissions(self, user_obj, project):
        return self._get_project_permissions(user_obj, project, "group")

    def get_all_project_permissions(self, user_obj, project):
        return {
            *self.get_user_project_permissions(user_obj, project),
            *self.get_group_project_permissions(user_obj, project),
            *self.get_user_permissions(user_obj),
            *self.get_group_permissions(user_obj),
        }

    def has_project_perm(self, user_obj, perm, project):
        return perm in self.get_all_project_permissions(user_obj, project)
# settings.py

AUTHENTICATION_BACKENDS = ["django_project.projects.auth_backends.ProjectBackend"]
Sören Weber
  • 601
  • 6
  • 25
1

My answer is on the basis of a user should be able to have a view_page permission for one project instance, and don't have it for another instance.

So basically you will have to catch first user visit == first model instance , you can create FirstVisit model which will catch and save each first instance using url, user.id and page.id, then you check if it exists.

# model

class Project(models.Model):
   pass

class Page(models.Model):
    project = models.ForeignKey(Project)

class FirstVisit(models.Model):
    url = models.URLField()
    user = models.ForeignKey(User)
    page = models.ForeignKey(Page)


#views.py

def my_view(request):
   if not FisrtVisit.objects.filter(user=request.user.id, url=request.path, page=request.page.id).exists():
      # first time visit == first instance
      #your code...
      FisrtVisit(user=request.user, url=request.path, page=request.page.id).save()

based on this solution

I suggest to use device (computer or Smartphone) Mac Address instead of url using getmac for maximum first visit check

HB21
  • 135
  • 10
  • Thanks for your answer! Unfortunately, this doesn't solve my problem and I guess my question was ambiguous in the part you quoted. What I meant was: It should be possible for the user to have the `view_page` permission in some projects and to don't have it in other projects. My main problem here is understanding how to use/extend [Django's permission system](https://docs.djangoproject.com/en/dev/topics/auth/default/) to fit my use case. – Sören Weber Oct 17 '21 at 18:05
  • 1
    I think best approach for current case is to create `User Groups` and set permissions for each group. more details here https://www.botreetechnologies.com/blog/django-user-groups-and-permission/ – HB21 Oct 17 '21 at 22:26
  • That's an interesting way to solve my main problem, thank you! Essentially, they are dynamically creating permissions when new projects are created. The permission codenames contain the projects ID to be specific to them. This is certainly a possible solution and requires no extension of the permission system. I'm a bit worried about keeping the permissions up-to-date but it seems doable. Feel free to adapt your answer accordingly! I'd like to wait a few more days, though, to see if there's an even cleaner solution for this. – Sören Weber Oct 18 '21 at 07:16