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