Правильный RBAC

от автора

Четыре оси доступа: как мы построили 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},   # только чтение    }

Готовые пресеты закрывают типовые случаи и читаются как обычный текст:

Пресет

C

R

U

D

ACL.FULL

ACL.READ_ONLY

ACL.NO_DELETE

ACL.NO_ACCESS

Что важно: 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

только таблица

🟢 высокая

🟢 хорошая

🔴 низкая

🟢 простая

Хардкод if-проверок

любая

🟢 высокая

🔴 плохая

🟡 любая, но руками

🔴 ад

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 как метод модели. Получилось то, что не нужно держать в голове отдельно от данных — оно описано там же, где и сами данные.


Итог

Безопасный доступ — это не «роли и галочки». Это четыре независимых вопроса, на каждый из которых нужно ответить осознанно:

  1. ACLможет ли роль работать с таблицей?

  2. RACс какими строками?

  3. FACкакие поля можно менять?

  4. VACкакие значения допустимы?

Плоский RBAC отвечает только на первый и оставляет остальные три на откуп хардкоду — со всеми вытекающими дырами вроде «пользователь сделал себя админом». Сложив четыре оси и завязав их на иерархию ролей, мы получили RBAC, который реально защищает: быстрый (фильтры в SQL, проверки в памяти), масштабируемый (CTE + кэш + шина), гибкий (любая гранулярность) и поддерживаемый (три оси из четырёх — декларативны).

Но главная ценность — даже не в отдельных проверках, а в общем языке. Когда доступ разложен на четыре оси, про него перестаёшь думать в стиле «кажется, тут надо что-то добавить». Вопрос ставится иначе: «на каком это уровне — таблица, строка, поле или значение?» — и сразу ясно, куда он ложится и кто за него отвечает.


FARA CRM — открытая CRM с собственным ORM и продуманной системой доступа. GitHub.

ссылка на оригинал статьи https://habr.com/ru/articles/1052068/