Правильный RBAC

Правильный RBAC

Четыре оси доступа: как мы построили 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 .

← Cybersecurity