Четыре оси доступа: как мы построили RBAC, который реально защищает (на примере FARA CRM)
Третья статья в блоге FARA CRM. В первых двух мы рассказывали про работу с файлами и обзор fara crm. Сегодня — про то, без чего CRM нельзя выпускать в прод: про разграничение доступа. Не «роли и галочки», а инженерно проработанную систему из четырёх независимых осей.
RBAC знают все. Вопрос — как его правильно реализовать
Спросите любого инженера, как разграничить доступ в приложении, — услышите RBAC: роли и права. Но RBAC — это концепция, а не рецепт. Совет «заведите роли и выдайте им права» не говорит ничего о том, как это сделать, когда доходит до реального продукта.
А в продукте почти сразу всплывают вопросы, на которые модель «роль → право» ответить не может:
-
Менеджер работает с лидами. Со всеми — или только со своими?
-
Пользователь редактирует свой профиль. А поле «Роли» или «Суперпользователь» внутри этого профиля — тоже может менять? (если да — он одним кликом сделает себя администратором)
-
Суперадмин снимает админские права. А с последнего оставшегося админа? С себя?
Ключевое наблюдение: эти вопросы — разного уровня. Один про таблицу целиком, другой про конкретную строку, третий про отдельное поле, четвёртый про допустимое значение. «Плоский» RBAC отвечает только на первый. Остальные в большинстве проектов превращаются в россыпь if-ов по коду — а это прямой источник уязвимостей (то самое самоназначение роли — классический privilege escalation через mass-assignment, на котором в 2012-м взломали GitHub).
Мы формализовали ответ в концепцию из четырёх независимых осей доступа — вместе они закрывают все четыре уровня. Это по-прежнему RBAC, просто доведённый до конца. О нём и пойдёт речь.
Почему «роль → право» — это только первый слой
Классический RBAC выглядит так:
Пользователь → Роли → Права (permissions)
Менеджеру выдали роль manager, у роли есть право lead.update — значит, менеджер может редактировать лиды. Просто, понятно, работает в большинстве случаев.
Проблема в том, что такое «право» — это галочка уровня таблицы целиком. Оно отвечает ровно на один вопрос: «может ли роль в принципе выполнять C/R/U/D над этой моделью?» — и на этом останавливается.
А реальные требования живут на четырёх уровнях: таблица → строка → поле → значение. Плоский RBAC закрывает только первый. Остальные три в большинстве проектов оседают прямо в коде обработчиков:
if not user.is_admin and lead.owner_id != user.id: raise PermissionError()
Такие проверки невозможно поддерживать: они разбросаны по сотням мест, их легко забыть добавить, их нельзя проаудировать централизованно. Мы пошли другим путём — разложили доступ на четыре независимые оси и сделали каждую декларативной.
Четыре оси доступа
Чтобы было наглядно, представьте охраняемое здание:
|
Ось |
Аналогия |
Вопрос, на который отвечает |
|---|---|---|
|
ACL |
Охранник на входе |
Пустят ли тебя в это здание (таблицу) вообще — и читать, или ещё и менять? |
|
RAC |
Пропуск в комнаты |
В какие именно комнаты (строки) ты можешь зайти? |
|
FAC |
Что можно трогать |
Что в комнате тебе разрешено трогать (поля)? |
|
VAC |
Что можно делать |
А из того, что трогаешь, какие действия допустимы (нельзя выкрутить рубильник всего здания)? |
Эти оси ортогональны: каждая решает свою задачу и ничего не знает о других. Запрос проходит их по очереди, как через четыре поста охраны. Разберём каждую.
1. ACL — доступ к таблице
ACL (Access Control List) — это базовый слой, тот самый «обычный RBAC». Одна запись ACL = связка роль × модель × флаги CRUD.

В FARA права описываются декларативно, прямо в модуле, через миксин:
class LeadsApp(ACLPostInitMixin, App): # Права для базовой роли BASE_USER_ACL = { "lead": ACL.FULL, # CRUD "lead_stage": ACLPerms(create=True, read=True, update=True, delete=False), } # Права для остальных ролей ROLE_ACL = { "manager": {"lead": ACL.NO_DELETE}, # всё, кроме удаления "viewer": {"lead": ACL.READ_ONLY}, # только чтение }
Готовые пресеты закрывают типовые случаи и читаются как обычный текст:
Что важно: ACL отвечает только на вопрос «можно ли работать с таблицей вообще». В примере с самоназначением роли ACL отработал бы штатно — у пользователя есть право обновлять свою запись. Дело не в нём; чтобы закрыть такой случай, нужны следующие оси.
2. RAC — доступ к строкам
RAC (Rules Access Control) отвечает на вопрос «с какими именно записями таблицы». Это правила (Rule), которые добавляют к запросу фильтр — domain.

