Не так давно в отдельной статье я описывал опыт построения примитивной ERP-lite системы, ориентированной на малый бизнес РФ. В общем виде были проговорены основные архитектурные и доменные проблемы, с решением которых возникли трудности в процессе реализации, в том числе изоляции данных организаций-тенантов, миграции схем и проблема ограничения доступов в рамках конкретной компании (и ещё 7 ключевых тем — в моей терминологии именуемые кругами ада).
Причина, по которой написана уже эта статья, довольно проста — тема разграничения доступности действий в рамках конкретного тенанта выходит далеко за рамки ERP домена и требует особо пристальной реализации. Это особенно применимо для коммерческих систем (коей и является Kroncl — название системы), в которых классический RBAC требует определённых доработок, включающих адаптацию к упрощённой features-based access control (в народе — FBAC, является своего рода реализацией ABAC). Кроме того, технологические компании крайне редко (уникальные случаи всё же есть) посвящают публичные статьи внутреннему устройству своих систем тарификации, что крайне печально, ведь это буквально могли быть рассказы о том, как архитектурные решения напрямую влияют на маркетинг и как следствие доходность компании.
Как же строить системы определения доступа не вокруг конкретных действий, а экономической модели платформы? Как легко переусложнить то, что переусложнять априори не стоит? Где заканчивается гибкость и начинается ад поддержки?
Попытаемся ответить на каждый из этих вопросов, отбросим пафос и сконцентрируемся на технической сути.
База
Для начала определим корень проблемы (или недостаточности сырого RBAC).
Предположим, читатель работает над своего рода мультитенантным сервисом, предлагающим клиентам определённую ценность (в моём конкретном случае — учёт данных организаций). Изоляция данных в таком сервисе организована через отдельные схемы/инстансы базы данных. Таким образом, каждая организация получает доступ к бизнес-логике приложения, предоставляющей действия над объектами в рамках разных модулей сервиса (в отдельно взятых случаях категоризация по модулям избыточна — достаточно разделения <объект>.<действие>) + выделенное хранилище данных всех объектов системы.
В таком случае все действия внутри организации можно формализовать моделью <модуль>.<объект>.<действие>. Не секрет, что такой минимальный строительный блок RBAC называется разрешением.
Итак, всю вашу систему можно описать конфигурацией разрешений, которые регистрируются при разработке приложения и требуются для того или иного метода платформы. В публичной схеме/инстансе БД хранится два реестра — аккаунты пользователей и зарегистрированные тенанты (в случае с Kroncl — организации). При вступлении в пространство тенанта (организацию), аккаунт получает так называемую guest-роль в рамках тенанта, которой доступны скажем только read-only разрешения. После вступления эти разрешения могут быть переопределены, например с помощью присвоения увеличивающих ролей по типу admin/owner и так далее.
Такой механизм определения доступности действий чаще всего и подразумевают под термином RBAC, но вот в чём главная проблема: если ваш сервис является коммерческим и предполагает доступ организации к разрешениям на основании тарифных планов, в системе доступа возникает как минимум две новых переменных — тарифный план и статус его окончания (isExpired). Встроить их в уже созданную систему можно разными способами, каждый из которых напрямую влияет на экономику всей платформы.
Способ 1. Тарификация по модулям
Этот способ лишён гибкости, но в большинстве случаев является оптимальным. В такой модели все разрешения объединены по модулям (областям) и доступность конкретного разрешения для конкретного тенанта (далее будем именовать организацией) наследуется от включения модуля в состав тарифного плана.
Таким образом, в специальном поле записи тарифного плана содержится перечень включённых модулей (как пример реализации jsonb с идентификаторами модулей в ячейке public.tariffs.modules), которые проверяются при каждом запросе ко всем методам организации. После этой базовой проверки разрешений, доступных всей организации, идёт уже упомянутый слой RBAC — определение доступности разрешения конкретному аккаунту.
Проблема такого подхода лишь в двух аспектах:
-
в такой модели вы продаёте цельные модули, без возможности включать конкретные фичи разных модулей в разные тарифные планы. Однако это же означает и то, что вам не надо заводить дополнительных конфигураций — вся работа заключается в добавлении ячейки включённых модулей в записи тарифных планов + логики определения принадлежности конкретного разрешения к определённому модулю;
-
для определения доступности разрешений внутри модулей в случае просрочки оплаты тарифа вам всё равно придётся заводить отдельную мапу (конфигурацию) с перечислением условных read-only разрешений / либо определять доступность разрешения в этом случае на основании атрибута действия (например разрешить только *.read разрешения) (однако даже в этом случае есть проблема — если количество действий вашей системы не ограничивается только read или write, а например содержит analyse, report и подобные действия — при росте системы вам постоянно придётся расширять список действий, доступных в кейсах isExpired организации).
Повторюсь, в большинстве случаев этот способ является оптимальным. Поддерживать такую архитектуру относительно просто. Сложность понимания новыми разработчиками низкая, да и в целом так делает огромное количество гигантов наподобие Битрикс24. В результате изучения данной темы пришёл к выводу, что выбирать эту стратегию предпочтительно условно при:
-
возможности разбить весь функционал сервиса на модули;
-
количество таких модулей ложится на вашу тарифную сетку (распределить 2 модуля на 3 тарифных плана просто невозможно);
-
количество действий над объектами держится в диапазоне от 1 до 7-8: read, write, update, create и <придумайте сами>.
Способ 2. FBAC (упрощённый) / Разрешения по тарифам
Читатель уже мог догадаться, что этот способ отличается от предыдущего своей детализацией вплоть до конкретного разрешения. Вместо определения доступности действия в конкретном тарифном плане для данной организации через модули используется прямая связь разрешение->тарифный план.
В обмен на готовность поддерживать реализацию такой модели вы получаете абсолютную гибкость вплоть до включения того самого всеми забытого разрешения в конкретный тарифный план. Вместо продажи цельных модулей вы начинаете продавать конкретные фичи, что во многом играет вам только на руку и позволяет детально определять состав тарифных планов.
Это особенно понравится вашим маркетологам, ведь именно благодаря такой модели рекламные слоганы вашей платформы могут акцентировать внимание клиентов на конкретных фичах, доступных только в самых дорогих тарифных планах. Больше акцента на фичах -> больше удержания -> резко повышается вовлечённость клиента в функционал сервиса -> больше золотых слитков (а мы любим золото).
Однако не спешите радоваться. Отсюда же вытекает целый водопад проблем, охватывающих не только серверное устройство вашего сервиса, но и аспект определения доступности действия на клиенте (грамотная реализация здесь стоит в приоритете над архитектурной красотой модели на сервере), стоимость поддержки модели и совместимость с традиционными моделями RBAC/ABAC (попытайтесь воткнуть такую детализацию в Casbin и расскажите, сколько слёз пролили [комментарии к этой статье ждут всех желающих]).
Эта статья посвящена именно второму способу (здесь не будут рассматриваться гибриды, но не забывайте, что и они возможны) и тому, как аналогичная модель реализована в Kroncl. Пример реализации (далеко не является эталоном и содержит ряд своих костылей, нецелесообразность которых я понял лишь на 7 день сотворения). Далее я затрону как аспекты серверной реализации, так и некоторые пояснения по клиентской части. Вне зависимости от вашей специализации (Go, Java, PHP или вовсе React + TS), не сомневаюсь, что статья послужит полезной демонстрацией комплексности проблемы доступов и того, как можно или наоборот не нужно связывать тарификацию с разрешениями (вывод оставляю читателю).
Структура наследования
В процессе реализации такой модели вы неизбежно придёте к решению разделения системы определения доступа на несколько слоёв. Здесь уже упоминалась данная концепция, но закрепим её ещё раз. Можно последовательно (в соответствии с порядком применения) выделить три основных слоя:
-
определение возможности действия для всей организации;
-
возможность выполнения действия данным пользователем в рамках организации на основании базовой роли;
-
всевозможные оверрайды, кастомная логика самой организации (в Kroncl это reduce/increase условия для конкретного аккаунта, наследование разрешений через связку аккаунта->сотрудник->должность (или проще говоря та же роль, но переопределённая самим тенантом)).
На примере такого расслоения наглядно демонстрируется факт того, что в первую очередь (и никак иначе) определяется область возможного для всей организации на основании применённого тарифа. Биллинг. Он лежит в основании всего, и в коммерческих сервисах очевидно это неизбежная правда.
Биллинг
Существует огромное количество реализаций хранить платёжное состояние конкретного тенанта. Кто-то делает через ячейки напрямую в записях тенантов (назовём это stable-версиями), кто-то рассчитывает текущий тариф на основании transactions/operations связующих таблиц, а кто-то организует гибридные схемы. Как бы то ни было, суммирую свой опыт:
-
Одна таблица transactions покрывает большинство сценариев. Не стоит даже синхронизировать ячейки по типу current_plan_id с историей платежей всех тенантов — это просто напросто избыточно для большинства случаев. С ростом нагрузки количества проверок доступа можно и нужно реализовывать кэширование текущего тарифного плана. Но делать это на уровне основной базы данных, по мнению автора, сомнительно. Организации живут своей жизнью, история платежей — своей, тарифы — своей. При необходимости определить текущий план тенанта: смотрим в историю операций, определяем последний успешный платёж -> мапим соответствующий ему тарифный план в структуру организации. Это оставляет возможность для операций с флагом is_trial или например для демонстрации предыдущего тарифного плана.
-
Использование универсального числового кода тарифа. Это может показаться очевидным, но тем не менее все тарифные планы следуют определённой градации, например от самого скромного до расширенного. При условии, что большинство сервисов ограничиваются 3 тарифными планами, логично использовать числовые коды от 1 до 3, обозначая их на стороне приложения как условные TARIFF_MIN_LEVEL, TARIFF_MID_LEVEL и так далее. В своей реализации мне ошибочно показалось, что использование единицы для расширенного плана является лучшим решением, ведь в таком случае можно неограниченно увеличивать количество более скромных тарифов. Нет, это только ломает логику и вводит в ступор.
Забудьте про связь разрешений с реестром тарифов БД через строки (например коды по типу pro, medium и start для связи с разрешениями являются ужасом).
Биллинг -> разрешения
Перейдём к связи разрешения с тарифами. В идеале приложение вообще ничего не должно знать о тарифах. В этом случае связь с тарифами в базе данных организуется с помощью констант по типу TARIFF_MIN_LEVEL или вашего аналога.
Определить принадлежность конкретного разрешения к коду тарифа можно с помощью конфигурации (да, ещё один массив). В Kroncl такая связь реализована в пакете internal/config, где permissions.go регистрирует все существующие разрешения сервиса, а pricing.go определяет принадлежность разрешений к тарифам с помощью REQUIRED_TARIFF_LEVEL. Этот метод громоздкий, и его вполне себе можно сжать или вовсе описать декларативно.
Внутренности мидлвари проверки доступа стоит разносить на отдельные пакеты. Таким образом, отдельный сервис для проверки вхождения целевого разрешения в разрешения организации на основании тарифного плана логично поместить в пакет billing, сервис для определения разрешений конкретного аккаунта в зависимости от базовой роли — в пакет accounts, а оверрайды — в пакет companies (или tenant, как часть пространства самого тенанта). Ваш же покорный слуга совсем немного является лентяем и уместил реализацию в permissioner пакете. На больших объёмах такой подход быстро превратится в кашу, так что лучше создать один мидлварь, использующий публичные методы трёх разных пакетов, с чётко определённой зоной ответственности.
В конечном итоге ваш роутер(ы) может(-ут) состоять из блоков наподобие этого (полная реализация — правильнее регистрировать роуты модулей в отдельных пакетах):
r.Route("/hrm", func(r chi.Router) {r.Use(permissioner.RequirePermission(permDeps, config.PERMISSION_HRM))// employeesr.Route("/employees", func(r chi.Router) {r.Use(permissioner.RequirePermission(permDeps, config.PERMISSION_HRM_EMPLOYEES))r.Get("/", rt.hrm(func(h *hrm.Handlers) http.HandlerFunc {return h.GetEmployees}))r.With(permissioner.RequirePermission(permDeps, config.PERMISSION_HRM_EMPLOYEES_CREATE)).Post("/", rt.hrm(func(h *hrm.Handlers) http.HandlerFunc {return h.CreateEmployee}))r.Route("/{employeeId}", func(r chi.Router) {r.Get("/", rt.hrm(func(h *hrm.Handlers) http.HandlerFunc {return h.GetEmployee}))})})})
Немного про логирование
Этот аспект выходит за рамки темы статьи, но является частью системы разрешений. В Kroncl реализована система логирования действий внутри пространства тенанта в виде таблицы логов в каждой отдельной схеме/инстансе. Считаю нужным акцентировать внимание читателя на полезности связи критичности (число от 1 до 10 или ваша система) с разрешением (да, ещё массив). Это отлично работает на визуализацию логов в виде условных грядок активности и в целом является ценной лог-информацией.
Система разрешений отлично ложится на систему логирования, позволяя использовать коды разрешений в качестве идентификатора действий. НО. Не допустите мою ошибку: чрезмерная детализация разрешений может взорвать ваш мозг и стать избыточной.
Синхронизация с клиентской частью
Затронем тему синхронизации доступов сервер->клиент. В общем виде фронтенду нужно ровно два типа данных:
-
разрешения, доступные всей организации;
-
разрешения, доступные пользователю (аккаунту), наследующиеся от базовой роли + оверрайды тенанта.
На основании двух таких массивов можно воссоздать систему определения доступности действия на клиенте. Но и здесь есть два важных принципа:
-
храните только коды разрешений, без всех принадлежностей к тарифам, критичности и другой системной информации. Это не означает, что нельзя хранить условное название + описание на клиенте, нет, даже напротив — эта информация не должна лежать на сервере (мнение автора). Речь идёт о системщине, которая может вызвать рассинхрон между источником правды (сервер) и фронтендом.
-
кэшируйте, кэшируйте, кэшируйте.
Таким образом, ваша реализация проверки доступности действия на клиенте может быть централизована в хуке usePermission(<код разрешения>), который в свою очередь стучится на соответствующие эндпоинты сервера и суммирует полученную информацию в той же последовательности: доступно ли разрешение для всей организации (содержится ли код целевого разрешения в массиве разрешений организации) и доступно ли действие конкретному аккаунту пользователя (код -> массив разрешений пользователя).
Бэкенд же в свою очередь содержит два разных эндпоинта:
-
/{companyId}/permissions
-
/{companyId}/accounts/{accountId}/permissions
Таким образом, фронтенд проверяет наличие кода целевого разрешения на основании двух источников данных и отдаёт ответ в место инициализации. На страницах это выглядит как объявление констант, вызывающих хук для определения доступности действия, на основании которых можно отображать/запрещать блоки с точностью до конкретных кнопок. Благодаря кэшу (условный React Query) операции занимают микроскопическое время, да вот зато удобство пользователя обеспечено.
Такое обеспечение доступности стоит вам покрытия кодовой базы блоками по типу:
const ALLOW_PAGE = usePermission(PERMISSIONS.CRM_CLIENTS)const ALLOW_CLIENT_CREATE = usePermission(PERMISSIONS.CRM_CLIENTS_CREATE)
В завершение
Конечно, существует слишком много вариаций систем тарификации, которые данная модель не покрывает. Чего стоят многоступенчатые тарифные сетки облаков, где в ход идут не только разрешения, но и превышение доменных лимитов тенанта. Всё это — ад и ужас, и очевидно для каждой задачи — своё решение.
Тем не менее, в этой статье законсервирован субъективно мой опыт воспроизведения такой модели. Если вы сталкивались с реализацией аналогичных задач — буду рад почитать комментарии.
Материалы: kroncl-server kroncl-client
ссылка на оригинал статьи https://habr.com/ru/articles/1028298/