После написания предыдущей статьи по языку PERM и библиотеке Casbin, возникли вопросы. Причем не у одного человека, и я хотел ответить сначала в комментарии, но понял, что объем материала выходит за рамки обычного комментария, поэтому изложу этот ответ в виде отдельной статьи.
Я долго не мог понять ту конкретную конструкцию, которая сидела в голове у вопрошающих, но в конечном итоге после уточняющих вопросов получил ответы, компиляцию которых приведу в цитате.
А как с такими DSL решается задача «показать список объектов, которые я могу видеть? Надо же в SQL-запрос это как-то транслировать, не выгребать же все записи с БД.
Есть интерфейс на сайте, показывающий список чего-либо. Скажем — статей в админке CMS. Статей с базе — десятки тысяч, но обычно пользователь имеет доступ только к десятку. Как достать из БД статьи, которые видны конкретному пользователю? Ну, если мы все правила что кому видно — вынули из кода в какой-то DSL?
Другими словами — как написать запрос типа
select * from articles a
join roles r on r.userId = currentUserId
where article.owner = currentUserId
OR (r.role in ['admin', 'supevisor']) — админ всего
OR (r.domain = currentDomainId AND r.role in ['domain-admin', 'domain-supervisor']) — админ домена
У меня такие правила в коде, в виде LINQ-выражений, и я такую задачу решать умею. А возникает такая задача даже чаще, чем «проверить есть ли доступ к одному выгруженному с память объекту»
Надеюсь, я правильно понял эту конструкцию и в ходе обратного реверсинженеринга, мне удалось вытащить исходные данные для решения данной задачи. Для начала обойдемся без использования мультитенатности (домены), так как они усложняют задачу и соответственно понимание. Пример их использования я приводил в прошлой статье.
Сначала я опишу ту реализацию, которая сидит в голове у вопрошающего, чтобы вы тоже ее понимали, а затем мы эту реализацию трансформируем в решение с использованием Casbin.
Описание задачи
У нас имеется CMS, в которую пользователи через админпанель могут добавлять статьи. В адмике пользователь с ролью user
может видеть только свои статьи. Статьи других людей он не может видеть, если только ему не присвоена роль admin
или supervisor
. Пользователь с ролью supervisor
может видеть и редактировать все статьи, а admin
имеет все те же права что и supervisor
, но кроме этого, еще может и удалять любые статьи.
Структура, схема и содержимое БД:
Структура БД нашей CMS:
Содержимое таблицы пользователей — Users:
Пользователям присвоены следующие роли:
Содержимое таблицы — Roles:
Как мы видим, Piter является администратором, а Bob — супервизором. Alice обычный пользователь, может видеть, создавать и редактировать только свои статьи.
Содержимое таблицы со статьями — Articles:
Исходя из вопроса, выборка осуществляется для администратора (Piter, id=3) таким образом:
select * from articles a
left join roles r on r.userId = 3
where a.owner = 3
OR (r.role in ('admin', 'supevisor'))
Выборка для супервизора (Bob, id=2) таким образом:
select * from articles a
left join roles r on r.userId = 2
where a.owner = 2
OR (r.role in ('admin', 'supevisor'))
А выборка для пользователя (Alice, id=1) выглядит так:
select * from articles a
left join roles r on r.userId = 1
where a.owner = 1
OR (r.role in ('admin', 'supevisor'))
Давайте теперь попробуем использовать другой подход к авторизации, на базе библиотеки Casbin.
Подход с использованием Casbin
Для начала давайте определимся что ресурс в подходе PERM — это не столько экземпляр сущности, сколько сама сущность.
Т.е. когда мы описываем модель авторизации, под ресурсом в нашем примере подразумевается сама сущность (таблица) Статья. А не конкретная запись из этой таблицы (с Id=1 например).
Дальше необходимо уточнить, что те роли, которые используются в описании этой задачи — это не классические роли из подхода RBAC.
Роли RBAC описывают те разрешения, которые можно выполнить с сущностью. Например в классическом RBAC роль user
могла бы только читать статьи, роль author
могла бы наследовала роль user
(т.е. чтение статей), и еще могла бы редактировать и создавать новые статьи, а роль admin
могла бы наследовать все предыдущие разрешения и плюс еще удалять статьи.
В описанной же нами выше задаче, по сути, все эти все роли не отличаются друг от друга. И user
и supervisor
и admin
имеют одни и те же права, один набор разрешений — каждый носитель любой из ролей может создавать, редактировать или удалять статьи. Разница только в области видимости, user
может видеть в админке только свои статьи, и соответственно редактировать их и удалять. А admin
и supervisor
не только свои, но еще и чужие.
И в этом заключается большой минус модели RBAC, так это статичная модель авторизации, и с ее помощью вообще невозможно выразить бизнес-правила, в которых используются атрибуты, значения которых заранее не известны и вычисляются в процессе работы.
Об этом подробно уже было рассказано в статье Подходы к контролю доступа: RBAC vs. ABAC
А те роли, что мы используем (user
, supervisor
,admin
) — это так называемые — динамические роли
. Термин не официальный, и зачастую каждый подразумеваем под ним свое. Они реализуются различными способами, и описанное вопрошающим решение, которое я привел в начале статьи — один из таких подходов.
Выборка значений с учетом "динамических ролей"
Для начала давайте определим модель политики RBAC (rbac_model.conf
), ее подробное описание я привел в предыдущей статье:
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
Далее, мы убираем таблицу ролей Roles. Роли у нас теперь будут описаны в хранилище политик. Это может быть как обычный *.csv
файл, так и таблица в базе данных. Для простоты я буду использовать cvs файл rbac_policy.csv
:
p, user, article, read
p, user, article, modify
p, user, article, create
p, user, article, delete
g, supervisor, user
g, admin, supervisor
g, 1, user
g, 2, supervisor
g, 3, admin
Суть здесь такая, что мы даем роли user
права на чтение, модификацию, создание и удаление статей. Затем роль supervisor
наследует права роли user.
А роль admin
наследует права роли supervisor
.
Далее пользователю alice(1) мы присваиваем роль user
, bob(2) у нас supervisor
, а piter(3) — admin
.
В принципе этого достаточно, чтобы решить проблему, которую описывал автор вопроса.
Этот код конечно не для продакшена, а для демонстрации. Для продакшена я советую использовать cross-cutting concern с CQRS+MediatR
public IList<Article> GetArticlesForAdminPanel(int currentUserId)
{
var e = new Enforcer("CasbinConfig/rbac_model.conf", "CasbinConfig/rbac_policy.csv");
var obj = "article";
var act = "read";
//Сначала проверяем, что пользователь имеет права на чтение статей
if (e.Enforce(currentUserId.ToString(), obj, act))
{
//Получаем список ролей пользователя
var currentUserRoles = e.GetRolesForUser(currentUserId.ToString());
//Проверяем, является ли пользователем админиом или супервизором
var isAdmin = currentUserRoles.Any(x => x == "admin" || x == "supervisor");
//Если админ, вернуть все записи, иначе только те, которые принадлежат пользователю
if (!isAdmin) return _context.Articles.Where(x => x.OwnerId == currentUserId).ToList();
else return _context.Articles.ToList();
}
else
{
// отклонить запрос, показать ошибку
throw new Exception("403. У вас нет прав для чтения статей");
}
}
Тадам! Задача решена, ответ на вопрос дан.
Редактирование статей с учетом "динамических ролей"
Теперь же пойдем еще дальше. Мы получили список статей, отобразили в админке, и попытаемся отредактировать какую-нибудь статью. И нам соответственно надо проверить, имеем ли мы права чтобы ее отредактировать, пользователь с ролью user
может отредактировать только свои статьи, supervisor
и admin
могут отредактировать все статьи.
Для этого определяем новую модель, называем ее rbac_with_abac_model.conf
:
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = (r.sub == r.obj.OwnerId.ToString() || g(r.sub, "supervisor")) && g(r.sub, p.sub) && r.act == p.act
Данная модель не сильно отличается от модели чтения, за исключением секции [matchers]
, в ней мы конструкцию r.obj == p.obj
заменили на (r.sub == r.obj.OwnerId.ToString() || g(r.sub, "supervisor"))
. Это следует читать как r.sub (id пользователя) должен совпадать с полем r.obj.OwnerId (id владельца обновляемой записи) или r.sub должен принадлежать группе "supervisor". Поскольку группа admin
наследует все права группы supervisor
то и члены группы admin
будут соответствовать этому правилу.
Файл с политиками остается прежним, его мы не меняем. Теперь смотрим как это выглядит в коде:
public void UpdateArticle(int currentUserId, Article newArticle)
{
var e = new Enforcer("CasbinConfig/rbac_with_abac_model.conf", "CasbinConfig/rbac_policy.csv");
var act = "modify";
//Проверяем, что пользователь имеет права на редактирование статьи
if (e.Enforce(currentUserId.ToString(), newArticle, act))
{
//Обновляем, и сохраняем изменения
_context.Articles.Update(newArticle);
_context.SaveChanges();
}
else
{
// отклонить запрос, показать ошибку
throw new Exception("403. Недостаточно прав");
}
}
Здесь стоит обратить внимание на то, что мы в метод e.Enforce
передаем вторым параметром объект, который представляет из себя экземпляр класса Article
.
Ну и последний шаг — попытаемся удалить статью.
Удаление статьи
Бизнес-правило у нас здесь такое, что пользователь с ролью user
может удалить свою статью, supervisor
— не имеет прав удалять чужие статьи, а admin
такое право имеет.
Опишем теперь это бизнес-правило в модели политики PERM, в файле delete_model.conf
:
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = (r.sub == r.obj.OwnerId.ToString() || g(r.sub, "admin")) && g(r.sub, p.sub) && r.act == p.act
Она не сильно отличается от предыдущей политике редактирования, за тем исключением, что удалять чужие статьи мы разрешили только роли admin
. Если носитель роли supervisor
попытается удалить чужую статью, у него выйдет ошибка превышения полномочий.
Как и в случае с моделью, код мало чем отличается от предыдущего примера на редактирование:
public void DeleteArticle(int currentUserId, Article deleteArticle)
{
var e = new Enforcer("CasbinConfig/delete_model.conf", "CasbinConfig/rbac_policy.csv");
var act = "delete";
//проверяем, что пользователь имеет права на удаление статьи
if (e.Enforce(currentUserId.ToString(), deleteArticle, act))
{
//Удаляем статью
_context.Articles.Remove(deleteArticle);
_context.SaveChanges();
}
else
{
// отклонить запрос, показать ошибку
throw new Exception("403. Недостаточно прав");
}
}
Резюме
Надеюсь, данный пример продемонстрировал гибкость, универсальность и удобство использования библиотеки Casbin и языка PERM для построения решений по разделению доступа и авторизации.
Еще отмечу, что как сами модели политики, так и правила политики могут храниться в БД. И предусмотрена возможность фильтрации правил, если их большое множество, и это может стать узким местом в высоконагруженных приложениях.
Casbin под капотом использует библиотеку DynamicExpresso.Core для интерпретации простых выражений C# при сопоставлении правил политик с входными значениями, что позволяет эффективно использовать Casbin даже в самых сложных сценариях авторизации.
Не смотря на свою молодость, Casbin активно развивается, используется во множестве проектов, обрастает полезными инструментами и API. Такими например как UI для управления политиками.
Полностью работоспособный и самодостаточный код примера, который я использовал для написания данной статьи я разместил у себя на Github, можете скачать и поиграться, если есть интерес и желание.
jakobz
Пример ты понял правильно. Но решение — так себе. Casbin тут занимается обычным RBAC, а нам нужен был ABAC. А ABAC-аспект про owner-а статьи — вынесен в хард-код.
Даже хуже — можно было бы ввести разрешение на действие «list_all_articles», и дать его админам, а без этого этот Casbin чисто заменяет табличку с ролями, которая зачем-то из БД (где к ней можно прикрутить админку, например), переехала в загадочный CSV.
Это мы еще не трогали случаи:
— когда есть вложенные сущности. Скажем, картинки в статье. И надо к ним давать доступ только если пользователь имеет доступ к статье
— когда есть атрибуты у сущности, доступ к которым ограничен. Вроде тут можно просто — проверяем есть ли доступ, если нет — отдаем null через API. Но при этом, если по такому полю возможна сортировка — то можно по порядку сортировки догадаться что было в этом поле, т.к. сортироваться будет на стороне БД, которая ничего не знает про доступ к полю
— когда есть всякие рекурсивные штуки, типа «человека видит его менеджер, менеджер менеджера, и т.д.»
Может быть это у меня специфика такая — бизнес-приложения для энтерпрайза. А где попроще, и подходит RBAC — такая штука и пойдет.
Но для моих кейсов, мне критически надо вычислять роли прямо в БД, чтобы отсекать доступ прямо там.
И это нормально делается без всяких библиотек. Если надо считать на сервере — просто выносим логику в функции типа:
Дальше этот код можно звать чтобы:
— проверить может ли человек сделать что-то
— отдать на UI чтобы показать флажки
А чтобы втаскивать какие-то условия в БД, делается трюк с Expression, типа так:
На деле все еще сложнее:
— надо «жонглировать» Expression-ами — упрощать их (если админ — нечего засорять SQL ненужными условиями), добавлять только нужные термы (если мы дергаем API админки, куда можно только админам — нечего проверять на owner-а
— надо аккуратно вклеить это все в код, чтобы
— невозможно было бы забыть добавить проверки
— если мы тащим в API parent-сущность, а в ней — вложенные, то не обязательно проверять правила про parent-а для вложенных еще раз — мы же уже достали parent-а и проверили.
Так что мне вообще непонятно, зачем хард-код — со строгой типизацией, читаемый, и гибкий, и решающий все мои задачи — надо заменять каким-то загадочным DSL, который надо хакать от входа.
pprometey Автор
Может решение которое я здесь привел и не самое изящное. Но в целом ценность такой библиотеки в переносе правил авторизации на уровень конфигурации с уровня кода, что позволяет более гибко ими управлять, как сказал один комментатор. Плюс бизнес-правила хранятся в одном месте.
В вашем же подходе — получается спагетти. Бизнес-правила размазаны, и зашиты в SQL. Те сложности которые вы перечисляете — более легко и изящно решаются как раз-то с использованием Casbin, нежели если лепить собственный велосипед, который вы предлагаете.
jakobz
Мы же оба видим в твоем примере что ABAC занимается не Casbin, а хард-код — тот самый if(role == «Admin»). Ты уже не можешь добавить через конфигурацию правила вроде «а еще у статьи есть аттрибут reviewerId, и мы ревьюверам доступ на чтение, редактирование, а еще — и на публикацию статей». Тебе придется опять лезть и хардкодать это. Т.е. задача перенести авторизацию в конфигурацию — не решена.
Ну и второе — я искренне не понимаю негативный контекст в словосочетании «хард-код». Почему у меня сразу будет «велосипед» и «спагетти»? Почему заменяя один язык на другой, мы почему-то сразу делаем что-то правильное? Почему просто на C# нельзя все сделать аккуратно, вынести все правила в один сервис, построить какой-нибудь eDLS если требуется? Почему какой-то странный DLS — без типизации, без каких-либо compile-time проверок, с необходимостью всей командой учить его — почему это считается крутым? Чем таким волшебным отличается файлик .cs от файлика с другим расширением в репозитории?
tia_ru
Можно ещё добавить группы пользователей с определённой контекстной ролью по отношению к сущности. Например, группа исполнителей поручения с правами чтения и группа замещающих автора с правами редактирования). Группы могут включать другие группы и пользователей.
jakobz
Да. И, что важно в контексте, «группа» — скорее всего будет не просто абстрактной «группой», это будет «склад» с атрибутом «начальник», или «отдел», или «грузовик» с атрибутом «водитель».
Авторизация делается поверх существующей модели предметной области, очень сильно с ней пересекается. И все что касается групп и ролей — живет в БД вместе с остальными данными.
Casbin пытается отделить авторизацию, пытается забрать в себя роли и группы — такой для бизнес-приложений — обречён.
Я не говорю что авторизаци прям невозможно вытащить в библиотеку. Но делать это надо нежнее — не забирая к себе данные, а глубоко интегрируясь в Data Access Layer приложения.