Классический пример — «менеджер видит только свои лиды»:
[["user_id", "=", "{{user_id}}"]]
{{user_id}} подставляется на лету. А правило «пользователь может редактировать только свой профиль» — то самое, что должно было остановить эскалацию на уровне строки, но не остановило, потому что профиль и был его собственным:
{ "name": "User can only edit own profile", "role_id": base_user_role_id, "domain": [["id", "=", "{{user_id}}"]], "perm_update": True,}
Ключевая инженерная деталь: правила транслируются в SQL WHERE и уезжают в базу. Мы не вытаскиваем все строки в приложение, чтобы потом отфильтровать — это было бы и медленно, и небезопасно. База сразу отдаёт только разрешённое:
SELECT ... FROM leadsWHERE (бизнес-фильтр) AND (user_id = 42 OR team_id IN (1,2,3))
Если у роли несколько правил на одну операцию — они объединяются через OR (достаточно попасть под любое). А для сложных случаев есть кастомные операторы вроде @is_member (членство в чате/проекте) или @has_parent_access (каскад прав через родителя). Но снаружи это всё тот же декларативный domain.
3. FAC — доступ к полям
Самая недооценённая ось. FAC (Field Access Control) отвечает на вопрос «какие поля записи» можно писать. Именно она ловит самоназначение роли из примера выше: поле «Роли» — обычное поле профиля, и без FAC его никто не охраняет.
class User(DotModel): # Менять роли может только «Администратор настроек» role_ids: list["Role"] = Many2many( role_update="system_admin", ... ) # Ставить/снимать суперпользователя — только суперпользователь is_admin: bool = Boolean( default=False, role_create=SUPERUSER, role_update=SUPERUSER, )
Атрибуты role_read / role_create / role_update задают, кому разрешена операция над конкретным полем. Случай с самоназначением роли закрывается одной строкой: role_update="system_admin". Теперь обычный пользователь, попытавшийся выдать себе роль, получит отказ — несмотря на то что запись его собственная и ACL/Rules его пропустили.

Тонкость, которая отличает рабочее решение от «вроде работает». Проверять надо не присутствие поля в запросе, а его реальное изменение. Форма на фронте присылает is_admin при каждом сохранении — и если блокировать любое присутствие поля, то «Администратор настроек» не сможет сохранить даже имя пользователя (потому что в payload приедет неизменённый is_admin). Поэтому проверка change-based:
# Блокируем поле, только если значение действительно поменялосьif cls._field_value_changes(field, name, payload, current): # ... и только теперь спрашиваем, вправе ли роль его менять
Холостой повтор того же значения — не изменение, он проходит. Бонус: так автоматически решается и проблема «обязательное поле с ограничением» — если его прислали без изменения, оно не упрётся в проверку.
4. VAC — доступ к значениям
Самая тонкая ось. VAC (Value Access Control) отвечает на вопрос «какие именно значения» допустимо положить в поле — даже если право его менять у тебя есть.
Классический пример — суперадмин снимает галочку is_admin. Право на это поле у него есть (FAC пропускает). Но есть бизнес-инварианты, которые нельзя нарушать:
if payload.is_admin is False: # Нельзя снять админа с самого себя if current_user.id == self.id: raise FaraException("YOU_CANNOT_REVOKE_YOUR_OWN_ADMIN_STATUS") # Нельзя удалить последнего администратора в системе if active_admins_count <= 1: raise FaraException("CANNOT_REMOVE_THE_LAST_ADMINISTRATOR")

Сюда же относятся правила вроде «менеджер может назначить роль, но не выше своей собственной» или «скидку больше 20% подтверждает только руководитель». Это не про поле как таковое, а про конкретное значение в контексте — поэтому VAC живёт в бизнес-логике модели, рядом с самим инвариантом, а не в общей таблице прав.
Это единственная ось, которая остаётся императивной (кодом). И это правильно: значимые инварианты слишком разнообразны, чтобы загонять их в декларативную схему — попытка это сделать обычно рождает нечитаемый «движок правил», который никто не понимает.
Также часть этой логике может уходить в схемы валидации (пидантик), когда мы например устанавливаем валидацию на конкретное поле.
Это и есть RBAC — только продуманный
Теперь главный тезис. Всё перечисленное — не замена RBAC, а его правильная реализация.
Плоский RBAC — это только первая ось (ACL). Он отвечает «может ли роль трогать таблицу» и останавливается. «Продуманный» RBAC добавляет ещё три измерения, и вместе они дают полную картину:
┌─────────────────────────────────────────┐ Запрос ───► │ ACL может роль работать с таблицей? │ ─► нет → 403 ├─────────────────────────────────────────┤ │ RAC с какими строками? (→ SQL WHERE) │ ─► фильтр ├─────────────────────────────────────────┤ │ FAC какие поля разрешено менять? │ ─► нет → 403 ├─────────────────────────────────────────┤ │ VAC допустимо ли это значение? │ ─► нет → 403 └─────────────────────────────────────────┘ │ ▼ запись в БД

