Привет! Меня зовут Антон Губарев, я работаю инженером в Avito PaaS и веду канал о техническом лидерстве и инфраструктурной разработке на Go «Техлидошная». Сегодня я расскажу о том, как мы реализовывали межсервисную авторизацию в рамках платформы и с какими проблемами столкнулись. Я — фича-лид и основной разработчик этого проекта, поэтому мне есть, чем поделиться.

PaaS в Авито: 2500 сервисов, на которых нужно было реализовать авторизацию, и ничего не сломать
PaaS, по сути — набор готовых решений, который помогает продуктовым разработчикам не тратить время на погружение в особенности инфраструктуры: как она работает, как устроен прод, сколько используется своих дата-центров, сколько — облачных, и так далее. Подробнее про PaaS в Авито можно почитать в статье моего коллеги Александра Лукьянченко.
В PaaS есть UI-интерфейс — дашборд. В нём находится всё для управления сервисами. Например:
-
деплой в разные окружения;
-
информация о потреблении ресурсов;
-
задеплоенные манифесты в Kubernetes;
-
связи: в какие другие сервисы ходит этот сервис и откуда приходят запросы к нему.
Ещё у нас есть собственный формат для межсервисного взаимодействия — brief. Он похож на Protobuf, но немного упрощённый и заточенный под наши требования.
Пример брифа:
service "some-service" idempotent rpc sum (SumIn) SumOut 'A sum method' rpc countWords (CountWordsIn) CountWordsOut 'Count Words in text' message SumIn { a int 'A first number' b int 'A second number' } message SumOut { sum int 'A sum of the numbers' } message CountWordsIn { text string } message CountWordsOut { words int }
Похоже на protobuf, но только гораздо проще. Это значит, потребуется немного времени на погружение и вход.
Вот, как команды используют Brief:
-
Разработчики пишут brief-схемы для сервиса, описывают ручки, которые хотят сделать.
-
Затем они используют генератор, который на нужном языке программирования генерит код сервера и клиента
-
Остается только реализовать логику внутри ручек или использовать клиенты для походов в другие сервисы
-
При деплое brief-схемы регистрируются в отдельном реестре
Внутри платформы 2500 сервисов, и все они тесно связаны друг с другом форматом Brief. На этом большом живом организме нам нужно было реализовать авторизацию так, чтобы ничего не сломалось. Поговорим о том, как мы это делали.
Наши требования к межсервисной авторизации
Чтобы понять, каким будет наше решение для межсервисной авторизации, мы опросили команды сервисов, живущих внутри PaaS, поговорили с отделом безопасности, и выявили шесть требований:
-
Контролировать доступ к ручкам. Часть сервисов или отдельные ручки могут содержать чувствительную информацию: данные пользователей или финансовые показатели.
-
Логировать изменения. Нужно знать, кто и когда вносил изменения и пользовался ручками, — это основа любой системы безопасности.
-
Не сломать связи 2.500 сервисов. Нарушение любой из связей могло привести к деградации продакшена.
-
Не менять сервисы. Сервисов много, поэтому заставлять сотни разработчиков вносить изменения в свои сервисы — неправильно.
-
Не повлиять на скорость работы. Для некоторых сервисов лишние 10мс — уже критично.
-
Сделать платформенное решение. Некоторые команды уже стали пилить свою локальную авторизацию: например, через JWT-токены. Нам нужно было централизованное и удобное решение, подходящее всем.
Проанализировав требования, мы стали думать, как продуктово организовать конфигурацию политик авторизации.
Конфигурация политик авторизации. Как это выглядит для пользователя
У нас уже был подход конфигурации как код. В каждом сервисе есть файлик app.toml, на основании которого наша платформа катит сервис так, как это нужно разработчикам:
-
с нужным количеством реплик;
-
с нужными переменными окружения;
-
с указанным расписанием запуска кронов;
-
и многое другое.
Формат при этом максимально абстрагирован от инфраструктурных деталей и прост в использовании.
Политики конфигурируются через файл auth.toml — по аналогии с app.toml. Вот почему мы решили так сделать:
-
разработчики уже привыкли к toml;
-
можно логировать и апрувить изменения через git и настройки codereview;
-
можно использовать канареечное тестирование;
-
разработчикам не нужно погружаться в реализацию.
Структура auth.toml состоит из двух разделов: default и policy. Они позволяют задать общие правила и конкретизировать их для отдельных ручек.
Политик может быть несколько. Главное — не повторять ручки, ни внутри политики, ни внутри всего auth.toml, чтобы избежать наложений. Это контролирует наш валидатор.
В политиках мы указываем набор ручек и перечисляем клиентов, которые имеют к ним доступ. Всем остальным на эти ручки доступ запрещен.
Какие типы клиентов существуют:
-
сервисы — сервисы внутри PaaS.
-
пользователи — логин одного сотрудника (например, user:apgubarev) или целый юнит (например, user.unit:architecture).
-
внешние зависимости (например, ext:pass-bot).
Генерация auth.toml происходит через утилиту auth init. Мы встроили её в свой внутренний CLI. Она позволяет не переписывать все ручки из брифа в auth.toml вручную и не допускать ошибок.
Утилита берёт потребителей из реестра, о котором я писал выше, и добавляет их в секцию default. Дальше разработчик может донастроить auth.toml через отдельные policy.
Также auth init добавляет в файл auth.toml мини-документацию, которая позволяет быстро вспомнить, какие разделы за что отвечают.
В итоге для пользователя вся конфигурация политик происходит в одном файле auth.toml. Всё остальное делаем мы.
Техническая реализация
У нас было два основных варианта, как сделать межсервисную авторизацию.
1. Запилить middleware для используемых языков. Но у такого решения есть ряд проблем:
— реализации на разных ЯП будут различаться и поддержка усложняется кратно количеству библиотек
— сложно обновлять — нужно вносить изменения в каждый сервис, а их я напомню 2500 и их количество растет.
— инфраструктурный слой протекает в сервисы — концептуально плохо.
2. Реализовать авторизацию на базе service mesh.
+ нет проблем, свойственных первому решению.
+ нагрузка только на команду PaaS, пользователям авторизации ничего делать не нужно.
В итоге мы остановились на service mesh на Istio. До Istio у нас было самописное решение, но сейчас Istio отвечает всем нашим потребностям — правда, приходится платить сложностью. Но это уже отдельная история.
Основной принцип любого service mesh — перехват входящего трафика и заруливание на сайдкар.
-
В каждый под, где нужен service mesh, добавляется сайдкар.
-
Трафик приходит в сайдкар.
-
Из сайдкара трафик проксируется в сервис.
Обратно — аналогично. Сервис шлет трафик не напрямую, а через прокси.
Аутентификация через mTLS
Прежде чем реализовывать авторизацию, нужно узнать имя клиента. Для аутентификации мы используем mTLS. Он уже был реализован у нас ранее для:
-
шифрования чувствительных данных. Трафик даже внутри кластера может быть небезопасным, поскольку там работает куча сторонних библиотек.
-
защиты от несанкционированного доступа. Например, по IP пода. Чтобы постучаться в сервис, в mTLS нужен персональный сертификат. Сервис может управлять доступами, используя имена этих сертификатов.
Подробнее про использование mTLS в Авито можно почитать в статье моего коллеги.
Автоматизация выдачи сертификатов
TLS-сертификаты для доступа к сервисам можно выдавать вручную, но лучше — генерировать их автоматически. Для автоматизации выдачи сертификатов мы используем готовое решение — Spire.
Spire реализует стандарт SPIFFE, который описывает подход к идентификации и управлению секретами в инфраструктуре. Spire состоит из двух частей:
-
Сервер — отвечает за выпуск новых сертификатов.
-
Агент — выдает сертификаты.
Вот, как работает автоматизация выдачи сертификатов с помощью Spire:
-
Сервер выпускает новые сертификаты.
-
Агент запускается на каждой ноде.
-
Istio конфигурирует сайдкар так, чтобы он устанавливал соединения с другими сайдкарами по протоколу mTLS.
-
Istio конфигурирует envoy: где и как он может получать сертификаты.
-
Сайдкар приходит к указанному агенту и получает TLS-сертификат, с помощью которого шифрует трафик.
Подробнее про Spire и SPIFFE можно почитать в статье моего коллеги.
HTTP-фильтр
Istio в качестве сайдкара использует envoy. Внутри envoy запрос проходит через цепочку фильтров и идет в целевой порт внутри пода, если его не заблокировал один из HTTP-фильтров. В Istio много готовых фильтров, но можно написать и свои.
ext-authz спрашивает у некоего внешнего агента (можно подложить сюда что угодно, хоть самопис), можно ли запросу лететь дальше. Если запрос блокируется, envoy сразу отвечает кодом 403. До сервиса запрос даже не доходит.
В качестве внешнего агента мы используем готовое и проверенное решение — Open Policy Agent.
Open Policy Agent
Open Policy Agent (OPA) — это CNCF-дипломированный проект, направленный на унификацию применения политик в различных технологиях и системах. Одно из назначений OPA — как раз service mesh. Плюс есть много готовых интеграций, в их числе — envoy.
Подробнее про Open Policy Agent →
OPA может работать в двух вариантах: как демон, или как Go-библиотека. В обоих случаях агенту можно передавать политики на языке Rego и данные в формате JSON.
Rego — это собственный декларативный язык OPA для выражения политик над сложными иерархическими структурами данных.
Особенность Rego в том, что он позволяет описывать сложные политики сокращённым синтаксисом — буквально в одно выражение. Для нас это было полезно, поскольку в поддержке можно очень быстро «продебажить» код глазами, даже не открывая IDE.
Другие плюсы Rego:
-
читабельность. Например, прогон многомерного массива в Rego можно сделать так: sites[].servers[].hostname.
-
расширяемость. Поскольку Rego написан на Golang, в него легко добавлять свои плагины: например, для похода во внутренние API или БД.
-
удобное тестирование и дебаг. Например, можно сравнивать политики двух сервисов на совместимость.
Перевод auth.toml в Rego
В нашей схеме разработчики описывают политики в auth.toml. А наша задача перевести этот конфиг на Rego, чтобы его понимал Open Policy Agent.
Вот, как мы реализовали перевод auth.toml в rego всего в пару сотен строк кода.
1. Общая часть. Забираем нужные входные данные, определяем тип клиента, устанавливает значения по умолчанию (например, allow = false).
default allow := false path : = trim(http_request.path, "/") X_source := http_request.headers["x-source"] X_source_ingress := http_request.headers ["x-source-ingress"] source : = x_source_ingress if { startswith(×_source_ingress, "user:") } else := x_source_ingress if { startswith(×_source_ingress, "ext:") } else := x_source source_is_user := startswith (source, "user:") source_is_ext := startswith(source, "ext:") source_is_service if { not source_is_user not source_is_ext count (source) > 0 }
2. Проходим по политикам в auth.toml. Если хоть одна из политик разрешает запрос, открываем доступ.
allow if { path in ["test2", "test3"] source in ['servicel", "service2"] } allow if { path in ["test4"] source_is_service } allow if { path in ["test4"] source_is_user x_source in data.units.architecture }
Итоговая схема
Подытожим, как работает межсервисная авторизация.
-
Запрос попадает на сайдкар.
-
Сайдкар спрашивает у агента, можно ли пустить запрос дальше.
-
Агент обрабатывает rego-правила и решает, нужно ли блокировать запрос.
-
Если запрос блокируется, клиент получает 403. Если нет — запрос доходит до сервиса.
Проблема: latency запросов к OPA
После реализации авторизации через запрос к сайдкару мы замерили latency. Оказалось, что envoy тратит дополнительные 10 мс для походов в OPA.
Для нагруженных сервисов это критично — им нельзя добавлять больше 2 мс. Поэтому для них такой latency стал бы блокером для использования авторизации.
Мы выяснили, что тратили время на одинаковые запросы к OPA. Ведь для пользователя apgubarev авторизационный ответ для ручки getUser будет одним и тем же на протяжение всей жизни пода (помним — политики меняются только при новом деплое).
Решение — кешировать. Envoy позволяет писать расширения разными способами, но нам лучше всего подошел Lua. Теперь запрос один раз посылается агенту, сохраняется в памяти и больше туда не ходит, если не было изменений в auth.toml. Прибавка — не больше 1 мс.
Валидация изменений в политиках авторизации
Недостаточно просто включить межсервисную авторизацию, нам было важно ничего не сломать. И у нас получилось.
Представим ситуацию: у сервиса A есть ручка getUser, на которую ходит сервис B. Владелец сервиса A закрывает доступ к этой ручки через auth.toml — сервис B ломается, получаем деградацию продакшена.
Мы сделали валидатор, чтобы не допускать таких ситуаций. Вот, как мы валидируем изменения, связанные с авторизацией:
— Предотвращаем закрытие существующих связей.
— Проверяем корректность auth.toml: указанные клиенты существуют, одна ручка указана только один раз и т.д.
— Не допускаем в прод с ошибками — блочим деплой.
Ещё мы завели реестр auth.toml. У нас уже был сервис схем для Brief, с помощью которого мы проверяли, что изменения не ломают обратную совместимость ручки или не удаляют поле, которое кто-то использует.
Перед деплоем сервиса мы достаём связи из реестра и тестируем rego правила: проверяем зависимости и клиентов.
Проверка зависимостей происходит следующим образом:
-
Смотрим зависимости в brief-папке.
-
Берём rego зависимостей из прода.
-
Тестируем rego с данными текущего сервиса: передаём путь и клиента, убеждаемся, что пришёл положительный ответ.
Проверка клиентов выглядит так:
-
Смотрим зарегистрированные клиенты у сервиса.
-
Берём текущий rego сервиса.
-
Проверяем доступ у клиентов: передаём путь и клиента, убеждаемся, что доступ есть.
Если билд падает, мы делаем одно из трех:
-
Оставляем связь как есть — наткнулись на связь, которая реально нужна, разработчикам нужно договориться, что с ней делать.
-
Помечаем связь как deprecated — связь уже устарела, нужно поставить задачу, чтобы её выпилить.
-
Ничего не делаем — нашли ненужную связь.
Таким образом нам удалось не допустить поломок существующих связей. До сих пор не было ни одного инцидента связанного с этим.
Выводы
Что у нас получилось
-
Простое платформенное решение для межсервисной авторизации. auth.toml не требует погружения, разработчики сразу начинают писать политики.
-
Сервисы не затронуты. Ничего менять не нужно, достаточно добавить ещё один файлик конфигурации.
-
Связи не сломаны. Все валидаторы отрабатывают корректно, пока не было ни одного инцидента.
Что поняли
Просто добавить авторизацию недостаточно. Как минимум нам ещё понадобилась генерация auth.toml и валидаторы для контроля изменений.
Что нам дали Open Policy Agent и rego
-
Не надо писать и поддерживать своё решение.
-
Понятная и читаемая политика для платформенной команды.
-
Удобная работа с оргструктурой, включая все иерархические вложенности.
-
Возможность тестирования доступов.
Полезные ссылки:
ссылка на оригинал статьи https://habr.com/ru/articles/796851/
Добавить комментарий