Зачем нужна авторизация? Какие проблемы она решает и в каких ситуациях будет полезна? Рассмотрим модели организации контроля доступа и способы их реализации.

Привет, Хабр! Меня зовут Олег Козырев. Senior Golang инженер в BigTech-компании, ментор и блогер. Обучаю людей backend-разработке и консультирую по вопросам IT. Веду каналы «IT и жизнь»: в телеграме и на Ютюб. А главным героем этой статьи по мотивам моего доклада для GolangConf будет мой кот. Он проведёт нас по тернистому пути создания платформы контроля доступами.

Проблемы с доступом

Представим, что банк выкатил коту жирный оффер на много денег:

Нужно решить проблему с правами доступа, потому что в дырки безопасности лезут все, кто может. Зато тем, кому реально нужны права, выдать их не могут — бюрократия. Приходится ждать месяц. Коту нужно решить этот вопрос, и тогда он получит «жирный» sign-on бонус, за которым и пришёл работать.

Чтобы с этим разобраться, вспомним основные термины: идентификация, аутентификация и авторизация.

Кот приходит и просит денег. Ему отвечают: «А ты вообще кто такой? Почему мы тебе должны деньги давать?» — «Я кот!» — идентификация пройдена.

Но охранник не был бы охранником, если бы верил на слово. Он говорит: «Брат, лапы на осмотр предъяви!» Кот предъявляет лапы, тем самым доказывает, что он — действительно кот. И аутентификация пройдена!

Но не факт, что у кота действительно есть доступ. Охранник достаёт журнал, убеждается, что котов можно пускать в хранилище — пройдена авторизация.

Попав в хранилище, кот понял, что в банке с авторизацией дела обстоят плохо — какая-то тётка ищет нужную ячейку перебором, ничего не автоматизировано, говорит, что испокон веков так ещё её дед делал.

Кот понимает: нужно придумать обобщённое решение. И вернувшись домой, он начал размышлять о вариантах. Потом вспомнил, что work-life balance тоже нужно соблюдать, и решил подумать об этом в офисе. Придя на работу, развернул план инфраструктуры и увидел большой жирный юзер интерфейс, модный API Gateway и множество сервисов под ним (модерация, логистика, поддержка, склады и прочее-прочее). На рисунке изображено пять, в реальности их — сотни.

Кот подумал, что разбирается в вопросах безопасности и решил сделать одну эталонную авторизацию в качестве примера. Разработчики конкретных бизнес-проектов решают одну и ту же задачу. Но у каждого решения — своя специфика, и управлять полученным решением непрофильным специалистам будет сложно. Обнаружив баг в одном из решений, непонятно, как его исправлять во всех реализациях —  это долго, муторно и хлопотно.

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

Поиск общего решения

Для того чтобы понять, как построить общее решение, кот отправляется в библиотеку читать олдскульный код.

Выясняется, что базовая модель авторизации — это RBAC (Role Based Access Control), то есть авторизация, устроенная на ролях. Суть такова — есть ресурсы, например, GitLab и Kubernetes. В GitLab даём доступ и разрабам, и админам, а вот в Kuber — только админам.

Кот, не мудрствуя лукаво, делает для себя права администратора: «Всё в моей власти!». Но приходит его друг разработчик по имени Шем и возмущается, что его не пускают в Kubernetes. Кот поясняет, что такая политика компании.

И всё бы было круто, если бы действительно были аккуратно выделенны ограниченные роли. Но Шем заглянул в устав компании и выяснил, что если разработчик работает в ней пять лет, то в Kubernetes его должны пускать. Это усложнение: можно создать роли для разработчиков, которые работают в компании год, два, три и так далее. Так наплодим лишние сущности.

На выручку приходит другая модель авторизации — это ABAC (Attribute-Based Access Control), авторизация устроенная на атрибутах. Доступ к тем же ресурсам мы определяем не ролями, а атрибутами в виде опыта работы и принадлежности к человеческому виду. Очевидно, что кот даже со своими админскими правами теперь не пролезет в Kubernetes.

Приведу более жизненный пример — ABAC. В отделе есть админ, аналитик, модератор и новичок, которому ещё не дают полномочий принимать решения об одобрении контента.

Мы можем выделить ресурсы. Например, есть страница с аналитикой, управление модераторами и контент, который нужно проверять. Выделим атрибуты: resource_type, action. Сами роли тоже можем использовать в качестве атрибутов. Прописываем каждому из ресурсов политики. К примеру, чтобы аналитику получить доступ к странице аналитики, у него должны быть три атрибута в заданных значениях.

Интересно то, что атрибутами регулируется не только доступ к конкретной странице, но и действия на ней. Например, значение view позволяет только смотреть и не позволяет ничего редактировать. Но можно расширить права. У админа будет своя политика для админской страницы, у модератора — своя. Например, опытный модератор получит action, view и approve_content. А новичку мы таких прав не дадим, ограничив только просмотром.

Поразмыслив над этими вариантами, кот приходит к выводу, что RBAC хорош, когда мы реально можем выделить чёткие статичные роли и долгое время с ними жить. Но если мы хотим более гибкого поведения, которое обеспечивают атрибуты, то ABAC придёт на выручку.

Поскольку мы строим платформу, гибкость в приоритете. Поэтому нам подойдёт ABAC. Чтобы его реализовать, есть базовый механизм — это ACL (Access Control List). Это когда мы выделяем права на ресурсы. Например, просмотр клиентской информации, редактирование статусов клиентов, создание сделок и так далее.

Мы действительно можем довольно гибко управлять доступами. Но со временем неизбежно накопятся проблемы. Например, появятся новые сущности (селлеры и так далее). ACL тоже расплодятся до сотен и даже тысяч, а какие-то потеряют актуальность. Менеджерить их будет сложно, да и кто за это будет отвечать — непонятно. Конечно, можно их группировать, в какой-то степени решив проблему. Но ACL в любом случае лишают нас гибкости, которая может пригодиться.

Например, Шем сидит за компьютером и думает, откуда у него взялись заявки из Колумбии? Он модератор, и ему хотелось бы давать доступ к контенту из своего региона. Но как это сделать, если мы только привязались к конкретной странице, ресурсу. Чтобы разграничивать доступ по дополнительному атрибуту, придётся делать копии всех ACL для каждого из регионов. Это ужасно. А если появится ещё один атрибут, который нужно учитывать? Получится неконтролируемый хаос.

Таким образом, кот подумал, что ACL всё-таки не подходят.

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

allow {
  input.page == "analytics"
  input.operation == "view"
  input.region == "EU"
  input.experience >= 2
}

Первый вариант, который попался коту на глаза — гугловая разработка CEL. Довольно легковесный вариант, в котором можно прописать значения атрибутов пользователя, чтобы предоставить ему доступ. Это хороший вариант, но для более простых сценариев. Клиенты могут озадачить более серьёзными запросами. Например, клиент хочет выстраивать в политике доступа несколько сотен атрибутов, и в CEL возникают такие же проблемы, как и со следующим вариантом Casbin.

Casbin разработан под highload и работает достаточно быстро. Проблемы возникают, когда начинает плодиться множество атрибутов, потому что в первую очередь Casbin был разработан под RBAC, с которым на больших масштабах с гибкостью тоже могут возникнуть проблемы.

Кот остановился на языке Rego, потому что в компании кота уже была экспертиза по Rego. То есть и гибкость на больших масштабах выше, и в целом Rego — это стандарт, используемый в технологии Open Policy Agent, зарекомендовавший себя временем.

Базовый синтаксис в Rego — несложный. Например, Allow — это просто переменная, которая может принимать значение true или false. Если в четырёх строках с input атрибуты совпали, то выставляем true. То есть все строчки с input объединены логическим И. В то же время описана вторая политика. Если хотя бы одна из них сработает (выставится в true), то и финальный итог будет true — у пользователя должен сработать один из наборов атрибутов.

В итоге кот сделал выводы:

  • RBAC в нашем случае менее гибок. Он подойдёт, если вы соберетесь делать систему, в которой чётко прописанные роли.

  • ACL — тоже нормальное решение, но если система планирует разрастаться на большое количество ресурсов, вы столкнетесь с проблемой менеджмента ACL.

  • CEL — хорошее легковесное решение, но на больших масштабах не сработает как надо. На меньших масштабах же позволит очень быстро начать с ним работать и получать пользу.

  • Casbin хорош для highload, особенно в сценариях с RBAC, но мы хотим больше гибкости, поэтому выбираем Rego.

  • Rego подходит для сложных и централизованных политик.

Проектируем авторизацию на Rego

Возникает вопрос: как всех обучить Rego? Разрабы с Go порой с трудом справляются, а тут ещё и Rego! Они просто скажут: «Зачем нам авторизация? И так нормально жили без неё». Тут приходит резонный ответ — нужна платформа, которая облегчит труд разработчиков, чтоб они могли просто накликать кнопочки и завести авторизацию в своём сервисе. Ведь авторизация не является центровым процессом, который они в своем сервисе покрывают.

Очевидно, что нужно начать с проектирования платформы. И тут стоит напомнить, что Rego —  язык, используемый в технологии под названием Open Policy Agent (OPA). Использовать её можно в двух вариантах:

Вариант 1. Берём готовое коробочное решение, раскатываем у себя на сервере. Rego эти политики хранит, кэширует, обеспечивает скорость работы. Мы просто ему сообщаем, кого нужно проверить на возможность доступа, и он нам выдаст ответ.

Минус: сложно кастомизировать коробочное решение, возможно, нам понадобится что-то дополнительно. Конечно, решение написано на Go, но вопросики с кастомизацией могут всё равно возникнуть.

Вариант 2. Подключаем Go библиотеку, которая будет принимать на вход саму политику и атрибуты, подставлять в эту политику атрибуты и выдавать ответ.

Вернёмся к архитектуре. Нам понадобится менеджер пользователей. Но мы хотим, чтобы у пользователей были какие-то атрибуты, чтобы мы сравнивали их с атрибутами ресурса — пускать пользователя туда или нет. Для этого создадим сервис, который будет их хранить. Дополнительно будем использовать его ещё для аутентификации, но это отдельная история с сессиями. Также нам понадобится движок политик, то есть тот самый сервис, который бы рендерил, билдил эти политики и определял, давать доступ или нет. Хранитель атрибутов будет хранить необходимые выставленные ограничения для пользователя к ресурсу. Классическое решение — поставить Gateway перед этим, чтобы разрабы, которые к нам заезжают на платформу со своими сервисами, вообще в своём коде ничего не модифицировали.

Флоу запроса такой. Например, сервис 1 или пользователь пытается попасть в сервис 2. Он попадает в Gateway. Gateway перенаправляет запрос в движок политик. Движок политик, чтобы сделать вывод — пускать или не пускать, обращается к хранилищу атрибутов, чтобы понять атрибуты ресурса. Затем идёт в менеджер пользователей, чтобы понять атрибуты пользователя, делает вывод, возвращает ответ, и если он положительный, то запрос проксируется дальше.

С точки зрения логики платформы, в принципе, никаких открытий и инноваций не случилось — просто набор сервисов. Но взглянем на сам Rego, вспомним его синтаксис.

Мы задали дефолтное значение для allow и выбираем из двух политик: либо роль админа, либо роль менеджера c пятью годами опыта. Это два условия, при которых мы пускаем к ресурсу.

С Rego определились, теперь разберёмся, как это использовать в коде на Go.

type authParams struct {
    role            string
    experienceYears int
}

В коде создаём структуру, которая содержит поля, соответствующие атрибутам, использованные в политике. Затем формируем input для библиотеки на Go. Напомню, мы будем использовать именно библиотеку, которая сбилдит эти политики. Важный момент, что просто предоставляется требование, что там это должно быть map[string]interface{}, то есть нетипизированное значение.

Мы это всё заполнили. Дальше создаём достаточно простой запрос. Во-первых, говорим, значение какого поля хотим выцепить. Значение помещаем в поле allow. Там есть пакет authorization — напишем сверху package name и всё. А data — это дефолтный namespace, куда всё кладётся, если вы не переопределили специально.

В Rego.Module просто пробрасываем политику. Слово policy — это название константы, в которую поместили политику. Есть отдельная функция, которая позволяет политику подгружать из файла или строки. В сам Input поместим мапу.

После этого выполним запрос.

    rs, err := regoQuery.Eval(ctx)
    if err != nil {
        return false, fmt.Errorf("ошибки при оценке политики: %w", err)
    }

Там происходит магия и высылает нам ответ. Поскольку ответ содержит всего лишь одну булевскую переменную, а так бывает далеко не всегда, мы просто обращаемся к нулевому элементу. Нам остаётся его скастить в bool, вернуть из нашей функции и сказать — ОК или нет.

Избавляем разработчиков от рутины

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

На платформе появляются два типа абстракций:

  1. Ресурсы — что-то, к чему мы хотим получить доступ (кнопка, страница, что угодно).

  2. Политики — правила, по которым определяется доступ к тому или иному ресурсу.

Заходя на платформу, мы попадаем во фронтенд. Начнём с ресурсов, которые нужно создать.

