Permissions, Groups, decorators, mixins, object-level permissions
После аутентификации приложение знает пользователя. Авторизация сопоставляет пользователя, действие и ресурс. Ошибка «у него есть change_post, значит он может изменить любой пост» возникает, когда model-level permission подменяет object-level правило.
Материал актуален для Django 5.2 LTS и 6.0.
Для каждой managed model Django создаёт codenames:
view_post;add_post;change_post;delete_post.Полное имя включает app label: blog.change_post. Permission связан с ContentType, поэтому один codename не гарантирует уникальность во всей базе:
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from blog.models import Post
content_type = ContentType.objects.get_for_model(Post)
permission = Permission.objects.get(
content_type=content_type,
codename="change_post",
)Стандартный ModelBackend проверяет model-level permissions. user.has_perm("blog.change_post") не знает, принадлежит ли пользователю конкретный пост.
class Post(models.Model):
# поля модели
class Meta:
permissions = [
("publish_post", "Can publish post"),
("moderate_post", "Can moderate post"),
]Проверка должна использовать ровно тот же codename:
request.user.has_perm("blog.publish_post")Записи Permission создаются обработчиком post_migrate при выполнении migrate, а не самой командой makemigrations. Изменение Meta может породить migration state, но строка permission появляется после миграций. Поэтому bootstrap ролей запускают после migrate.
Permission описывает возможность, но не реализует переход состояния. Операция публикации отдельно проверяет текущий статус, обязательные поля и конкурентное изменение.
Group объединяет permissions, а пользователь наследует разрешения всех своих групп:
from django.contrib.auth.models import Group
editors, _ = Group.objects.get_or_create(name="Editors")
editors.permissions.add(permission)
user.groups.add(editors)Группы удобны для стабильных ролей вроде Editor/Moderator. Но проверка строки user.groups.filter(name="Editors") связывает бизнес-код с названием роли. Если действие уже описано permission, проверяйте permission; состав группы тогда можно менять без переписывания views.
Прямые user_permissions полезны для исключений, но усложняют аудит. Обычно базовый доступ дают группами, а индивидуальные выдачи документируют и ограничивают сроком.
has_perm() и кэш экземпляраif request.user.has_perm("blog.publish_post"):
...
if request.user.has_perms([
"blog.change_post",
"blog.publish_post",
]):
...has_perms() требует все перечисленные permissions. Результат собирается authentication backends; custom backend может изменить поведение.
ModelBackend кэширует permissions на экземпляре пользователя после первой проверки. Если тест или admin-код добавил group/permission, прежний user может не увидеть изменение. Надёжный путь — получить пользователя заново из базы, а не очищать приватные _perm_cache вручную.
Активный superuser получает все model-level permissions через стандартный backend. Это не означает автоматический успех любой самописной проверки владельца: если политика должна разрешать superuser обход, укажите это явно и протестируйте.
from django.contrib.auth.decorators import permission_required
from django.views.decorators.http import require_POST
@require_POST
@permission_required("blog.publish_post", raise_exception=True)
def publish_post(request, pk):
post = get_object_or_404(Post, pk=pk, status=Post.Status.PENDING)
post.publish(by=request.user)
return redirect("blog:post_detail", slug=post.slug)raise_exception=True даёт 403 при отсутствии permission. Без него декоратор направляет на login URL, что может создать redirect loop для уже вошедшего пользователя без права. Для HTML-продукта явно решите: anonymous идёт на login, authenticated без права получает 403.
Изменение состояния выполняется только небезопасным HTTP-методом с CSRF, не GET-ссылкой. Метод publish() должен сохранять инварианты и аудит; один флаг во view быстро начинает дублироваться.
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.views.generic import UpdateView
class PostUpdateView(
LoginRequiredMixin,
PermissionRequiredMixin,
UpdateView,
):
permission_required = "blog.change_post"
raise_exception = True
model = Post
form_class = PostForm
def get_queryset(self):
queryset = super().get_queryset()
if self.request.user.is_superuser:
return queryset
return queryset.filter(author=self.request.user)Mixin проверяет model-level permission, а get_queryset() ограничивает конкретные строки. Такое сочетание выражает политику «автор с общей возможностью редактирования — только свои; superuser — все». Если автору не нужен общий permission, уберите PermissionRequiredMixin и оставьте аутентификацию плюс ownership.
Не вызывайте self.get_object() много раз из разных mixin: это даёт повторные запросы и усложняет MRO. Ограниченный QuerySet обычно проще и не раскрывает существование чужого объекта.
Для простого «только свои» достаточно:
post = get_object_or_404(
Post,
pk=pk,
author=request.user,
)Это одновременно выборка и запрет. При multi-tenant сначала ограничивают tenant, затем владельца; ни один id из URL не должен обходить эту область.
Когда правило зависит от статуса, роли и участника ресурса, вынесите его в чистую функцию:
def can_edit_post(*, user, post) -> bool:
if not user.is_authenticated or not user.is_active:
return False
if user.is_superuser:
return True
return post.author_id == user.pk and post.status == Post.Status.DRAFTPolicy легко тестировать таблицей сценариев. QuerySet всё равно ограничивают tenant/visibility до передачи объекта в policy.
Django core передаёт obj в user.has_perm(perm, obj), но стандартный ModelBackend не хранит назначения на объекты. Сторонний django-guardian добавляет backend и таблицы object permissions:
INSTALLED_APPS += ["guardian"]
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"guardian.backends.ObjectPermissionBackend",
]from guardian.shortcuts import assign_perm, get_objects_for_user
assign_perm("blog.change_post", user, post)
allowed = user.has_perm("blog.change_post", post)
posts = get_objects_for_user(
user,
"blog.change_post",
klass=Post,
)Специальный model mixin для базовой работы Guardian не обязателен. Direct foreign-key permission models — отдельная оптимизация, которую добавляют по документации библиотеки и с миграциями.
Guardian не отменяет tenant scope и проверку действия. Также решите, должны ли глобальные model permissions автоматически давать доступ к каждому объекту: параметры shortcut влияют на результат.
Право на «удалить пост» не значит, что можно удалить любой переданный id. Сначала сформируйте разрешённый QuerySet, затем изменяйте строки. Массовая action должна проверить каждый объект или целиком фильтровать QuerySet политикой.
Проверка только в Model.delete() недостаточна: у модели нет request user, а QuerySet.delete() и cascade имеют другой путь. Лучше держать use case с явным actor и запретить обход на уровне вызывающего слоя; для особенно чувствительных данных добавляют database RLS как отдельную defense-in-depth политику.
Идемпотентная management command может задать роли:
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
from blog.models import Post
class Command(BaseCommand):
def handle(self, *args, **options):
content_type = ContentType.objects.get_for_model(Post)
permissions = Permission.objects.filter(
content_type=content_type,
codename__in={"view_post", "change_post", "publish_post"},
)
editor, _ = Group.objects.get_or_create(name="Editors")
editor.permissions.set(permissions)set() намеренно заменяет весь набор. Это правильно, если command — источник истины для системной группы, и опасно, если администраторы вручную добавляют права: следующий deploy их удалит. Выберите ownership конфигурации и зафиксируйте его в runbook.
Для критичных ролей тест проверяет точный набор codenames после bootstrap. Не ищите permissions только по codename и не предполагайте, что они существуют до migrate.
django.contrib.auth регистрирует стандартные User и Group в admin. Повторная @admin.register(User) или Group без unregister() приводит к AlreadyRegistered.
Для custom user наследуйте UserAdmin, добавьте поля в fieldsets/add_fieldsets и зарегистрируйте свою модель один раз. Inline through-таблиц для groups/permissions обычно не нужен: стандартный UserAdmin уже предоставляет интерфейс.
{% if perms.blog.publish_post %}
<form method="post" action="{% url 'blog:publish' post.pk %}">
{% csrf_token %}
<button type="submit">Опубликовать</button>
</form>
{% endif %}Условие улучшает интерфейс, но endpoint обязан повторить проверку. Пользователь может отправить HTTP-запрос без кнопки.
Permissions, Groups, decorators и mixins актуальны. Частые legacy-проблемы — permission lookup без content_type, несовпадающие codenames, проверка названия группы вместо действия и предположение, что model permission ограничивает владельца.
Custom permissions создаются после migrate через post_migrate. Старые инструкции «достаточно запустить makemigrations» описывают изменение model state, но не момент появления строк в таблице auth_permission.
Далее: Django Admin: Кастомизация