Готовим, пробуем Casbin RBAC и handmade RBAC
Всем привет!
Меня зовут Андрей Таболин, я системный аналитик в компании Bimeister.
Casbin — одна из популярных библиотек для построения авторизации в веб-сервисах. В этой статье расскажу, как я тестировал Casbin, попутно подготовил своё решение для сравнения и покажу результаты работы обоих. Тестировалась в первую очередь эффективность работы с СУБД на разных объёмах данных для ролевой модели доступа (RBAC). Использовал: Node.js + PostgreSQL.
Перед стартом
Для понимания статьи нужно иметь хотя бы поверхностное представление о том, что такое RBAC и PERM.
RBAC (Role-Based Access Control) — популярная модель контроля доступа, которая для разрешения действия над объектом проверяет наличие роли у пользователя. Есть ещё ABAC — модель, проверяющая атрибуты. И другие модели, которых мы касаться не будем. Подробнее: Подходы к контролю доступа: RBAC vs. ABAC.
PERM (Policy, Effect, Request, Matchers) — метамодель для описания целевого контроля доступа. С его помощью можно подготовить: RBAC, ABAC, ACL и др. Описание выбранной модели происходит в конфигурационном файле (*.conf), необходимом для работы Casbin. Подробнее:
Подготовка данных
Тестовая модель данных
Я решил использовать следующие тестовые данные:
Объекты, к которым ограничиваем доступ
Операции над ними
Пользователей
Роли и разрешения в них в отношении объектов
Распределение ролей по пользователям
Объект будет относиться к определённой группе (Group) и типу (Type). Под каждый тип объекта будет своя таблица с названием вида: Group1Type1.
Для каждого типа предусмотрел операции CRUD (Create Read Update Delete) и распределил по ним роли с соответствующими операциями.
Итого, в тестовом наборе (Group Set) будет 2 группы объектов, по 4 типа в каждой группе, по 4 роли на каждый тип:
После приступил к созданию нагрузочного объёма для ролевой модели. Определил группы пользователей (User Gr), назначил им роли. Количество ролей на группу пользователей отличается, один пользователь в рамках группы объектов не будет обладать несколькими ролями.
Итоговый набор данных назвал пул (Pool), он содержит:
32 уникальных типа объектов = 32 таблицы, по 10 объектов в каждой. Для тестирования ролевой количество объектов не имеет значения
16 ролей, распределённых между 1000 пользователями
При подготовке пула заложил множитель. Он позволит протестировать решения на разных объёмах: 1 пул, 2 пула или 10.
Тестовая модель данных для RBAC определена.
Проверка операций
Для проверки RBAC достаточно одной операции — чтение. Мне важно было понять скорость принятия решения: предоставить доступ на операцию пользователю или нет.
Для прототипов я создал небольшую консольную утилиту (CLI), так было удобнее. Поэтому команды пользователя будут в соответствующем стиле:
хочу получить все объекты конкретной группы и типа: list obj group=1 type=1
хочу получить все объекты доступных мне типов из указанной группы: list obj group=1
Константы могут меняться и приведены для примера. В рамках операций проверял, как быстро ролевая:
group, type — примет решение: имею я доступ к указанной группе и типу объектов
group — проверит каждый тип на доступ в рамках одной группы, в итоге получу список доступных типов
Примеры CLI отражают знакомые кейсы пользователя:
зашёл в раздел и вижу/не вижу объекты
зашёл в раздел и вижу только доступные объекты разных типов
Подготовка Casbin
Установил саму библиотеку Casbin по инструкции от авторов для Node.js.
Для соединения с СУБД Casbin предлагает использовать адаптеры как middleware. Выбор зависит от языка разработки и самого источника данных. Под мой стек и задачу взял Sequelize Adapter. Дополнительно мне потребовалось поставить драйвер pg для PostgreSQL — выдавало ошибку при запуске. Драйвер используется самим Sequelize Adapter.
У Casbin есть требования к таблице, с которой он будет работать. Некоторые адаптеры создают таблицы сами по-умолчанию. Но с моим нужно создать явно, строго с именем «casbin_rule» и полями ниже. Следует обратить внимание на автоматически инкрементируемый первичный ключ. Casbin работает с политиками, но не с id записей. Сделать это можно средствами адаптера или руками. Я создал руками в СУБД.
Поле | Настройка |
id | serial, Primary Key |
ptype | varchar 255, null |
v0 | varchar 255, null |
v1 | varchar 255, null |
v2 | varchar 255, null |
v3 | varchar 255, null |
v4 | varchar 255, null |
v5 | varchar 255, null |
Инфо по полям:
id — ид записи таблицы
ptype — тип политики Casbin
v0 — v5 — параметры по порядку, которые указываются в [policy_definition] файла конфигурации Casbin
Настроил индексацию по рекомендации.
Подготовил файл конфигурации Casbin (.conf):
[request_definition]
r = sub, objGroup, objType, act
[policy_definition]
p = sub, objGroup, objType, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && r.objGroup == p.objGroup && r.objType == p.objType && r.act == p.act
У меня получился следующий маппинг параметров файла конфигурации Casbin с созданной таблицей casbin_rule:
В «casbin_rule» внёс немного данных для проверки работы Casbin с СУБД. Вывел в консоль доступные политики по фильтру, указав: роль, группу объектов и их тип.
import { newEnforcer } from "casbin";
import { SequelizeAdapter } from "casbin-sequelize-adapter";
const app = async () => {
//готовим адаптер для СУБД
const adapter = await SequelizeAdapter.newAdapter({
username: "postgres",
password: "password",
database: "casbin",
dialect: "postgresql",
logging: false, //выключили логи в консоль, мешают
});
//создаём объект casbin, через который будем работать с политиками,
//передаём подготовленный файл конфигурации и адаптер
const e = await newEnforcer("./rbac_model.conf", adapter);
//через API объекта casbin получаем политики по фильтру, первый аргумент - номер поля v*
//v0 == "role1", v1 == "group1", v2 == "type1"
const rules = await e.getFilteredPolicy(0, "role1", "group1", "type1");
//закроем подключение к СУБД
await adapter.close();
console.log(rules);
};
app() //Результат выполнения: [ [ 'role1', 'group1', 'type1', 'read' ] ]
Супер, дорогу к СУБД через Casbin проложил. Дальше нужно было подготовить остальное хранение.
Хранение
Решил, что буду тестировать две реализации ролевой модели: Casbin и самописную (handmade) реализацию. Для этого нужно было подготовить таблицы в БД:
Users — пользователи
Auth — логины пользователей
Roles — роли
Permissions — правила доступа. Одна роль может содержать много доступных операций (permissions) в отношении одного или многих типов объектов
UserRoles — пользователи и назначенные на них роли
casbin_rule — таблица для Casbin, обсудили выше и уже подготовили
Objects* — таблицы для объектов, которые будут создаваться автоматически в зависимости от количества пулов данных
Для вашего понимания приведу примеры содержимого пары таблиц. В остальных по логической диаграмме БД, думаю, содержимое понятно.
табл. «casbin_rule»
id | ptype | v0 | v1 | v2 | v3 | v4 | v5 |
1 | p | role1 | group1 | type1 | read | ||
2 | p | role2 | group2 | type2 | read | ||
3 | p | role2 | group2 | type2 | create |
табл. «Permissions»
Id | RoleId | ObjectGroup | ObjectType | Permission |
1 | 1 | group1 | type1 | read |
2 | 2 | group2 | type2 | read |
3 | 2 | group2 | type2 | create |
Подготовка функций
Команды пользователя
Общий алгоритм функций CLI прост и не зависит от реализации: handmade или Сasbin. Пример команды list obj на запрос объектов:
Из схемы видно, что функция CLI должна содержать:
инфорсер — функция, проверяющая доступ пользователя к объекту. Взял из терминологии Casbin для упрощения понимания подходов
функция для запроса данных из БД
Заготовка функции:
async someCliFunc(user, obj, act) {
//сначала проверяем доступ для пользователя по запрошенной операции
const allowed = await enforce(user, obj, act);
//если разрешено, то запрашиваем данные. Если нет, то отказ
if (allowed) {
const objects = await this.db.listObjects();
return objects;
} else {
return this.denyMessage;
}
}
После перешёл к подготовке того, что будет под функцией enforce.
Handmade инфорсер
Для описанной схемы БД мне было достаточно сделать так:
//db - экземпляр самописного класса DB для работы с СУБД,
//в нём подготовлены требуемые методы
const enforce = async (db, user, group, type, act) => {
//забираем из таблицы Permissions записи по фильтру
const permissions = await db.getUserPermissions(user, group, type, act);
//если хоть одна запись есть, то доступ разрешён
return permissions.length > 0;
}
Создание подключения к БД (db) вынес за рамки инфорсера, это позволило сэкономить время проверки. Один раз создали и пользуемся. Но не забываем контролировать.
Можно подметить, что в таблице Permissions в моей схеме нет пользователя. Но т.к. я готовил handmade, то никто не запрещал мне в методе db.getUserPermissions добавить sql-запрос вида:
with rolesId as (
select userRoles."RoleId" from public."UserRoles" as userRoles
inner join public."Auth" as auth
on auth."UserId" = userRoles."UserId"
where auth."Login"='${user}'
)
select * from public."Permissions" as perm
inner join rolesId
on rolesId."RoleId" = perm."RoleId"
where perm."ObjectGroup"='${group}'
and perm."ObjectType"='${type}'
and perm."Permission"='${act}'
Альтернатива инлайн SQL-запросам является ORM. В моём случае можно использовать тот же Sequelize. Для финального тестирования я фоном подготовил реализацию handmade на нём.
Casbin инфорсер
На сайте Casbin есть пример создания и использования инфорсера.
Изначально я пошёл схожим с handmade путём — универсальный инфорсер. Файл конфигурации Сasbin и заранее созданный адаптер к БД можно передавать с аргументами в момент исполнения, они могут быть разные. Мощно, круто.
const enforce = async (model, adapter, ...args) => {
const e = await casbin.newEnforcer(model, adapter);
//тот самый Casbin-инфорсер
const allowed = await e.enforce(...args);
await adapter.close();
return allowed;
};
Но радость моя быстро закончилась, когда я начал делать замеры подходов. Результаты были неутешительные.
В ходе разбирательств выяснил, что casbin.newEnforcer загружает все политики из источника в создаваемый объект. Получается, что я при каждом вызове инфорсера просил Casbin прочитать всё из БД и загрузить к себе. Неудивительно, что результаты меня опечалили. Поняв природу Casbin, я вынес создание инфорсера в момент запуска CLI. В последующем мне осталось только вызывать его в командах пользователя таким образом:
const allowed = await e.enforce(user, group, type, act)
Что делать, если данные в БД изменились? Если говорить в общем, то работа с политиками в хранилище осуществляется через API самого инфорсера Casbin. Если политики в БД были изменены не через наш инфорсер, то можно загрузить актуальные с помощью LoadPolicy.
Подготовка команд пользователя
После подготовки инфорсеров требовалось подготовить команды пользователя:
list obj group={n} type={m} — покажи все объекты группы N типа M
list obj group={n} — покажи все объекты доступных типов группы N
list obj group={n} type={m}
Handmade
Инфорсер поместил в отдельный файл:
const enforce = async (db, user, group, type, act) => {
const permissions = await db.getUserPermissions(user, group, type, act);
return permissions.length > 0;
}
Далее импортировал его в команду CLI:
import enforce from 'handmadeEnforcer.js'
async listObjectsByType(group, type) {
//подключение к БД (this.db) создаётся при запуске CLI
const allowed = await enforce(this.db, this.userLogin, group, type, "read");
if (allowed) {
const objects = await this.db.listObjectsByType(group, type);
return objects;
} else {
return this.denyMessage;
}
}
Casbin
В классе CLI определил метод init для создания инфорсера при запуске интерфейса:
import { newEnforcer } from "casbin";
import { SequelizeAdapter } from "casbin-sequelize-adapter";
async init() {
const adapter = await SequelizeAdapter.newAdapter({
username: "postgres",
password: "password",
database: "casbin",
dialect: "postgresql",
logging: false
});
const model = "./rbac_model.conf";
this.enforcer = await newEnforcer(model, adapter);
}
Добавил инфорсер Casbin в команду пользователя:
async listObjectsByType(group, type) {
const allowed = await this.enforcer.enforce(this.userLogin, group, type, "read");
if (allowed) {
const objects = await this.db.listObjectsByType(group, type);
return objects;
} else {
return this.denyMessage;
}
}
list obj group={n}
Handmade
Здесь логика меняется. Всего 4 типа объектов в одной группе, а доступ может быть только к двум, к примеру. Эти два и требуется вывести пользователю.
Увидел пару вариантов решения задачи:
получить существующие типы объектов для запрошенной группы и каждый тип проверить через инфорсер. В результате получу список доступных типов и запрошу данные по нему
немного модифицировать существующий запрос в БД getUserPermissions, чтобы он мог получать пустой тип и возвращать все доступные типы по группе
Решил сходить ради интереса в п.2. Обернул его не в функцию enforce, а в функцию getAllowedTypes:
const getAllowedTypes = async (db, user, group, act) => {
//теперь функция принимает объект и можно без указания типа
const permissions = await db.getUserPermissions({user, group, act});
//вернём только массив типов
return permissions.map((rec) => rec.ObjectType);
}
Код в CLI:
import { getAllowedTypes } from 'handmadeEnforcer.js'
async listObjectsByGroup(group) {
const allowedTypes = await getAllowedTypes(this.db, this.userLogin, group, "read");
//если есть, то получаем выборку объектов из БД и возвращаем пользователю
if (allowedTypes.length > 0) {
const objects = await Promise.all(
allowedTypes.map(async (type) => await this.db.listObjectsByType(group, type))
)
return objects.flat();
} else {
return this.denyMessage
}
}
Casbin
С Casbin трюк по получению доступных типов тоже пройдёт. Я уже проводил похожий с помощью e.getFilteredPolicy.
Но такая карета быстро превратится в тыкву, когда я захочу усложнить сценарий выборки за счёт передачи или хранения в политиках масок/последовательностей:
group*
type[1–10]
! delete
пр.
Спойлер: в Casbin при вызове e.enforce () работа с такими сценариями может быть предусмотрена.
Ещё выделение getAllowedTypes не выглядит каким-то универсальным средством проверки прав. Поэтому здесь решил пойти по пути проверки каждого типа на доступ, т.е. по п.1 из раздела выше. Для handmade инфорсера я подготовил фоном схожий подход, чтобы можно было сравнить в ходе замеров.
В инфорсере Casbin я ничего не менял, поэтому опущу код, но посмотрим CLI:
async listObjectsByGroup(group) {
//получим все типы у группы
const allTypes = await this.db.getGroupTypes(group);
const allowedTypes = (await Promise.all(
//проверим доступ к каждому типу
allTypes.map(async (type) => {
const allowed = await this.enforcer.enforce(this.userLogin, group, type, "read");
//если доступ к типу разрешён, то вернём тип
return allowed && type;
}))
//почистим массив от undefined и получим список типов для выборки объектов
.filter(el => el)
if (allowedTypes.length > 0) {
const objects = await Promise.all(
allowedTypes.map(async (type) => await this.db.listObjectsByType(group, type)
)
return objects;
}
else {
return this.denyMessage;
}
}
Тестирование
Для измерения времени работы инфорсеров воспользовался в Node.js функцией perfomance.now. Фиксировал время перед запуском инфорсера и после получения результата, вычислял разницу.
Нагрузка на инфорсер по командам:
list obj group={n} type={m} — инфорсер проводит одну проверку по уникальному типу объекта. Уникальность типа складывается из: группа объекта + тип объекта.
list obj group={n} – инфорсер проводит проверку каждого типа в рамках группы. В тестовых данных четыре типа на одну группу.
Для проверки handmade у меня получилось:
SQL — реализация с инлайн SQL-запросами в БД
ORM — реализация через ORM Sequelize
getAllowedTypes (GAT) — реализация, при которой команда list obj group={n} сразу отдаст список доступных типов, а не будет проверять каждый тип на доступ. Реализация есть для SQL и ORM. В рамках тестирования данного режима я не буду проверять команду list obj group={n} type={m}, потому что результат будет схож с SQL/ORM.
Для проверки Casbin:
casbin — проверка работы инфорсера Casbin с его загрузкой в системную память
casbin (no init enf) — реализация, при которой инфорсер Casbin создаётся во время проверки доступа
Я добавил к командам CLI опцию Enforce Repeats, которая позволила устанавливать количество запусков инфорсера. Время работы будет вычисляться среднее (average). Единица измерения времени — миллисекунды (ms).
Взял пользователей (User) с разным количеством ролей (User Roles), чтобы проверить влияние количества ролей на результат.
Аппаратная часть для теста: Processor: i5–1135G7 @ 2.40GHz, 2419 Mhz, 4 Core (s), 8 Logical Processor (s)
POOLS x 1
1 000 пользователей, 16 ролей, 32 уникальных типа сущностей. Объём данных:
Потребитель | Имя таблицы | Строк |
handmade | Auth | 1 000 |
Roles | 16 | |
UserRoles | 1 720 | |
Permissions | 352 | |
casbin | casbin_rule | 2 072 |
Результаты:
Какие выводы я сделал после первого теста:
инлайн SQL-запросы работают ожидаемо быстро. Но при таком подходе важно учитывать задержку работы служб и сети между хостом и СУБД. У всех будет индивидуальная.
запросы через ORM на холодном старте дают задержку. Последующий запуск требует меньше времени. Пример трёх ручных последовательных запусков команды list obj group=1 type=1 rep=1 для handmade ORM с перерывом ~1 сек:
enforce = 156 ms
enforce = 4 ms
enforce = 5 ms
ORM справляется близко к уровню инлайн запроса. Отличает время на холодный старт.
использование getAllowedTypes дало небольшой прирост производительности в инлайн SQL при частой сработке, но при холодном старте для ORM показал негативный результат. Если и оптимизировать инфорсер, то лучше это делать внутри, чтобы наружу была видна одна функция enforce.
количество ролей на пользователе не влияют на скорость работы handmade инфорсера. Но у Сasbin сложилось впечатление, что влияет.
casbin (no init enf) предметно показал, что создавать инфорсер в момент исполнения команды CLI не стоит, если хотим получить большую производительность.
Исходя из выводов выше, для дальнейших тестов:
убрал user750 с двумя ролями, оставил user930 с четырьмя
убрал handmade (GAT). В целом я понял, что за счёт таких подходов можно оптимизировать работу внутри инфорсера. Но при использовании ORM нужно сравнивать тщательнее.
убрал casbin (no init enf). Вне материала статьи проводил с ним тесты. Время работы инфорсера увеличивалась с объёмом данных. На 10 пулах list obj group=1 type=1 rep=1 показывал 0,6 сек, а на 100 пулах 4,426 сек.
POOLS x 10
10 000 пользователей, 160 ролей, 320 уникальных типа сущностей. Объём данных:
Потребитель | Имя таблицы | Строк |
handmade | Auth | 10 000 |
Roles | 160 | |
UserRoles | 17 200 | |
Permissions | 3 520 | |
casbin | casbin_rule | 20 720 |
Ранее были сомнения насчёт выборки по user930 у casbin. Заменил его на пользователя user9910:
Выводы:
у ORM специально оставил пример, когда при частой сработке холодный старт даже при другой команде уходит. Если пройдёт 5–10 сек, то время работы будет примерно 150ms на холодном и 3ms при частом обращении.
на производительность инфорсера Casbin сказывается: количество записей, количество ролей на пользователей и местоположение пользователя в массиве политик Casbin. Такие выводы сделал из результатов тестирования с пользователями с другим количеством ролей, которые находятся в конце списка политик:
POOLS x 100
100 000 пользователей, 1 600 ролей, 3 200 типов сущностей. Объём данных:
Потребитель | Имя таблицы | Строк |
handmade | Auth | 100 000 |
Roles | 1 600 | |
UserRoles | 172 000 | |
Permissions | 35 200 | |
casbin | casbin_rule | 207 200 |
Оставил одного пользователя в конце списка политик:
Выводы
Casbin «из коробки» готов покрыть функциональные требования разных моделей доступа: RBAC, ABAC, ALC, др. В рамках одной системы можно их комбинировать, создавая инфорсеры с разными файлами конфигурации. За счёт этого можно закрыть большое количество требований к системе. Скорость работы инфорсера на Node.js при проверке доступа к ресурсу составила ~150ms на 200 000 записях таблицы Casbin. Что может быть достаточным для многих веб-сервисов. На компилируемых языках скорость должна быть выше.
В рамках одной системы можно комбинировать Casbin с handmade решением в местах, где требуется большая производительность. При таком решении будут таблицы, которые обеспечивают консистентность данных. А таблица Casbin будет служить, как операционная. Если записи по доступу в основных таблицах будут меняться, то потребуется обновить инфорсер Casbin. Уже через инфорсер данные будут попадать в таблицу Casbin. Позволит сэкономить время разработки на несложных участках и сконцентрироваться на требовательных.
Можно оптимизировать скорость работы инфорсера, храня url в политиках: user1, /group1/* , read. Если user1 захочет получить доступ на чтение /group1/type4, то инфорсер Casbin вернёт true. Записей в таблице будет меньше, скорость выше.
За счёт проделанной работы, я смог намного лучше понять природу Casbin, его основные возможности. Убедиться в его эффективности. Библиотека уже предлагает дополнительные средства для оптимизации проверок и всё ещё развивается.
На этом пока всё. Если у вас есть под рукой советы по ускорению/использованию Casbin, инфо по скорости работы на других стеках/кейсах или полезные ссылки по данной библиотеке, то обязательно оставляйте комментарии. Они значимо смогут дополнить общий материал для всех специалистов, кто ищет эффективные подходы в авторизации.