Pull to refresh

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

Reading time6 min
Views4.8K

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


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

Tags:
Hubs:
+13
Comments3

Articles