
Четыре оси доступа: как мы построили RBAC, который реально защищает (на примере FARA CRM)
Третья статья в блоге FARA CRM. В первых двух мы рассказывали про работу с файлами и обзор fara crm . Сегодня — про то, без чего CRM нельзя выпускать в прод: про разграничение доступа. Не «роли и галочки», а инженерно проработанную систему из четырёх независимых осей.
Третья статья в блоге FARA CRM. В первых двух мы рассказывали про работу с файлами и обзор fara crm . Сегодня — про то, без чего CRM нельзя выпускать в прод: про разграничение доступа. Не «роли и галочки», а инженерно проработанную систему из четырёх независимых осей.
RBAC знают все. Вопрос — как его правильно реализовать
Спросите любого инженера, как разграничить доступ в приложении, — услышите RBAC : роли и права. Но RBAC — это концепция , а не рецепт. Совет «заведите роли и выдайте им права» не говорит ничего о том, как это сделать, когда доходит до реального продукта.
А в продукте почти сразу всплывают вопросы, на которые модель «роль → право» ответить не может:
Менеджер работает с лидами. Со всеми — или только со своими ?
Менеджер работает с лидами. Со всеми — или только со своими ?
Пользователь редактирует свой профиль. А поле «Роли» или «Суперпользователь» внутри этого профиля — тоже может менять? (если да — он одним кликом сделает себя администратором)
Пользователь редактирует свой профиль. А поле «Роли» или «Суперпользователь» внутри этого профиля — тоже может менять? (если да — он одним кликом сделает себя администратором)
Суперадмин снимает админские права. А с последнего оставшегося админа? С себя ?
Суперадмин снимает админские права. А с последнего оставшегося админа? С себя ?
Ключевое наблюдение: эти вопросы — разного уровня . Один про таблицу целиком, другой про конкретную строку, третий про отдельное поле, четвёртый про допустимое значение. «Плоский» RBAC отвечает только на первый. Остальные в большинстве проектов превращаются в россыпь if -ов по коду — а это прямой источник уязвимостей (то самое самоназначение роли — классический privilege escalation через mass-assignment , на котором в 2012-м взломали GitHub).
Мы формализовали ответ в концепцию из четырёх независимых осей доступа — вместе они закрывают все четыре уровня. Это по-прежнему RBAC, просто доведённый до конца. О нём и пойдёт речь.
Почему «роль → право» — это только первый слой
Классический RBAC выглядит так:
Менеджеру выдали роль manager , у роли есть право lead.update — значит, менеджер может редактировать лиды. Просто, понятно, работает в большинстве случаев.
Проблема в том, что такое «право» — это галочка уровня таблицы целиком . Оно отвечает ровно на один вопрос: «может ли роль в принципе выполнять C/R/U/D над этой моделью?» — и на этом останавливается.
А реальные требования живут на четырёх уровнях : таблица → строка → поле → значение. Плоский RBAC закрывает только первый. Остальные три в большинстве проектов оседают прямо в коде обработчиков:
Такие проверки невозможно поддерживать: они разбросаны по сотням мест, их легко забыть добавить, их нельзя проаудировать централизованно. Мы пошли другим путём — разложили доступ на четыре независимые оси и сделали каждую декларативной.
Четыре оси доступа
Чтобы было наглядно, представьте охраняемое здание:
Вопрос, на который отвечает
Охранник на входе
Пустят ли тебя в это здание (таблицу) вообще — и читать, или ещё и менять?
Пропуск в комнаты
В какие именно комнаты (строки) ты можешь зайти?
Что можно трогать
Что в комнате тебе разрешено трогать (поля)?
Что можно делать
А из того, что трогаешь, какие действия допустимы (нельзя выкрутить рубильник всего здания)?
Эти оси ортогональны : каждая решает свою задачу и ничего не знает о других. Запрос проходит их по очереди, как через четыре поста охраны. Разберём каждую.
ACL (Access Control List) — это базовый слой, тот самый «обычный RBAC». Одна запись ACL = связка роль × модель × флаги CRUD .
В FARA права описываются декларативно , прямо в модуле, через миксин:
Готовые пресеты закрывают типовые случаи и читаются как обычный текст:
ACL.FULL
ACL.READ _ONLY
ACL.NO _DELETE
ACL.NO _ACCESS
Что важно: ACL отвечает только на вопрос «можно ли работать с таблицей вообще». В примере с самоназначением роли ACL отработал бы штатно — у пользователя есть право обновлять свою запись. Дело не в нём; чтобы закрыть такой случай, нужны следующие оси.
RAC (Rules Access Control) отвечает на вопрос « с какими именно записями таблицы ». Это правила ( Rule ), которые добавляют к запросу фильтр — domain .
Классический пример — «менеджер видит только свои лиды»:
{{user_id}} подставляется на лету. А правило «пользователь может редактировать только свой профиль» — то самое, что должно было остановить эскалацию на уровне строки, но не остановило, потому что профиль и был его собственным:
Ключевая инженерная деталь: правила транслируются в SQL WHERE и уезжают в базу . Мы не вытаскиваем все строки в приложение, чтобы потом отфильтровать — это было бы и медленно, и небезопасно. База сразу отдаёт только разрешённое:
Если у роли несколько правил на одну операцию — они объединяются через OR (достаточно попасть под любое). А для сложных случаев есть кастомные операторы вроде @is_member (членство в чате/проекте) или @has_parent_access (каскад прав через родителя). Но снаружи это всё тот же декларативный domain.
Самая недооценённая ось. FAC (Field Access Control) отвечает на вопрос « какие поля записи » можно писать. Именно она ловит самоназначение роли из примера выше: поле «Роли» — обычное поле профиля, и без FAC его никто не охраняет.
Атрибуты role_read / role_create / role_update задают, кому разрешена операция над конкретным полем . Случай с самоназначением роли закрывается одной строкой: role_update="system_admin" . Теперь обычный пользователь, попытавшийся выдать себе роль, получит отказ — несмотря на то что запись его собственная и ACL/Rules его пропустили.
Тонкость, которая отличает рабочее решение от «вроде работает». Проверять надо не присутствие поля в запросе, а его реальное изменение . Форма на фронте присылает is_admin при каждом сохранении — и если блокировать любое присутствие поля, то «Администратор настроек» не сможет сохранить даже имя пользователя (потому что в payload приедет неизменённый is_admin ). Поэтому проверка change-based :
Холостой повтор того же значения — не изменение, он проходит. Бонус: так автоматически решается и проблема «обязательное поле с ограничением» — если его прислали без изменения, оно не упрётся в проверку.
Самая тонкая ось. VAC (Value Access Control) отвечает на вопрос « какие именно значения » допустимо положить в поле — даже если право его менять у тебя есть.
Классический пример — суперадмин снимает галочку is_admin . Право на это поле у него есть (FAC пропускает). Но есть бизнес-инварианты, которые нельзя нарушать:
Сюда же относятся правила вроде «менеджер может назначить роль, но не выше своей собственной» или «скидку больше 20% подтверждает только руководитель». Это не про поле как таковое, а про конкретное значение в контексте — поэтому VAC живёт в бизнес-логике модели, рядом с самим инвариантом, а не в общей таблице прав.
Это единственная ось, которая остаётся императивной (кодом). И это правильно: значимые инварианты слишком разнообразны, чтобы загонять их в декларативную схему — попытка это сделать обычно рождает нечитаемый «движок правил», который никто не понимает. Также часть этой логике может уходить в схемы валидации (пидантик), когда мы например устанавливаем валидацию на конкретное поле.
Это и есть RBAC — только продуманный
Теперь главный тезис. Всё перечисленное — не замена RBAC, а его правильная реализация .
Плоский RBAC — это только первая ось (ACL). Он отвечает «может ли роль трогать таблицу» и останавливается. «Продуманный» RBAC добавляет ещё три измерения, и вместе они дают полную картину:
И всё это завязано на роли с наследованием . Роль может расширять другую через based_role_ids : crm_admin → crm_manager → crm_user → base_user . Права (ACL и Rules) собираются по всему дереву наследования одним рекурсивным SQL-запросом (CTE) — никаких N+1, независимо от глубины иерархии.
Почему «обычный RBAC не подойдёт»? Потому что он застревает на первой оси. Как только бизнесу нужно «своё/чужое», «нельзя трогать это поле» или «нельзя снять последнего админа» — плоский RBAC отвечает россыпью хардкода. А четыре оси отвечают декларативно и в одном месте.
Сравнение с другими подходами
Сравним наш слоёный RBAC с типовыми альтернативами по четырём критериям: скорость , масштабируемость , гибкость , поддерживаемость .
только таблица
🟢 высокая
🟢 хорошая
🔴 низкая
🟢 простая
Хардкод if -проверок
любая
🟢 высокая
🔴 плохая
🟡 любая, но руками
🔴 ад
ABAC (политики/атрибуты)
любая
🟡 средняя*
🟡 средняя
🟢 максимальная
🟡 сложная
строка
🟢 высокая
🟢 хорошая
🔴 только строки
🔴 вне кода приложения
Слоёный 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 силён на строках, но не знает про роли приложения и не покрывает поля/значения; политики живут в БД, отдельно от кода.
Postgres RLS силён на строках, но не знает про роли приложения и не покрывает поля/значения; политики живут в БД, отдельно от кода.
ABAC-движки (Casbin, Oso) мощны и гибки, но это отдельный слой со своим языком политик: растёт сложность, аудит размазывается между моделью и политикой, скорость падает с числом правил.
ABAC-движки (Casbin, Oso) мощны и гибки, но это отдельный слой со своим языком политик: растёт сложность, аудит размазывается между моделью и политикой, скорость падает с числом правил.
Pundit/CanCanCan — про код-политики; снова императивно и не про поля/значения «из коробки».
Pundit/CanCanCan — про код-политики; снова императивно и не про поля/значения «из коробки».
А у нас уже был собственный ORM (DotORM) с декларативными моделями. Логично было встроить доступ в саму модель : ACL/Rules как данные поверх неё, FAC как атрибут поля, VAC как метод модели. Получилось то, что не нужно держать в голове отдельно от данных — оно описано там же, где и сами данные.
Безопасный доступ — это не «роли и галочки». Это четыре независимых вопроса , на каждый из которых нужно ответить осознанно:
ACL — может ли роль работать с таблицей?
ACL — может ли роль работать с таблицей?
RAC — с какими строками?
RAC — с какими строками?
FAC — какие поля можно менять?
FAC — какие поля можно менять?
VAC — какие значения допустимы?
VAC — какие значения допустимы?
Плоский RBAC отвечает только на первый и оставляет остальные три на откуп хардкоду — со всеми вытекающими дырами вроде «пользователь сделал себя админом». Сложив четыре оси и завязав их на иерархию ролей, мы получили RBAC, который реально защищает: быстрый (фильтры в SQL, проверки в памяти), масштабируемый (CTE + кэш + шина), гибкий (любая гранулярность) и поддерживаемый (три оси из четырёх — декларативны).
Но главная ценность — даже не в отдельных проверках, а в общем языке . Когда доступ разложен на четыре оси, про него перестаёшь думать в стиле «кажется, тут надо что-то добавить». Вопрос ставится иначе: «на каком это уровне — таблица, строка, поле или значение?» — и сразу ясно, куда он ложится и кто за него отвечает.
FARA CRM — открытая CRM с собственным ORM и продуманной системой доступа. GitHub .
FARA CRM — открытая CRM с собственным ORM и продуманной системой доступа. GitHub .