Зададим название, например, golang_conf. Дальше сделаем описание этого ресурса и добавим к нему действия. Например, API методы, которые хотим закрыть. По факту здесь они просто представлены строкой. Можно указывать глаголы. Если вы авторизацией хотите закрыть какой-то API, самый простой вариант — задать действия в соответствии со своим API. Это нужно, чтобы более точечно применять политики. Можно на весь ресурс golang_conf одну и ту же политику навесить, а можно точечно. Например, одну политику на действие create, другую — на действие delete и так далее.

В данном случае мы будем смотреть на два атрибута: название источника и его идентификатор. Если у пользователя подобные атрибуты совпадают с тем, что есть в политике, то мы его пускаем.

Самое приятное здесь то, что создав ресурс, мы не закидываем его сразу в продакшен. Он в статусе черновика. И только когда мы убедились, что всё в порядке, можем нажать «Опубликовать явно» и он выставится на продакшен. Другая приятность состоит в том, что мы храним все релизы и если поняли, что что-то пошло не так, можем откатиться назад и прыгнуть на предыдущий релиз или на два, а затем снова вернуться. Сделано как в Git, только без всяких pull-requests и merge, но ревизии сохраняем.

После этого переходим в политики.

Теперь остаётся написать политику к ресурсу. Мы выбираем, какой ресурс хотим покрыть политикой. Например, golang_conf, который мы только что создали, и действие, к которому будет применяться эта политика. Можно применить её ко всем, а можно только к некоторым. Дальше описываем правила. Тут можно сравнивать атрибуты ресурса с атрибутом пользователя или атрибуты пользователя с какой-то константой. Например, у пользователя есть permission, значит, можно посмотреть, есть ли он в слайсе permission, который выставляет политика, то есть входит ли строка в состав слайса. Тут можно настраивать различные операции.

После настройки, точно также, как с ресурсами, появляется политика. Мы должны её явно опубликовать. У нас тоже поддерживаются релизы этих политик, можно откатываться. Когда текущий релиз сверху, другие появляющиеся будут складываться под ним.

Как генерировать политики

Рассмотрим пример с SourceUUID и SourceSlug:

policy_resource := {
    "source_uuid": "{{ .SourceUUID }}",
    "source_slug": "{{ .SourceSlug }}"
}

В текущем Rego-файле атрибуты ресурса фактически зашиты в код. Дальше сравним их с атрибутами пользователя, которые передаются в input — нетипизированную мапу, содержащую данные о пользователе. Атрибуты пользователя сохранятся в менеджере пользователя в Postgres. Нам нужно избавиться от хардкода на 5, 6, 7 строках. 

В этот момент появляется специфика Go. В Go есть template. Мы заменяем их такой штуковиной. То есть мы превратили политику в шаблонизированную. На место плейсхолдеров в фигурных скобках, собрав с помощью библиотеки text template этот template, мы просто подставим значения из структуры. А сами значения сохраним уже в базе. Когда ресурс создавали, все эти атрибуты подписали.

Дальше остаётся только сделать универсальный template и это не самая простая затея.

required_permissions := {
  {{ range $i, $perm := .RequiredPermissions }}
    {{ if $i }}, {{ end }}"{{ $perm }}"
  {{ end }} }

Попытаемся понять, есть ли у пользователя необходимые атрибуты, которые присутствуют в политике. Например, политика выставляет требования, что должно быть read и write, а у пользователя могут быть read, write, ещё что-то. Нам важно вычленить, есть ли необходимые атрибуты в его списке. По факту тут происходит просто вычитание двух сетов. Остальная магия тут чуть выше 13 строки. Она сводится к тому, что данные извне приходят в виде слайса. А мы их просто конвертируем в сет, потому что с сетом есть операция минус. 

Как раз на 13 строчке происходит вычитание из необходимых атрибутов пользовательских. Если финальный сетбот 0, значит, все необходимые права есть. А если не 0, то у нас сохранится список прав, которых не хватает, и мы можем дать ответ не просто true/false, а сказать, чего конкретно не хватает. Это достаточно полезно для самого пользователя. Причём настроено это таким образом, что если не хватает, например, 10 прав, то все 10 выплюнутся за раз, а не будут выдаваться по одному. Это экономит время.

Но тут возникает ещё одна проблема — если мы хотим шаблонизировать политики, нам придётся повторять в шаблонах операции, которые предоставляет синтаксис Rego. Например, вхождение строки в массив, работа с int, string, преобразования из lowercase в uppercase и так далее. Повторять приходится, чтобы пользовательские настройки преобразовались в нужные операторы внутри Rego. Это сложно.

На самой первой итерации развития платформы получилась ситуация, когда есть возможность задать только string-атрибуты. Это был MVP по факту, то есть необходимая штука, которая чаще всего используется. И, что интересно, действительно самый распространенный вариант использования — когда мы просто сравниваем две строки. Но потом приходит клиент и говорит, что было бы неплохо проверять ещё вхождение в массив — идёшь в шаблон, докручиваешь, потом заводишь int и так далее.

Наверное, основная проблема и сложность развития такого подхода в том, что Rego придётся частично переизобретать, создавать над ним обёртку. Конечно же, не стоит сломя голову лезть и сразу пытаться переопределить все операторы. Это может быть бессмысленно.

В то же время мы не будем на каждый запрос билдить политику из шаблонизированного вида в финальный, который требует библиотека. Поэтому здесь поставим Redis.

Мы сбилдили разок, положили финальную политику в Redis — и всё получилось. А сами шаблоны заэмбэдим, чтобы не перезаписывать их каждый раз. Тем самым ускорим доступ к движку политик, который определяет пускать или не пускать пользователя. Также можно в кэш положить атрибуты, чтобы не обращаться к базе, но смотрим на основе метрик, имеет это смысл или нет.

А что дальше? С одной стороны, мы решили вопрос с тем, чтобы сделать авторизацию платформенной и универсальной, и проговорили возможные проблемы с Rego. Но в целом такой подход способен выдержать тысячу атрибутов. Как показывает практика, больше 400 атрибутов на политику никому не требовалось. Поэтому все довольны решением кота, потому что оно удовлетворяет требованиям внутри компании. В то же время хотелось бы подумать о том, как развивать получившееся решение дальше и какие бонусы это даст.

Поскольку авторизация становится централизованной, мы можем её дополнять, например, централизованным аудитом безопасности. Мы будем просто следить за тем, какие действия происходят во всех сервисах платформы. Также мы можем переиспользовать модуль авторизации. Это переиспользование случилось неспроста. 

Вернёмся к нашей схеме и вспомним, что есть менеджер пользователей. Он использовался как в движке политик, так и в аутентификации, чтобы хранить сессии, понимать, когда логинить пользователей. Но в то же время клиенты платформы хотели бы со своих бекэндовых сервисов обращаться к нему для разных операций. Может, они хотят выгрузить список своих пользователей к себе на фронтенд, а может, что-то поменять или обернуть дополнительной логикой. Просто так открывать в платформенное API для всех — рискованно. Кто-то может случайно удалить важные данные, и отвечать за это придётся вам. Лучше этого избежать и обеспечить контроль доступа.

Поэтому перед сервисом была поставлена Proxy (Gateway нам в этом флоу не нужен). Она сначала обращалась к движку политик, в хранилище атрибутов, подгружала атрибуты сервиса, который хочет получить доступ, и выдавала ответ — «ОК» или «НЕТ». В менеджер пользователя шёл запрос, если с доступами всё было успешно.

И итоге кот пришёл к начальнику и сказал, что всё сделал — вопрос решен!

Выводы

  • Всегда анализируйте имеющиеся решения. Rego у нас появился благодаря такому анализу. Если бы мы решили писать с нуля, вы бы эту статью не читали — я бы всё ещё писал решение и в нём ничего бы не работало.

  • Изобретать велосипед придётся, но его размер может быть разный, потому что есть некоторые специфики (инфраструктура, условия компании и всякое разное). Дополнительный анализ позволяет уменьшить размер велосипеда. По крайней мере, мы стремимся к уменьшению велосипедостроения. Если думать и анализировать, в итоге работать придётся меньше.

  • Будьте готовы закрыть проекта. Платформенная разработка — это прыжок вверх для компании. Если компания маленькая, то, возможно, платформа и не нужна. Вместо этого просто возьмём Rego, напишем парочку политик, задеплоим, и всё заработает. Можно взять CEL или Casbin. Но если компания большая, то в перспективе это, скорее всего, окупится. Но эта перспектива далеко не всем всегда очевидна. В моменте это может привести к тому, что вас решат закрыть — непонятно, какие перспективы у этого проекта. Так что готовьтесь держать оборону.

  • Ответьте на вопрос, а нужна ли вам платформа?

Комментарии (0)