Всем привет!

Меня зовут Андрей Таболин, я системный аналитик в компании 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 типа объектов в одной группе, а доступ может быть только к двум, к примеру. Эти два и требуется вывести пользователю.

Увидел пару вариантов решения задачи:

  1. получить существующие типы объектов для запрошенной группы и каждый тип проверить через инфорсер. В результате получу список доступных типов и запрошу данные по нему

  2. немного модифицировать существующий запрос в БД 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 сек:

    1. enforce = 156 ms

    2. enforce = 4 ms

    3. 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, инфо по скорости работы на других стеках/кейсах или полезные ссылки по данной библиотеке, то обязательно оставляйте комментарии. Они значимо смогут дополнить общий материал для всех специалистов, кто ищет эффективные подходы в авторизации.

Материалы

Комментарии (0)