И всё это завязано на роли с наследованием. Роль может расширять другую через based_role_ids: crm_admin → crm_manager → crm_user → base_user. Права (ACL и Rules) собираются по всему дереву наследования одним рекурсивным SQL-запросом (CTE) — никаких N+1, независимо от глубины иерархии.
Почему «обычный RBAC не подойдёт»? Потому что он застревает на первой оси. Как только бизнесу нужно «своё/чужое», «нельзя трогать это поле» или «нельзя снять последнего админа» — плоский RBAC отвечает россыпью хардкода. А четыре оси отвечают декларативно и в одном месте.
Сравнение с другими подходами
Сравним наш слоёный RBAC с типовыми альтернативами по четырём критериям: скорость, масштабируемость, гибкость, поддерживаемость.
|
Подход |
Гранулярность |
Скорость |
Масштабируемость |
Гибкость |
Поддержка |
|---|---|---|---|---|---|
|
Плоский RBAC |
только таблица |
🟢 высокая |
🟢 хорошая |
🔴 низкая |
🟢 простая |
|
Хардкод |
любая |
🟢 высокая |
🔴 плохая |
🟡 любая, но руками |
🔴 ад |
|
ABAC (политики/атрибуты) |
любая |
🟡 средняя* |
🟡 средняя |
🟢 максимальная |
🟡 сложная |
|
Postgres RLS |
строка |
🟢 высокая |
🟢 хорошая |
🔴 только строки |
🔴 вне кода приложения |
|
Слоёный RBAC (FARA) |
таблица+строка+поле+значение |
🟢 высокая |
🟢 хорошая |
🟢 высокая |
🟢 декларативная |
ABAC вычисляет политику на каждый запрос; при росте числа правил деградирует.*
Разберём по критериям.
Скорость. Узкое место в правах — это всегда уровень строк (их много). Мы решаем его тем, что проталкиваем фильтр в SQL — база сразу возвращает разрешённое. Проверки полей и значений выполняются только на запись (не на чтение) и в памяти, без обращения к БД (роли уже развёрнуты и лежат в сессии). Итог: на «горячем» пути чтения — один запрос, на записи — несколько дешёвых in-memory проверок.
Масштабируемость. Иерархия ролей раскрывается рекурсивным CTE — один запрос вне зависимости от глубины. Права кэшируются в сессии и инвалидируются через шину pg_notify при изменении ролей. ABAC же с ростом числа политик начинает тормозить (каждый запрос — прогон всех правил), а хардкод не масштабируется организационно — его невозможно поддерживать в большой команде.
Гибкость. Четыре ортогональные оси покрывают практически любой реальный кейс: от «только чтение» до «менеджер видит лиды своей команды, может менять стадию, но не сумму, и не может выставить скидку выше своего лимита». ABAC формально гибче (произвольные атрибуты), но платит за это сложностью и скоростью. Плоский RBAC и RLS — жёстко ограничены своей единственной гранулярностью.
Поддерживаемость. Три из четырёх осей декларативны: ACL и Rules — это данные (их можно править через UI без деплоя), FAC — это атрибут на поле рядом с его объявлением. Аудит сводится к чтению одной таблицы и одного места в модели. Только VAC остаётся кодом — но это осознанный выбор: бизнес-инварианты и должны жить рядом с бизнес-логикой. Сравните с хардкодом, где правило доступа может прятаться в любом из сотен обработчиков.
Почему мы не взяли готовое решение
Резонный вопрос: зачем своё, если есть Casbin, Pundit, Oso, RLS? Короткий ответ — ни одно не закрывает все четыре оси одинаково хорошо и нативно для ORM:
-
Postgres RLS силён на строках, но не знает про роли приложения и не покрывает поля/значения; политики живут в БД, отдельно от кода.
-
ABAC-движки (Casbin, Oso) мощны и гибки, но это отдельный слой со своим языком политик: растёт сложность, аудит размазывается между моделью и политикой, скорость падает с числом правил.
-
Pundit/CanCanCan — про код-политики; снова императивно и не про поля/значения «из коробки».
А у нас уже был собственный ORM (DotORM) с декларативными моделями. Логично было встроить доступ в саму модель: ACL/Rules как данные поверх неё, FAC как атрибут поля, VAC как метод модели. Получилось то, что не нужно держать в голове отдельно от данных — оно описано там же, где и сами данные.
Итог
Безопасный доступ — это не «роли и галочки». Это четыре независимых вопроса, на каждый из которых нужно ответить осознанно:
-
ACL — может ли роль работать с таблицей?
-
RAC — с какими строками?
-
FAC — какие поля можно менять?
-
VAC — какие значения допустимы?
Плоский RBAC отвечает только на первый и оставляет остальные три на откуп хардкоду — со всеми вытекающими дырами вроде «пользователь сделал себя админом». Сложив четыре оси и завязав их на иерархию ролей, мы получили RBAC, который реально защищает: быстрый (фильтры в SQL, проверки в памяти), масштабируемый (CTE + кэш + шина), гибкий (любая гранулярность) и поддерживаемый (три оси из четырёх — декларативны).
Но главная ценность — даже не в отдельных проверках, а в общем языке. Когда доступ разложен на четыре оси, про него перестаёшь думать в стиле «кажется, тут надо что-то добавить». Вопрос ставится иначе: «на каком это уровне — таблица, строка, поле или значение?» — и сразу ясно, куда он ложится и кто за него отвечает.
FARA CRM — открытая CRM с собственным ORM и продуманной системой доступа. GitHub.
ссылка на оригинал статьи https://habr.com/ru/articles/1052068/