Аудит прав доступа в большом Django-проекте: тонкости за рамками документации
Система прав Django выглядит простой ровно до момента, когда в проекте 80 моделей, 15 групп и четыре микросервиса. После этого начинаются сюрпризы: фантомные разрешения, тихий доступ через related-объекты и has_perm, возвращающий True там, где не должен.

Почему штатные инструменты молчат о главном
Django из коробки даёт три уровня контроля доступа: флаги is_staff / is_superuser, объектные permissions на уровне модели и группы. Документация описывает каждый из них отдельно, но почти не говорит о том, что происходит, когда они пересекаются в проекте с десятками приложений. А происходит вот что: у пользователя оказывается доступ, которого ему никто явно не давал, а аудитор (внутренний или внешний) не может за разумное время ответить на вопрос «кто именно может удалить заказ».
Разберём конкретные места, где система прав ломается, и приёмы, позволяющие это обнаружить до инцидента.
Фантомные permissions и ContentType-мусор
Каждая миграция, создающая модель, автоматически создаёт три-четыре permission-записи (add_, change_, delete_, view_ с Django 2.1+). Модель удалили или переименовали — permission остался. В таблице auth_permission копятся «мёртвые» записи, привязанные к несуществующим content_type. Это не просто мусор:
- Группа, которой назначили старый permission, формально всё ещё его «имеет». В админке это видно как строка с непонятным codename, но
has_permна неё всё равно отвечаетTrue. - Автоматические тесты, проверяющие количество permissions у группы, начинают падать непредсказуемо после каждой миграции.
- При аудите невозможно отличить реальное право от артефакта без ручной сверки с кодом.
Как обнаружить
Запрос, который покажет «осиротевшие» ContentType-записи (Django 5.x, PostgreSQL 16):
from django.contrib.contenttypes.models import ContentType
from django.apps import apps
# Собираем все реально зарегистрированные пары (app_label, model)
registered = {
(ct.app_label, ct.model)
for ct in (
ContentType(app_label=cfg.label, model=model._meta.model_name)
for cfg in apps.get_app_configs()
for model in cfg.get_models()
)
}
# Ищем записи в БД, которых нет в реестре
stale = ContentType.objects.exclude(
**{"pk__in": [
ct.pk for ct in ContentType.objects.all()
if (ct.app_label, ct.model) in registered
]}
)
for ct in stale:
perms = ct.permission_set.values_list("codename", flat=True)
print(f"Stale CT: {ct.app_label}.{ct.model} — permissions: {list(perms)}")
Если stale не пуст — это сигнал. Удалять руками через ORM не стоит без проверки: сначала убедитесь, что ни одна группа и ни один пользователь не ссылаются на эти permissions. Команда manage.py remove_stale_contenttypes делает это безопаснее, но она интерактивная и не логирует результат — в CI её придётся обернуть.
has_perm и суперпользователь: тихий обход любой логики
Документация об этом упоминает, но разработчики регулярно забывают: has_perm для суперпользователя всегда возвращает True, даже если вы написали кастомный бэкенд, который должен запрещать. Метод ModelBackend.has_perm проверяет is_active и is_superuser до вызова любой вашей логики.
Если в системе есть хотя бы один суперпользователь, который ходит не только в админку, а использует и фронтенд-API — любой permission-чек для него бессмысленен. Это не баг фреймворка, но это архитектурная ловушка, о которой стоит помнить при аудите.
Что с этим делать
Три подхода, расположенные по убыванию надёжности:
- Запретить суперпользователям доступ к production-данным через приложение. Суперпользователь нужен для админки и миграций; бизнес-логику обслуживают обычные пользователи с группами. На практике это значит: ни один аккаунт с
is_superuser=Trueне должен иметь активную сессию в основном приложении. - Переопределить бэкенд. Написать свой
AuthBackend, в которомhas_permне возвращаетTrueдля суперпользователя автоматически, а проходит ту же цепочку проверок. Цена: потеря «суперсилы» в стандартной админке, что потребует отдельных прав для администраторов. - Логировать каждое срабатывание суперпользовательского обхода. Промежуточное решение: бэкенд вызывает
super().has_perm(), но если результат былTrueтолько из-заis_superuser, пишет в лог-аудит. Так вы хотя бы видите масштаб проблемы.
Related-объекты и каскадный доступ
У пользователя есть change_order, но нет change_orderitem. Может ли он менять позиции заказа? Ответ зависит от того, как написаны view и serializer, а не от системы permissions. Если view принимает вложенный JSON и обновляет items через order.items.all() — проверка change_orderitem не вызовется, пока разработчик не добавит её явно.
Это самый частый источник «тихого» избытка прав: модели связаны через ForeignKey, а permissions проверяются только на корневом объекте.
Приём: карта записи
Перед аудитом постройте карту «какой view какие модели записывает». Это можно сделать полуавтоматически, проанализировав все вызовы .save(), .update(), .delete(), bulk_create и bulk_update внутри view-функций и serializer-методов. Результат — таблица, которая показывает разрыв между «что проверяется» и «что пишется».
| View / Serializer | Проверяемые permissions | Фактически изменяемые модели | Разрыв |
|---|---|---|---|
| OrderUpdateView | change_order | Order, OrderItem, Payment | OrderItem, Payment не проверяются |
| ProfileSerializer | change_userprofile | UserProfile, Address | Address не проверяется |
| CatalogImportView | add_product | Product, Category, Tag | Category, Tag не проверяются |
Каждая строка в столбце «Разрыв» — потенциальная уязвимость или как минимум вопрос для обсуждения с командой.
Группы, которые никто не пересматривает
В проектах старше двух лет типична ситуация: группа «Менеджер» получила delete_client на раннем этапе, когда клиентов было тридцать, а удаление было способом убрать дубли. Теперь клиентов пятьдесят тысяч, удаление ломает связанные отчёты, но право у группы осталось. Никто его не отзывал, потому что никто не проводил ревизию.
Рекомендация, которой нет в документации Django: ревизия групп и permissions раз в квартал, оформленная как задача в трекере, а не благое намерение. Минимальный чек-лист:
- Выгрузить все группы с их permissions (
Group.objects.prefetch_related('permissions')). - Для каждого permission ответить: есть ли в коде view или serializer, который его проверяет? Если нет — право не работает, оно декоративное.
- Есть ли пользователи с прямыми (не через группу) permissions? Прямые права — почти всегда признак ручного «костыля», который забудут убрать.
- Есть ли пользователи, входящие в несколько групп с пересекающимися правами? Пересечение — не ошибка, но повышает риск того, что при удалении права из одной группы оно останется через другую.
Кастомные бэкенды и порядок проверки
Django проверяет бэкенды из AUTHENTICATION_BACKENDS по порядку и возвращает True при первом положительном ответе. Если у вас два бэкенда — скажем, ModelBackend и ObjectPermissionBackend из django-guardian или аналога — порядок критичен. ModelBackend может вернуть True на уровне модели раньше, чем объектный бэкенд успеет сказать False для конкретного экземпляра.
Это не баг — это задокументированное поведение, но оно противоречит интуиции: «я ведь поставил объектные права, значит, они работают». Работают — но только если модельный бэкенд не дал ответ первым.
Сноска: объектные permissions (object-level permissions) — механизм, при котором право проверяется не для модели в целом, а для конкретного экземпляра. Django определяет интерфейс (has_perm(user, perm, obj)), но штатный ModelBackend игнорирует аргумент obj — для реальной работы нужен сторонний бэкенд вроде django-guardian или django-rules.
Логирование: чего не хватает по умолчанию
Стандартная модель LogEntry в django.contrib.admin пишет только действия в админке. Если пользователь меняет данные через API — следа нет. Для аудита прав это означает: вы не можете ответить на вопрос «кто и когда воспользовался правом delete_order», если удаление произошло через REST-эндпоинт.
Минимальное решение — middleware или mixin для DRF-view, который при каждом мутирующем запросе (POST, PUT, PATCH, DELETE) записывает: пользователь, endpoint, результат permission-проверки, timestamp. Хранить можно в отдельной таблице или отправлять в структурированный лог. Подробности реализации зависят от стека логирования — здесь важен сам принцип: без записи факта использования права аудит невозможен.
Что делать дальше
Начните с двух вещей: постройте карту записи (таблица «view — проверяемые permissions — фактически изменяемые модели») и выгрузите все группы с их правами. Это даст материал для первого аудита за пару часов. Затем — настройте логирование permission-проверок на мутирующих эндпоинтах и поставьте квартальную задачу на ревизию групп. Всё остальное — объектные права, кастомные бэкенды, разделение суперпользователей — решается по мере обнаружения разрывов в карте.
Защитник прав потребителей: Досудебные претензии на возврат товара со ссылками на ЗоЗПП.
Спросить за 15 ₽Источники: Django Documentation — Authentication system: permissions and authorization, Django Documentation — Customizing authentication backends
Частые вопросы
Как найти permissions, которые есть в базе, но не используются в коде?
Суперпользователь нужен для работы django-admin — как ограничить его права в остальном приложении?
У нас django-guardian для объектных прав, но пользователи всё равно видят чужие объекты. В чём может быть причина?
Как часто нужно проводить аудит прав и сколько это занимает?
Что будет, если не чистить stale ContentType и фантомные permissions?
Можно ли автоматизировать проверку разрывов между проверяемыми правами и фактически изменяемыми моделями?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.