После написания предыдущей статьи по языку 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 aleft join roles r on r.userId = 3where a.owner = 3OR (r.role in ('admin', 'supevisor'))
Выборка для супервизора (Bob, id=2) таким образом:
select * from articles aleft join roles r on r.userId = 2where a.owner = 2OR (r.role in ('admin', 'supevisor'))
А выборка для пользователя (Alice, id=1) выглядит так:
select * from articles aleft join roles r on r.userId = 1where a.owner = 1OR (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, 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, readp, user, article, modifyp, user, article, createp, user, article, deleteg, supervisor, userg, admin, supervisorg, 1, userg, 2, supervisorg, 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.ToList(); else return _context.Articles.Where(x => x.OwnerId == currentUserId).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, можете скачать и поиграться, если есть интерес и желание.