О пермишенах в Django

Дисклеймер: этот пост не про встроенную в Django систему пермишенов (модель auth.Permission), она ущербная и предназначена только для управления админкой.

Один из вопросов, которые я всегда задаю на собеседовании, посвящён управлению пермишенами в Django. Я не спрашиваю об этом напрямую, а даю задачу, в которой надо использовать пермишены, и смотрю, как человек справляется. Вот эта задача:

Сделать движок для коллективного ведения блога. Залогиненный пользователь может добавлять записи в блог. Редактировать или удалить запись может её автор или суперюзер.

Решение кандидата, обычно, выглядит примерно так:

class AddPostView(...):

    def dispatch(self, request, *args, **kwargs):
        if not request.is_authenticated():
            return django.http.HttpResponseForbidden()
        return super(...)

class EditPostView(...):

    def dispatch(self, request, *args, **kwargs):
        ...
        if not (
            request.user.is_authenticated() or
            request.user.is_superuser or
            request.user == self.object.author
        ):
            return django.http.HttpResponseForbidden()
        return super(...)

Возможны также варианты с использованием LoginRequiredMixin или декоратора login_required, но сути дела это не меняет.

Ещё надо проверить пермишены в шаблоне, чтобы определить, надо ли показывать кнопку для редактирования записи:

{% if request.user.is_authenticated or request.user.is_superuser or request.user == post.author %}
  <a href="...">Edit</a>
{% endif %}

Допустим, что мы захотели добавить новую фичу: в первый день каждого месяца дать возможность всем пользователям (даже анонимным) редактировать любую запись в блоге. Для этого надо просто добавить одну строку во вьюху, так?

if not (
    datetime.date.today().day == 1 or
    request.user.is_authenticated() or
    request.user.is_superuser or
    request.user == self.object.author
):
    ...

Добавили, всё классно. А потом приходит багрепорт, что кнопка редактирования не отображается в первый день месяца как положено, потому что мы забыли обновить шаблон.

Этот пример показывает существенный изъян такого подхода: у нас есть один и тот же набор правил, который надо поддерживать актуальным как минимум в двух местах. А если эти правила будут более сложными, то реализовать их в шаблонах может быть просто невозможно.

Решение у этой проблемы очень простое: проверка пермишенов должна быть выделана в отдельный компонент, к которому другие компоненты будут обращаться по мере необходимости.

Специально для этого есть отличная библиотека rules.

Перепишем наш пример с использованием этой библиотеки. Сначала надо задекларировать наш набор правил:

# apps/blog/rules.py
from __future__ import absolute_import
import rules
import datetime

@rules.predicate
def is_first_day_in_month():
    return datetime.date.today().day == 1

@rules.predicate
def is_author(user, obj):
    return obj.author == user

rules.add_perm("blog.add_post", rules.is_authenticated)
rules.add_perm("blog.edit_post", is_first_day_in_month | rules.is_superuser | is_author)

потом подправить вьюхи:

# apps/blog/views.py
from rules.contrib.views import PermissionRequiredMixin


class AddPostView(PermissionRequiredMixin, ...):

    permission_required = "blog.add_post"


class AddPostView(PermissionRequiredMixin, ...):

    permission_required = "blog.edit_post"

и шаблон:

{% load rules %}
{% has_perm "blog.edit_post" request.user post as can_edit %}
{% if can_edit %}
  <a href="...">Edit</a>
{% endif %}

Чтобы всё это заработало, надо добавить rules в INSTALLED_APPS и rules.permissions.ObjectPermissionBackend в AUTHENTICATION_BACKENDS.

В заключение хочу добавить, что в моём текущем проекте мы не используем именованные пермишены типа blog.edit_post. Вместо этого мы определяем ещё один предикат:

can_edit_post = is_first_day_in_month | rules.is_superuser | is_author

и напрямую вызываем его там, где надо проверить пермишен:

if can_edit_post(user, post):
    ...