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> BanUser(
            [FromServices] IAsyncHandler handler,
            UserId userId) 
             => await handler.Handle(userId, HttpContext.RequestAborted);

        [HttpPatch("{id}")]
        public async Task> RemoveBanUser(
            [FromServices] IAsyncHandler 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

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) 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
let (>=>) (h1:WebPart ) (h2:WebPart ) ctx : Async=
  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, а какая функция принимает контекст и возвращает Async?
WebPart.

Получается что »>=>» принимает для хендлера WebPart и возвращает WebPart, поэтому мы можем написать несколько комбинаторов подряд, а не только два.
Подробности о работе комбинаторов можно найти здесь


При чем тут роли и ограничение доступа?

Вернемся к началу статьи, как можно явно указать программисту, к каким ресурсам возможен доступ для той или иной роли? Нужно внести в pipeline эти данные, чтобы хендлеры имели доступ к соответствующим ресурсам, я сделал это так:

i5ohziec88tovhgpezx11dc0lw0.png

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

Я положил в 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

Спасибо за внимание!

© Habrahabr.ru