Привет, это Антон Губарев, инженер в команде Platform as a Service (PaaS) Авито. Платформа помогает разработчикам создавать сервисы с готовой инфраструктурой, писать для них бизнес-логику, тестировать, деплоить одной кнопкой. PaaS позволяет переиспользовать лучшие практики других команд и снижать издержки на интеграцию новых сервисов с инфраструктурой, поэтому её активно используют все разработчики.
В штате Авито уже 1200 инженеров и их число постоянно растёт, поэтому уследить за действиями каждого пользователя PaaS становится всё сложнее. Точно также растёт число сервисов — сейчас их больше 2000. При этом некоторые сервисы используют чувствительные данные, и требуют большего внимания к безопасности. Все это привело нас к необходимости авторизовать действия пользователей перед тем, как их выполнять.
Мы внедрили политики авторизации с помощью Open Policy Agent (OPA), при этом предоставили командам разработчиков возможность настраивать авторизационные правила для своих сервисов. В этой статье расскажу, какие шаги выполнили от идеи до запуска и с какими проблемами столкнулись.
Немного об Open Policy Agent
Open Policy Agent — это open-source-инструмент контроля доступа, основанный на политиках. Он не зависит от стека в проекте и подходит в том числе для внедрения в микросервисной архитектуре.
OPA создан в 2016 году и с тех пор стабильно развивается. Сейчас он входит в каталог дипломированных проектов Cloud Native Computing Foundation (CNCF). Его используют Netflix, Pinterest, TripAdvisor и другие компании.
OPA интегрируется с популярными инструментами, такими как Istio, Envoy, Terraform, Kubernetes, CoreDNS, Helm.
OPA работает по принципу «запрос-ответ» не является прокси. Клиент спрашивает у OPA есть ли доступ при имеющихся условиях и на основе ответа принимает решения о своих дальнейших действиях. В запросе можно передавать дополнительные аргументы: логин пользователя, токен, роль, а также любые дополнительные параметры, которые необходимые для принятия решения. Помимо этого в память OPA может быть загружен массив данных (в формате JSON), которые также можно использовать при принятии решения. Например данные пользователях и их ролях, в какие группы они входят.
Ответ определяется на основе политик на языке rego — может ли клиент выполнить то или иное действие. У него есть свои преимущества и особенности, поэтому остановлюсь на нем подробнее.
Почему в OPA используется Rego
Rego создан специально для OPA, чтобы сделать проще и удобнее работу с данными, например вложенными структурами: пользователи, атрибуты пользователей, группы.
В большинстве языков разработки для вложенных структур мы пишем foreach или какой-нибудь его аналог. Например, в Python это будут вложенные for с переборкой. В Rego похожий алгоритм будет гораздо проще, лаконичнее и в разы короче.
Чем меньше становится кода, тем проще его читать. Благодаря этому описание политики становится понятнее: в нём не нужны нагромождения логических конструкций и бесконечного перемалывания данных в циклах.
Rego легко расширять с помощью кастомных функций, то есть он позволяет покрыть практически любой даже самый экзотический кейс. Больше подробностей об этом будет в разделе о том, как мы реализовали авторизацию.
Разработчики Rego заложили в него возможность тестировать политики. И тесты пишутся тоже на rego. Допустим, у нас есть простая политика, в которой два аргумента проверяются на равенство.
package authz
import future.keywords
allow if {
input.path == ["users"]
input.method == "POST"
}
allow if {
input.path == ["users", input.user_id]
input.method == "GET"
}
Чтобы убедиться, что эта политика работает как надо, нужен простой тест буквально по одной строке на кейс.
package authz
import future.keywords
test_post_allowed if {
allow with input as {"path": ["users"], "method": "POST"}
}
test_get_anonymous_denied if {
not allow with input as {"path": ["users"], "method": "GET"}
}
После этого запустим OPA-тест и увидим, что политика отработала правильно.
$ opa test . -v
data.authz.test_post_allowed: PASS (1.417µs)
data.authz.test_get_anonymous_denied: PASS (426ns)
--------------------------------------------------------------------------------
PASS: 4/4
Как работает Rego на примере простых функций
Сравнение двух аргументов. Функция будет равна истине, только если все условия, которые в ней перечислены, тоже равны истине.
package play
import future.keywords.if
default hello := false
default bue := false
hello if {
input.message == "world"
input.name == "bob"
# необходимо чтобы выполнились условия во всех строках
}
bue if {
input.name == "bob"
}
Функция hello проверяет, что значение message равно world, а name — bob. Функция buy проверяет только второе условие.
Аргументы для сравнения передаются в функцию в объекте input. Это массив входных параметров.
{
"name": "bob",
"message": "hello"
}
В ответ мы получаем объект output — который содержит значения всех функций, использованных в правилах.
{
"bue": true,
"hello": true
}
Но если мы заменим имя пользователя, одно из двух условий уже не будет выполняться. Тогда обе функции будут равны false.
{
"name": "alice",
"message": "hello"
}
{
"bue": false,
"hello": false
}
Работа с массивом data. Формат JSON позволяет политикам работать с каждой отдельной записью внутри массива. Например, проверить, есть ли среди пользователей конкретный человек.
package play
import future.keywords.if
import future.keywords.contains
import future.keywords.in
q contains name if {
some person in data.persons
name := person.name
}
input:
{
"name": "alice"
}
data:
{
"persons": [
{"name": "jhon"},
{"name": "bob"},
{"name": "alice"}
]
}
output:
{
"q": [
"alice",
"bob",
"jhon"
]
}
Два варианта интеграции OPA с платформой
Архитектура OPA позволяет использовать его в двух вариантах: как демон daemon или как библиотеку для Go.
OPA как daemon
В этой интеграции OPA запускается бинарным файлом, доступ к нему можно получить через Rest API. Мы передаем OPA-клиенту бандл — набор данных для принятия решения по авторизации: обязательно политики и по необходимости массив data.
Данные для принятия решения могут периодически обновляться, например, если появились новые сотрудники. Поэтому их нужно хранить не в самом клиенте, а отдельно. Частый кейс — размещение массивов данных в контейнер Nginx. Также можно хранить бандл в другом сервисе и передавать OPA ссылку на него.
Rest API позволяет запросить авторизацию какого-либо действия: передать Input и получить ответ. Подобным образом можно работать и с самими данными в массиве data и с политиками. Их можно добавлять, обновлять, удалять с помощью Rest API.
POST /v1/data/example/allow
Content-Type: application/json
{
"input": {
"method": "GET",
"path": "salary",
"subject": {
"user": "bob"
}
}
}
OPA-daemon часто используют в качестве сайдкара в service mesh — контейнера, который запускается рядом с основным контейнером.
Допустим, у нас есть два сервиса и service mesh на Istio, который определяет правила взаимодействия между ними. Нам нужно настроить правила авторизации для этих сервисов. Также есть прокси-сервер Envoy, который при попытке подключиться к сервису делает запрос к OPA. Если политики авторизации запрещают подключение, то запрос даже не доходит до сервиса.
Как это работает на практике: мы передаем данные сотрудников в массиве. Затем проверяем, что используется http-метод POST и путь employees.
employee_managers := {"ivan@company.com", "elena@company.com", "vasiliy@company.com"}
allow if {
input.attributes.request.http.method == "GET"
input.attributes.request.http.path == "/"
jwt.valid
jwt.payload.Role == "manager"
employee_managers[_] == jwt.payload.sub
}
В этом примере мы используем jwt-токен, стандартизированный веб-токен в формате JSON. По нему проверяется роль и группа пользователя в payload. На основе всех данных принимается решение, разрешен ли пользователю доступ к сервису.
OPA как go-библиотека
Чтобы OPA работал как бибилиотека, мы создаем объект Rego. Передаем в него необходимые параметры, политики и данные.
r := rego.New(
rego.Query(`x = hello("bob")`),
rego.Module("example.rego", module)
).PrepareForEval(ctx)
if err != nil {
// handle error.
}
Затем оборачиваем объект в нужную нам бизнес-логику и подключаем готовую библиотеку в нужных сервисах.
В правила Rego можно прокидывать любые данные: указывать, в каких файлах брать политики или информацию о пользователях. Это позволяет работать с политиками более гибко, нам не нужно обновлять весь контейнер OPA, чтобы внести новые правила. Достаточно переписать отдельный файл, где они хранятся. Также можно задать обновление данных по событиям или задать другие гибкие правила.
Используя библиотеку, мы можем расширить базовые возможности языка Rego. По сути, это главная фича, ради которой стоит использовать OPA в качестве библиотеки.
Допустим, нам необходимо добавить простую функцию Hello с двумя аргументами и внутри нее строить нужную нам логику. Например, подключаться к внешнему API или базе данных, строить деревья, управлять кэшированием.
r := rego.New(
rego.Query(`x = hello("bob")`),
rego.Function1(
®o.Function{
Name: "hello",
Decl: types.NewFunction(types.Args(types.S), types.S),
},
func(_ rego.BuiltinContext, a *ast.Term) (*ast.Term, error) {
if str, ok := a.Value.(ast.String); ok {
return ast.StringTerm("hello, " + string(str)), nil
}
return nil, nil
}),
)
query, err := r.PrepareForEval(ctx)
if err != nil {
// handle error.
}
Также в формате библиотеки мы можем покрыть тестами всю логику, причем не только OPA-тестами, но и кастомными на Go: интеграционными или unit.
Чтобы было легче выбирать между двумя подходами к интеграции, я сделал табличку.
Для нас реализация в качестве Go-библиотеки оказалась удачнее. Дальше опишу, что именно мы сделали и как.
Авторизация действий с помощью OPA
Перед тем как проводить интеграцию, мы изучили, как команды используют разные сервисы. Оказалось, что со временем накопилось очень много кейсов, когда в один сервис коммитят и раскатывают изменения сразу несколько команд.
Такие общественные сервисы нужны сразу многим инженерам. Мы не можем просто закрыть доступ к ним внутри той команды или юнита, которая его поддерживает.
Поэтому у нас появились две задачи:
Дать командам возможность управлять правами на свои сервисы. Тогда они смогут самостоятельно создавать политики и определять, кому и что можно делать.
Не сломать текущие процессы. Если у одной из команд всегда был доступ к конкретному сервису, а потом он вдруг оказался закрыт, это может остановить или замедлить работу целых юнитов. В итоге, пока мы будем чинить доступ, пострадает и эта команде, и общие процессы разработки, и весь бизнес.
Мы начали работу с того, что определили роли и права для них. Оказалось, что для всех действий, которые выполняют пользователи, достаточно четырех ролей. Меньше всего прав у наблюдателя: он может только получать данные о сервисе. Больше всего у владельца, который может выполнять все действия.
Логика построена на трех сущностях: субъект, роль и объект.
Базовая проверка построена на том, чтобы последовательно определить, к какой роли относится субъект (пользователь, команда или организация).
package can_do
default is_allowed = false
# если сработает хоть одно правило то is_allowed будет true
is_allowed {
# если пользователь относится к юнитам-администраторам, то можно все
is_user_is_admin(input.userId, input.subjectType)
}
is_allowed {
# если владелец то можно все, что в рамках данной роли
is_user_service_owner(input.userId, input.serviceName, input.action, input.subjectType)
}
is_allowed {
# если юнит владелец, то доступны права редактора
is_unit_is_service_owner(input.userId, input.serviceName, input.action, input.subjectType)
}
# ...
Вначале проверяется, является ли пользователь администратором платформы, которому автоматически разрешены все действия.
Если первая проверка возвращает True, политика завершается. Если False — проверяем, является ли пользователь владельцем сервиса. Затем идет проверка по конкретному юниту(команде) и пользователю.
Кастомная функция для конкретного сервиса может содержать дополнительные проверки. Например, делать запрос в базу данных, где пермишены хранятся все варианты действий (permission) и моментально получать ответ. В этом случае, в отличие от отправки бандлов, не нужно ждать обновлений.
func (r *RegoIsOwner) GetFunction() func(*rego.Rego) {
return rego.Function4(®o.Function{
Name: "is_user_service_owner",
Decl: types.NewFunction(types.Args(types.N, types.S, types.S, types.S), types.B),
Memoize: false,
}, func(bctx rego.BuiltinContext, userID, serviceName, action, subjectType *ast.Term) (*ast.Term, error) {
subjectTypeString, err := termToString(subjectType)
if err != nil {
return nil, fmt.Errorf("OPA function RegoIsOwner, arg `subjectType`: %v", err)
}
if subjectTypeString == nil || *subjectTypeString == "bot" {
return ast.BooleanTerm(false), nil
}
userIDInt, err := termToInt(userID)
if err != nil {
return nil, fmt.Errorf("OPA function RegoIsOwner, arg `userId`: %v", err)
}
user, err := r.userStorage.GetUserById(bctx.Context, *userIDInt)
if err != nil {
return nil, fmt.Errorf("OPA function RegoIsOwner: %v", err)
}
if user == nil {
r.log.Debug(bctx.Context, "RegoIsOwner(false): user == nil")
return ast.BooleanTerm(false), err
}
serviceNameStr, err := termToString(serviceName)
if err != nil {
return nil, fmt.Errorf("OPA function RegoIsOwner, arg `serviceName`: %v", err)
}
srv, err := r.serviceStorage.GetService(bctx.Context, *serviceNameStr)
if err != nil {
return nil, fmt.Errorf("OPA function RegoIsOwner: %v", err)
}
if srv == nil || srv.OwnerId == nil {
r.log.Debug(bctx.Context, "RegoIsOwner(false): srv == nil || srv.OwnerId == nil")
return ast.BooleanTerm(false), err
}
foundedRoles, err := r.pgRuleStorage.GetRoleByNames(bctx.Context, []string{ownerRoleName})
if err != nil {
return nil, fmt.Errorf("OPA function RegoIsUnitIsServiceOwner, finding role: %s", err)
}
if len(foundedRoles) == 0 {
return nil, fmt.Errorf("OPA function RegoIsUnitIsServiceOwner, role %s is not exist", editorRoleName)
}
actionStr, err := termToString(action)
if *srv.OwnerId == user.Id && isActionExistsInRole(*actionStr, foundedRoles[0].Actions) {
return ast.BooleanTerm(true), nil
}
return ast.BooleanTerm(false), nil
})
}
Итоговая архитектура получилась довольно простой: к нас есть сервис API, который может деплоить, управлять релизами, выполнять все возможные на платформе действия.
Когда пользователь пытается выполнить какое-либо из этих действий, API отправляет запрос к сервису авторизации Auth на основе OPA.Он хранит агрегированные данные с множества других сервисов: информацию о пользователях, группах, а также политики, которые пользователи создают в административной части.
На основании всего этого сервис авторизации принимает решение и возвращает ответ: разрешить действие пользователю или нет.
Для пользователей, которые создают политики для своих сервисов, мы сделали простую форму в дашборде PaaS.
Владелец сервиса может выдавать роли отдельным пользователям, командам, либо всей организации. Для удобства добавили автокомплит: поиск по первым символам логина пользователя или названия юнита можно.
Результаты интеграции
Мы предоставили инженерам из команд Авито возможность авторизовывать действия пользователей в PaaS. Если раньше все разработчики по умолчанию имели доступ ко всем сервисам, то теперь можно гибко настроить политики авторизации.
Команды-владельцы сервисов самостоятельно настраивают политики, для этого не нужно обращаться к сервисной команде платформы. Все настройки доступны из дашборда PaaS.
На основе первого опыта, мы планируем и дальше развивать межсервисную авторизацию с помощью OPA и service mesh.
Предыдущая статья: Пишем хорошие компоненты, которые захочется переиспользовать, а плохие — не пишем