pgorecki/django-cancan

🔓Authorization library for Django

authorization
django
permissions

django-cancan

Logo

Build Status PyPI version

django-cancan is an authorization library for Django. It works on top of default Django permissions and allows to restrict the resources (models and objects) a given user can access.

This library is inspired by cancancan for Ruby on Rails.

Key features

  • All of your permissions logic is kept in one place. User permissions are defined in a single function and not scattered across views, querysets, etc.

  • Same permissions logic is used to check permissions on a single model instance and to generate queryset containing all instances that the user can access

  • Easy unit testing

  • Integration with built-in Django default permissions system and Django admin (coming soon)

  • Intergration with Django Rest Framework (coming soon)

How to install

Using pip:

pip install django-cancan

Quick start

  1. Add cancan to your INSTALLED_APPS setting like this:
INSTALLED_APPS = [
    ...,
    'cancan',
]
  1. Create a function that define the access rules for a given user. For example, create abilities.py in myapp module:
def define_access_rules(user, rules):
    # Anybody can view published articles
    rules.allow('view', Article, published=True)

    if not user.is_authenticated:
        return 

    # Allow logged in user to view his own articles, regardless of the `published` status
    # allow accepts the same kwargs that you would provide to QuerySet.filter method
    rules.allow('view', Article, author=user)

    if user.has_perm('article.view_unpublished'):
        # You can also check for custom model permissions (i.e. view_unpublished)
        rules.allow('view', Article, published=False)


    if user.is_superuser:
        # Superuser gets unlimited access to all articles
        rules.allow('add', Article)
        rules.allow('view', Article)
        rules.allow('change', Article)
        rules.allow('delete', Article)
  1. In settings.py add CANCAN section, so that cancan library will know where to search for define_access_rules function from the previous step:
CANCAN = {
    'ABILITIES': 'myapp.abilities.define_access_rules'
}

The define_access_rules function will be executed automatically per each request by the cancan middleware. The middleware will call the function to determine the abilities of a current user.

Let's add cancan middleware, just after AuthenticationMiddleware:

MIDDLEWARE = [
    ...
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'cancan.middleware.CanCanMiddleware',
    ...
]

By adding the middleware you will also get an access to request.ability instance which you can use to: - check model permissions, - check object permissions, - generate model querysets (i.e. when inheriting from ListView)

  1. Check for abilities in views:
class ArticleListView(ListView):
    model = Article

    def get_queryset(self):
        # this is how you can retrieve all objects that current user can access
        qs = self.request.ability.queryset_for('view', Article)
        return qs


class ArticleDetailView(PermissionRequiredMixin, DetailView):
    queryset = Article.objects.all()

    def has_permission(self):
        article = self.get_object()
        # this is how you can check if user can access an object
        return self.request.ability.can('view', article)
  1. Check for abilities in templates

You can also check for abilities in template files, i. e. to show/hide/disable buttons or links.

First you need to add cancan processor to context_processors in TEMPLATES section of settings.py:

TEMPLATES = [
    {
        ...,
        "OPTIONS": {
            "context_processors": [
                ...,
                "cancan.context_processors.abilities",
            ],
        },
    },
]

This will give you access to ability object in a template. You also need add {% load cancan_tags %} at the beginning of the template file.

Next you can check for object permissions:

{% load cancan_tags %}

...

{% if ability|can:"change"|subject:article %}
    <a href="{% url 'article_edit' pk=article.id %}">Edit article</a>
{% endif %}

or model permissions:

{% if ability|can:"add"|subject:"myapp.Article" %}
    <a href="{% url 'article_new' %}">Create new article</a>
{% endif %}

You can also use can template tag to create a reusable variable:

{% can "add" "core.Project" as can_add_project %}
...
{% if can_add_project %}
    ...
{% endif %}

Checking for abilities in Django Rest Framework

Let's start by creating a pemission class:

from rest_framework import permissions

def set_aliases_for_drf_actions(ability):
    """
    map DRF actions to default Django permissions
    """
    ability.access_rules.alias_action("list", "view")
    ability.access_rules.alias_action("retrieve", "view")
    ability.access_rules.alias_action("create", "add")
    ability.access_rules.alias_action("update", "change")
    ability.access_rules.alias_action("partial_update", "change")
    ability.access_rules.alias_action("destroy", "delete")


class AbilityPermission(permissions.BasePermission):
    def has_permission(self, request, view=None):
        ability = request.ability
        set_aliases_for_drf_actions(ability)
        return ability.can(view.action, view.get_queryset().model)

    def has_object_permission(self, request, view, obj):
        ability = request.ability
        set_aliases_for_drf_actions(ability)
        return ability.can(view.action, obj)

Next, secure the ViewSet with AbilityPermission and override get_queryset method to list objects based on the access rights.

class ArticleViewset(ModelViewSet):
    permission_classes = [AbilityPermission]

    def get_queryset(self):
        return self.request.ability.queryset_for(self.action, Article).distinct()

Itegrating with admin panel

To inegrate django-cancan with the admin panel, add the following mixin to your admin.ModelAdmin class.

class AbilityModelAdminMixin:
    def get_queryset(self, request):
        if request.user.is_superuser:
            return super().get_queryset(request)
        return request.ability.queryset_for("view", self.model)

    def has_module_permission(self, request):
        if request.user.is_superuser:
            return True

        can = request.ability.can
        return (
                can("view", self.model)
                or can("change", self.model)
                or can("delete", self.model)
        )

    def has_add_permission(self, request, obj=None):
        if request.user.is_superuser:
            return True
        return request.ability.can("add", self.model)

    def has_view_permission(self, request, obj=None):
        if request.user.is_superuser:
            return True
        return request.ability.can("view", self.model)

    def has_change_permission(self, request, obj=None):
        if request.user.is_superuser:
            return True
        return request.ability.can("change", self.model)

    def has_delete_permission(self, request, obj=None):
        if request.user.is_superuser:
            return True
        return request.ability.can("delete", self.model)

like so:

class AbilityModelAdmin(AbilityModelAdminMixin, admin.ModelAdmin):
    pass

admin.site.register(Article, AbilityModelAdmin)

Unit testing

This is how you can unit test your define_access_rules function.

from cancan.access_rules import AccessRules
from cancan.ability import Ability
from myapp.abilities import define_access_rules

user = somehow_create_user(...)
instance1 = MyModel.objects.create(...)

access_rules = AccessRules(user)
define_access_rules(user1, access_rules)
ability = Ability(access_rules)

assert instance1 in ability.queryset_for("view", MyModel)
assert ability.can("update", instance1)

ability.queryset_for and rules.allow explained

When executing rules.allow you specify 2 positional arguments: action and subject. Any additional parameters passed to allow will filter the results in the same way as for Django QuerySet.fiter method.

Let's say that we have the following models in core.models.py:

class Project(models.Model):
    name = models.CharField(max_length=128)
    description = models.TextField(default="", blank=True)
    members = models.ManyToManyField(User, through="Membership")
    created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name="owner")

class Membership(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    project = models.ForeignKey(Project, on_delete=models.CASCADE)

If you have the following rules:

rules.allow('view', Project, name="Foo")

then executing:

ability.queryset_for('view', Project)

will result in the following query:

SELECT "core_project"."id", "core_project"."name", "core_project"."description", "core_project"."created_by_id" FROM "core_project" WHERE "core_project"."name" = Foo

Similarly, rules.allow('view', Project, name="Foo", description__contains="Bar")

will generate a query:

SELECT "core_project"."id", "core_project"."name", "core_project"."description", "core_project"."created_by_id" FROM "core_project" WHERE ("core_project"."description" LIKE %Bar% ESCAPE '\' AND "core_project"."name" = Foo)

Multiple rules for the same action and model will result in OR'ed queries, i.e.:

rules.allow('view', Project, name="Foo")
rules.allow('view', Project, description__contains="Bar")

will generate a query:

SELECT "core_project"."id", "core_project"."name", "core_project"."description", "core_project"."created_by_id" FROM "core_project" WHERE ("core_project"."description" LIKE %Bar% ESCAPE '\' OR "core_project"."name" = Foo)

See example_project/cancan_playground.ipynb for more examples.

Sponsors

STX Next
Ermlab


Logo made by Freepik from www.flaticon.com
Stars
34
100.00% more than last month
Forks
0
Open Issues
0