API на F#. Доступ к модулям приложения на основе ролей

от автора

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

Async версия

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
Спасибо за внимание!


ссылка на оригинал статьи https://habr.com/ru/post/461593/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *