Хабрахабр

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

NET Core по стандарту предлагает настраивать доступ к api с помощью атрибутов, есть возможность ограничить доступ пользователям с определенным claim, можно определять политики и привязывать к контроллерам, создавая контроллеры для разных ролей
У этой системы есть минусы, самый большой в том, что смотря на этот атрибут: ASP.

[Authorize(Roles = "Administrator")]
public class AdministrationController : Controller
{
}

Мы не получаем никакой информации о том, какими правами обладает администратор.

У меня стоит задача, вывести всех забаненных пользователей за этот месяц (не просто сходить в базу и отфильтровать, есть определенные правила подсчета, которые где-то есть), я делаю CTRL+N по проекту и ищу BannedUserHandler или IHasInfoAbounBannedUser или GetBannedUsersForAdmin.

Я нахожу контроллеры, помеченные атрибутом [Authorize(Roles = "Administrator")], тут может быть два сценария:

Делаем все в контроллере

[Route("api/[controller]/[action]")] public class AdminInfoController1 : ControllerBase // 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>

Внутри новые функции абсолютно идентичны оригинальным

Либо разнести эти хендлеры по разным модулям(желательно), либо сделать доступ ленивым(обернуть в unit -> friends list), главное не класть туда IQueryable<Friend>, потому это не сервис — это набор данных, определяющий роль Теперь хендлер сразу имеет доступ к ресурсам для каждой роли, туда нужно добавить только основное, чтобы можно было легко ориентироваться, например в AccountPart можно добавить никнейм, email, роль пользователя, список друзей если это соц.сеть, но возникает проблема: для одного подавляющего большинства хендлеров мне нужен список друзей, но для оставшихся он вообще не нужен, что делать?

Я положил в AdminInfo информацию об одобренных и забаненных пользователях текущим админом, в контексте моего "приложения" это определяет роль Администратора:

type AdminInfo = { ActiveUsersEmails: string list BanUsersEmails : string list } type UserInfo = { Name:string Surname:string }

Можно же в контроллере сделать User. В чем отличие от Claim? 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
Спасибо за внимание!

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть