ASP.NET Core по стандарту предлагает настраивать доступ к api с помощью атрибутов, есть возможность ограничить доступ пользователям с определенным claim, можно определять политики и привязывать к контроллерам, создавая контроллеры для разных ролей
У этой системы есть минусы, самый большой в том, что смотря на этот атрибут:
[Authorize(Roles = "Administrator")]
public class AdministrationController : Controller
{
}
Мы не получаем никакой информации о том, какими правами обладает администратор.
У меня стоит задача, вывести всех забаненных пользователей за этот месяц (не просто сходить в базу и отфильтровать, есть определенные правила подсчета, которые где-то есть), я делаю CTRL+N по проекту и ищу BannedUserHandler или IHasInfoAbounBannedUser или GetBannedUsersForAdmin.
Я нахожу контроллеры, помеченные атрибутом [Authorize(Roles = "Administrator")], тут может быть два сценария:
Делаем все в контроллере
[Route("api/[controller]/[action]")]
public class AdminInfoController1 : ControllerBase
{
private readonly IGetUserInfoService _getInfoAboutActiveUsers;
private readonly ICanBanUserService _banUserService;
private readonly ICanRemoveBanUserService _removeBanUserService;
// зависимости нужны нескольким action
public AdminInfoController1(
IGetUserInfoService infoAboutActiveUsers,
ICanBanUserService banUserService,
ICanRemoveBanUserService removeBanUserService)
{
_getInfoAboutActiveUsers = infoAboutActiveUsers;
_banUserService = banUserService;
_removeBanUserService = removeBanUserService;
}
// actions
//...
//...
}
Разносим по хендлерам
[Route("api/[controller]/[action]")]
public class AdminInfoController2 : ControllerBase
{
[HttpPatch("{id}")]
public async Task<ActionResult<BanUserResult>> BanUser(
[FromServices] IAsyncHandler<UserId, BanUserResult> handler,
UserId userId)
=> await handler.Handle(userId, HttpContext.RequestAborted);
[HttpPatch("{id}")]
public async Task<ActionResult<RemoveBanUserResult>> RemoveBanUser(
[FromServices] IAsyncHandler<UserId, RemoveBanUserResult> handler,
UserId userId)
=> await handler.Handle(userId, HttpContext.RequestAborted);
}
Первый подход неплох тем, что нам известно доступ к каким ресурсам имеет Админ, какие зависимости он может использовать, я бы использовал такой подход в маленьких приложениях, без сложной предметной области
Второй же не такой говорящий, все зависимости разрешаются в хендлерах, я не могу посмотреть на конструктор и понять какая зависимость нужна мне, такой подход оправдывает себя, когда приложение сложное и контроллеры разбухают, их становится невозможно поддерживать. Классическое решение этой проблемы разбиение решения на папки/проекты, в каждую/ый кладутся нужные сервисы, их легко найти и использовать
У всего этого есть большой недостаток, код не говорит разработчику что делать, заставляет задумываться => трата времени => ошибки в реализации
А чем больше приходится думать, тем больше совершается ошибок.
Введение в маршрутизацию Suave
Что если routing будет строиться так:
let webPart =
choose [
path "/" >=> (OK "Home")
path "/about" >=> (OK "About")
path "/articles" >=> (OK "List of articles")
path "/articles/browse" >=> (OK "Browse articles")
path "/articles/details" >=> (OK "Content of an article")
]
''>=>'' — что это? У этой штуки есть название, но его знание ни на грамм не приблизит читателя к пониманию, как это работает, поэтому приводить его нет смысла, лучше рассмотрим, как все работает
Выше написан pipeline от Suave, такой же используется в Giraffe (с другой сигнатурой функций), есть сигнатура:
type WebPart = HttpContext -> Async<HttpContext option>
Async в данном случае не играет особой роли(чтобы понять как это работает), опустим его
HttpContext -> HttpContext option
Функция с такой сигнатурой принимает HttpContext, обрабатывает (десериализует тело, смотрит на куки, заголовки реквеста), формирует ответ, и если все прошло успешно — оборачивает в Some, если что-то не так, возвращает None, например (библиотечная функция):
// дополнительно оборачиваем в async
let OK s : WebPart =
fun ctx ->
{ ctx with response =
{ ctx.response with status = HTTP_200.status; content = Bytes s }}
|> Some |> async.Return
Эта функция не может "завернуть поток выполнения запроса", всегда прокидывает дальше новый response, с телом и статусом 200, а вот эта может:
let path (str:string) ctx =
let path = ctx.request.rawPath
if path.StartsWith str
then ctx |> Some |> async.Return
else async.Return None
Последняя нужная функция это choose — получает список различных функций и выбирает ту, которая первая вернет Some:
let rec choose
(webparts:(HttpContext) -> Async<HttpContext option>) list) context=
async{
match webparts with
| [head] -> return! head context
| head::tail ->
let! result = head context
match result with
| Some _-> return result
| None -> return! choose tail context
| [] -> return None
}
Ну и самая главная, связывающая функция (Async опущен):
type WebPartWithoutAsync = HttpContext -> HttpContext option
let (>=>) (h1:WebPartWithoutAsync ) (h2:WebPartWithoutAsync) ctx
: HttpContext option =
let result = h1 ctx
match result with
| Some ctx' -> h2 ctx'
| None -> None
type WebPart = HttpContext -> Async<HttpContext option>
let (>=>) (h1:WebPart ) (h2:WebPart ) ctx : Async<HttpContext option>=
async{
let! result = h1 ctx
match result with
| Some ctx' -> return! h2 ctx'
| None -> return None
}
">=>" принимает два хендлера с левой и правой сторон и httpContext, когда приходит запрос, сервер формирует объект HttpContext, и передает его функции, ">=>" выполняет первый(левый) хендлер, если он вернул Some ctx, передает ctx на вход второму хендлеру.
А почему мы можем писать так (комбинировать несколько функций)?
GET >=> path "/api" >=> OK
Потому что ">=>" принимает две функции WebPart и возвращает одну функцию принимающую HttpContext и возвращающую Async<HttpContext option>, а какая функция принимает контекст и возвращает Async<HttpContext option>?
WebPart.
Получается что ">=>" принимает для хендлера WebPart и возвращает WebPart, поэтому мы можем написать несколько комбинаторов подряд, а не только два.
Подробности о работе комбинаторов можно найти здесь
При чем тут роли и ограничение доступа?
Вернемся к началу статьи, как можно явно указать программисту, к каким ресурсам возможен доступ для той или иной роли? Нужно внести в pipeline эти данные, чтобы хендлеры имели доступ к соответствующим ресурсам, я сделал это так:
Приложение разделяется на части/модули. В функциях AdminPart и AccountPart разрешается доступ к этим модулям различных ролей, к AccountPart имеют доступ все пользователи, к AdminPart только админ, происходит получение данных, обратите внимание на функцию chooseP, я вынужден добавить еще функции, потому что стандартные привязаны к типам Suave, а теперь у хендлеров внутри AdminPart и AccountPart другие сигнатуры:
// AdminPart
AdminInfo * HttpContext -> Async<(AdminInfo * HttpContext) option>
// AccountPart
AccountInfo* HttpContext -> Async<(AccountInfo * HttpContext) option>
Внутри новые функции абсолютно идентичны оригинальным
Теперь хендлер сразу имеет доступ к ресурсам для каждой роли, туда нужно добавить только основное, чтобы можно было легко ориентироваться, например в AccountPart можно добавить никнейм, email, роль пользователя, список друзей если это соц.сеть, но возникает проблема: для одного подавляющего большинства хендлеров мне нужен список друзей, но для оставшихся он вообще не нужен, что делать? Либо разнести эти хендлеры по разным модулям(желательно), либо сделать доступ ленивым(обернуть в unit -> friends list), главное не класть туда IQueryable<Friend>, потому это не сервис — это набор данных, определяющий роль
Я положил в AdminInfo информацию об одобренных и забаненных пользователях текущим админом, в контексте моего "приложения" это определяет роль Администратора:
type AdminInfo = {
ActiveUsersEmails: string list
BanUsersEmails : string list
}
type UserInfo = {
Name:string
Surname:string
}
В чем отличие от Claim? Можно же в контроллере сделать User.Claims и достать то же самое?
В типизации и в "говорящих": модулях, разработчик не должен искать примеры кода по хендлерам, находящимся в том же контексте, он создает хендлер и добавляет в роутинг и заставляет все это компилироваться
let AccountPart handler =
let getUserInfo ctx =
async.Return {Name="Al";Surname="Pacino"}
permissionHandler [User;Admin] getUserInfo handler
getUserInfo получает данные для модуля Account, имеет доступ к контексту, чтобы достать персональные данные(именно этого user'a, admin'a)
permissionHandler проверяет наличие jwt token'a, расшифровывает его, и проверяет доступ, возвращает оригинальный WebPart, чтобы сохранить совместимость с Suave
Полный исходный код можно найти на github
Спасибо за внимание!
Автор: Евгений