Управление правами доступа в Expressjs при помощи CASL

  • Tutorial



В современных приложениях, которые поддерживают аутентификацию, мы часто хотим изменить то, что видимо для пользователя, в зависимости от его роли. Например, гостевой пользователь может видеть статью, но только зарегистрированный пользователь или администратор видит кнопку для удаления этой статьи.


Управление этой видимостью может стать полным кошмаром с увеличением количества ролей. Вероятно, вы уже писали или видели код вроде этого:


if (user.role === ADMIN || user.auth && post.author === user.id) {
  res.send(post)
} else {
  res.status(403).send({ message: 'You are not allowed to do this!' })
}

Такой код распространяется по приложению и обычно становится большой проблемой, когда заказчик изменяет требования или просит добавить дополнительные роли. В конце концов вам нужно пройти все такие if-s и добавить дополнительные проверки.


В этой статье я покажу альтернативный способ реализации управления разрешениями в Expressjs API с помощью библиотеки, которая называется CASL. Она намного упрощает управление правами доступа и позволяет переписать предыдущий пример на что-то вроде этого:


if (req.ability.can('read', post)) {
  res.send(post)
} else {
  res.status(403).send({ message: 'You are not allowed to do this!' })
}

Впервые слышете о CASL? Рекомендую прочесть Что такое CASL?


Примечание: эта статья изначально была опубликована на Medium


Демо-приложение


В качестве тестового приложения я сделал достаточно простой REST API для блога. Приложение состоит из 3 сущностей (User, Post и Comment) и 4 модулей (по одному модулю для каждой сущности и еще один — для проверки авторизации). Все модули можно найти в папке src/modules.


Приложение использует mongoose модели, аутентификацию passportjs и авторизацию (или проверку прав доступа) на базе CASL. При помощи API, пользователь может:


  • читать все статьи и комментарии
  • создавать пользователя (т.е., регистрироваться)
  • управлять собственными статьями (создавать, редактировать, удалять), если авторизован
  • обновлять персональную информацию, если авторизован
  • управлять собственными комментариями, если авторизован

Чтобы установить это приложение, просто склонируйте его из github и запустите npm install и npm start. Также нужно запустить MongoDB сервер, приложение подключается к mongodb://localhost:27017/blog. После того, как все будет готово, можно немного поиграться, а чтобы было веселее, заимпортите базовые данные с папки db/:


mongorestore ./db

В качестве альтернативы, Вы можете следовать инструкциям в README файле проекта или использовать мою Postman коллекцию.


В чем фишка?


Во-первых, большим плюсом CASL является, то что он позволяет определить права доступа в одном месте, для всех пользователей! Во-вторых, CASL концентрирует внимание не на том кем является пользователь, а на том что он может делать, т.е. на его возможностях. Это позволяет распределять эти возможности разным ролям или группам пользователей без лишних усилий. Это значит, что мы можем прописать права доступа для авторизированных и не авторизированных пользователей:


const { AbilityBuilder, Ability } = require('casl')

function defineAbilitiesFor(user) {
  const { rules, can } = AbilityBuilder.extract()

  can('read', ['Post', 'Comment'])
  can('create', 'User')

  if (user) {
    can(['update', 'delete', 'create'], ['Post', 'Comment'], { author: user._id })
    can(['read', 'update'], 'User', { _id: user._id })
  }

  return new Ability(rules)
}

const ANONYMOUS_ABILITY = defineAbilitiesFor(null)

module.exports = function createAbilities(req, res, next) {
  req.ability = req.user.email ? defineAbilitiesFor(req.user) : ANONYMOUS_ABILITY
  next()
}

Давайте теперь разберем код написанный выше. В функции defineAbilitiesFor(user) создается экземпляр AbilityBuilder-a, его метод extract разбивает этот объект на 2 простые функции can и cannot и массив rules (в данном коде cannot не используется). Далее при помощи вызовов функции can мы определяем, что пользователь может делать: первым аргументом передает действие (или массив действий), вторым аргументом — тип объекта над которым проводится действие (или массив типов) и третьим необязательным аргументом можно передать объект условий. Объект условий используется при проверке прав доступа на экземпляре класса, т.е. он проверяет равняется ли свойство author объекта post и user._id, если равны, то вернется true, в противном случае false. Для большей наглядности приведу пример:


// Post is a mongoose model
const post = await Post.findOne()
const user = await User.findOne()

const ability = defineAbilitiesFor(user)

console.log(ability.can('update', post)) // если post.author === user._id, то вернется true

Далее при помощи if (user) мы определяем права доступа для авторизованного пользователя (если пользователь не авторизован, то мы не знаем кто он и не имеем объекта с информацией о пользователе). В конце возвращаем экземпляр класса Ability, с помощь которого мы и будем проверять права доступа.


Далее создаем константу ANONYMOUS_ABILITY, она является инстансом Ability класса для не авторизованных пользователей. В конце, экспортируем express middleware, которое отвечает за создание Ability экземпляра для конкретного пользователя.


Тестируем API


Давайте протестируем, что у нас получилось используя Postman. Для начала нужно получить accessToken, для этого отправьте запрос:


POST /session

{
  "session": {
    "email": "casl@medium.com",
    "password": "password"
  }
}

В ответ получите что-то вроде этого:


{ "accessToken": "...." }

Этот токен нужно вставить в Authorization header и отправлять со всеми последующими запросами.


Теперь давайте попробуем обновить статью


PATCH http://localhost:3030/posts/597649a88679237e6f411ae6

{
  "post": {
    "title": "[UPDATED] my post title"
  }
}

200 Ok

{
  "post": {
    "_id": "597649a88679237e6f411ae6",
    "updatedAt": "2017-07-24T19:53:09.693Z",
    "createdAt": "2017-07-24T19:25:28.766Z",
    "title": "[UPDATED] my post title",
    "text": "very long and interesting text",
    "author": "597648b99d24c87e51aecec3",
    "__v": 0
  }
}

Все хорошо работает. А что если обновим чужую статью?


PATCH http://localhost:3030/posts/59761ba80203fb638e9bd85c

{
  "post": {
    "title": "[EVIL ACTION] my post title"
  }
}

403 

{
  "status": "forbidden",
  "message": "Cannot execute \"update\" on \"Post\""
}

Получили ошибку! Как и ожидалось :)


А теперь давайте представим, что для авторов нашего блога мы хотим создать страницу, где они смогут видеть все посты которые они могут обновлять. С точки зрения конкретной логики это несложно, просто нужно выбрать все статьи в которых author равняется user._id. Но мы ведь уже прописали такую логику при помощи CASL, было бы очень удобно получить все такие статьи из базы без написания лишних запросов, да и если права поменяются, то придется менять и запрос — лишняя работа :).


К счастью, CASL имеет дополнительный npm пакет — @casl/mongoose. Этот пакет позволяет запрашивать записи из MongoDB в соответствии с определенными правами доступа! Для mongoose этот пакет предоставляет плагин, который добавляет метод accessibleBy(ability, action) в модельку. С помощью этого метода мы и будем запрашивать записи из базы (больше об этом читайте в документации CASL и README файле пакета).


Это именно, то как реализован handler для /posts (я также добавил возможность указывать, для какого действия нужно проверять права доступа):


Post.accessibleBy(req.ability, req.query.action)

Так вот, для того чтобы решить задачу описанную ранее, достаточно добавить параметр action=update:


GET http://localhost:3030/posts?action=update

200 Ok
{
  "posts": [
    {
      "_id": "597649a88679237e6f411ae6",
      "updatedAt": "2017-07-24T19:53:09.693Z",
      "createdAt": "2017-07-24T19:25:28.766Z",
      "title": "[UPDATED] my post title",
      "text": "very long and interesting text",
      "author": "597648b99d24c87e51aecec3",
      "__v": 0
    }
  ]
}

В заключение


Благодаря CASL у нас есть действительно хороший способ управления правами доступа. Я более чем уверен, что конструкция типа


if (ability.can('read', post)) ...

намного понятнее и проще чем


if (user.role === ADMIN || user.auth && todo.author === user.id) ...

При помощи CASL мы можем быть более четкими в отношении того, что делает наш код. Кроме того, такие проверки, безусловно, будут использоваться в другом месте нашего приложения, и именно здесь CASL поможет избежать дублирования кода.


Я надеюсь, Вам было на столько же интересно читать о CASL, на сколько мне было интересно его создавать. CASL имеет довольно хорошую документацию, Вы наверняка найдете там много полезной информации, но не стесняйтесь задавать вопросы если что в gitter чате и добавить звездочку на гитхабе ;)

Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 1
    +1
    Тот страшный момент, когда дошел до ссылки на медиум, начал читать в нем, а закончил читать все это дело в телеграмме…

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое