Open Policy Agent (OPA) — это инструмент, направленный на унификацию применения политик в различных технологиях и системах. Он позволяет избежать внедрения нескольких подходов к аутентификации отдельно для Kubernetes или Kafka или даже для микросервисов. Инструмент является частью CNCF и используется такими компаниями, как Netflix, Cloudflare и Pinterest.
Позвольте мне вкратце рассказать вам, как он работает. OPA написан на языке golang и может использоваться как агент/демон или как библиотека golang с возможностью расширения. Для создания политик доступа предусмотрен язык Rego.
Представьте, что в вашей компании несколько сотен микросервисов и вам нужно ограничить доступ к тем из них, которые содержат конфиденциальные данные, например, личные данные пользователей. Конечно, вы можете реализовать авторизацию на хэндлах сервиса: проверить токен, хранить политики доступа в самом сервисе. Но вам может понадобиться повторить реализацию этого механизма несколько десятков или сотен раз для каждого такого сервиса, что, конечно, будет очень дорого и сложно.
Гораздо проще реализовать механизм авторизации один раз и позволить другим сервисам обращаться к нему по мере необходимости. И это тоже кажется простым и очевидным. Но все усложняется, если нам нужно дать доступ не только к хендлам сервисов, но и к базе данных (ведь разработчикам для поиска проблем не обойтись без доступа к производственной базе данных), также, возможно, нужен доступ к namespace в кластере Kuberenets и все остальное. Представьте, сколько раз вам придется заходить в разные системы и давать доступ.
OPA — это универсальный инструмент, который может быть интегрирован с другими инструментами, такими как ssh, Kubernetes, Envoy и многими другими.
На рисунке ниже показано, как это работает.
Возможности использования OPA очень широки. В этой статье я опишу один из способов использования. Я расскажу вам, как можно выполнять политики доступа с помощью данных в базе данных SQL, используя подход RBAC. Я буду хранить политики доступа в базе данных, потому что это очень гибко и легко реализовать API и фронтенды для управления ими.
Схема
Какие основные сущности должны быть представлены в механизме.
- Пользователь. Уникальный логин. Субъект политики.
- Группа. Пользователи могут принадлежать к одной группе. Объект политики.
- Желаемое действие.
- Роль. Набор действий, которые разрешены всем, кто имеет эту роль. Это упростит работу с разрешениями, так как вам не придется выдавать один и тот же набор разрешений много раз и сразу давать роль.
- Служба. Это объект системы авторизации. Права выдаются для работы со службой и ее ресурсами (базами данных, хранилищами S3, секретами в хранилище и другими).
--- groups
CREATE TABLE IF NOT EXISTS public.groups
(
id serial,
name character varying(250) NOT NULL,
CONSTRAINT groups_pkey PRIMARY KEY (id),
CONSTRAINT groups_name_key UNIQUE (name)
)
Я сделал имя уникальным, чтобы не было случайных дублей.
--- users
CREATE TABLE IF NOT EXISTS public.users(
id serial,
login varchar(250) NOT NULL,
name varchar(250) NOT NULL,
group_id integer references groups(id),
CONSTRAINT users_pkey PRIMARY KEY (id)
);
Пользователь уникален в своем логине и относится к одной группе. При желании вы можете распространить пример из этой статьи на несколько групп.
--- roles
CREATE TABLE IF NOT EXISTS public.roles
(
id serial,
name text NOT NULL,
actions character varying(100)[] NOT NULL,
CONSTRAINT roles_pkey PRIMARY KEY (id)
)
Роль — это простая сущность, которая хранит массив доступных разрешений. Я буду привязывать объекты к роли.
CREATE TABLE IF NOT EXISTS public.service
(
id serial,
name character varying NOT NULL,
owner_id integer,
CONSTRAINT service_pkey PRIMARY KEY (service_id),
CONSTRAINT service_owner_id_fkey FOREIGN KEY (owner_id)
REFERENCES public.users (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE RESTRICT
)
Имя сервиса уникально и не должно повторяться, а также у каждого сервиса есть пользователь-владелец, главный ответственный, фактически админ.
CREATE TABLE IF NOT EXISTS public.rules
(
id serial NOT NULL,
user_id integer,
group_id integer,
role_id integer NOT NULL,
service_id integer NOT NULL,
CONSTRAINT rules_pkey PRIMARY KEY (id),
CONSTRAINT rules_group_id_fkey FOREIGN KEY (group_id)
REFERENCES public.groups (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE,
CONSTRAINT rules_role_id_fkey FOREIGN KEY (role_id)
REFERENCES public.roles (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE RESTRICT,
CONSTRAINT rules_service_id_fkey FOREIGN KEY (service_id)
REFERENCES public.service (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE,
CONSTRAINT rules_user_id_fkey FOREIGN KEY (user_id)
REFERENCES public.users (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE
)
Я реализовал привязку к объекту через отдельные поля user_id, group_id. То есть правило может быть как пользовательским, так и групповым. Во-первых, это сделано для упрощения примера и понимания работы с OPA. Во-вторых, для поддержания согласованности данных в базе. В реальных примерах лучше всего реализовать в виде пары полей subject_type/subject_id, это может быть полезно для расширения типов субъектов (организация, команда, другая служба и т.д.)
Правило рего
Теперь мне нужно реализовать правило, которое срабатывает, если выполняется одно из условий:
- Пользователь является владельцем услуги. Владельцам разрешены любые действия.
- У этого пользователя есть разрешение на запрашиваемое действие с сервисом.
- Группа пользователя имеет разрешение на запрашиваемое действие с сервисом.
Есть два способа:
- Выгрузить в OPA все данные из базы данных по пользователям, группам и правилам и обновлять эти данные каждые несколько минут.
- Делать запросы в базу данных непосредственно из правил rego.
Второй способ выглядит гораздо предпочтительнее, потому что
- Данные всегда будут актуальны и их не придется обновлять.
- Не нужно хранить в памяти большой объем данных, который в некоторых случаях может стать огромным.
Таким образом, наше правило будет выглядеть следующим образом
package auth
default is_allowed = false
is_allowed {
is_user_service_owner(input.userId, input.serviceName)
}
is_allowed {
has_user_permission(input.userId, input.serviceName, input.action)
}
is_allowed {
has_user_group_permission(input.userId, input.serviceName, input.action)
}
В некоторых аргументах я использую id, а в некоторых уникальное имя, так как в реальной жизни не всегда можно заниматься тем, чем хочется и это делает пример более приближенным к реальности. Надеюсь вы поняли о чем я)
По умолчанию is_allowed
имеет значение false. Это означает, что если ни одно из перечисленных ниже условий не выполняется, общий результат также будет false. Перейдем к реализации пользовательских функций
Пользовательские функции Rego
OPA позволяет нам расширить набор функций. Я покажу реализацию этой возможности на примере has_user_permission
. Другие функции будут отличаться только внутренним SQL запросом и достаточно просто реализуются по аналогии.
type RegoHasUserPermission struct {
userPermission HasPermission
}
func NewRegoHasUserPermission(userPermission storage.HasPermission) *RegoHasUserPermission {
return &RegoHasUserPermission{
userPermission: userPermission,
}
}
func (r *RegoHasUserPermission) GetFunction() func(r *rego.Rego) {
return rego.Function3(®o.Function{
Name: "has_user_permission",
Decl: types.NewFunction(types.Args(types.N, types.S, types.S), types.A),
Memoize: false,
}, func(bctx rego.BuiltinContext, userId, serviceName, action *ast.Term) (*ast.Term, error) {
userIDInt, err := termToInt(userId)
if err != nil {
return nil, fmt.Errorf("OPA function RegoHasUserPermission, arg `userId`: %v", err)
}
serviceNameStr, err := termToString(serviceName)
if err != nil {
return nil, fmt.Errorf("OPA function RegoHasUserPermission, arg `serviceName`: %v", err)
}
actionStr, err := termToString(action)
if err != nil {
return nil, fmt.Errorf("OPA function RegoHasUserPermission, arg `action`: %s", err)
}
result, err := r.userPermission.HasUserPermission(r.ctx, *userIDInt, *serviceNameStr, *actionStr)
if err != nil {
return ast.BooleanTerm(false), fmt.Errorf("OPA function RegoHasUserPermission: %v", err)
}
return ast.BooleanTerm(result), nil
})
}
Я создал структуру, в которую передаю некое хранилище в качестве интерфейса.
type HasPermission interface {
HasUserPermission(userId int, serviceName, action string) (bool, error)
}
Ниже я покажу ее реализацию. Я определил функцию has_user_permission
с тремя аргументами
- Первый
type.N
. Номер типа аргумента. Здесь будет передан идентификатор пользователя - Второй
type.S
. Аргумент строкового типа. Для имени сервиса и имени действия.Для преобразования значений аргументов, которые я буду получать во время выполнения функции rego, я реализовал две функцииtermToString
иtermToInt
, которые преобразуют аргумент со строкой в типstring
и числовой в типint
.
func termToString(arg *ast.Term) (*string, error) {
astStringVal, ok := arg.Value.(ast.String)
if !ok {
return nil, fmt.Errorf("cannot convert term to string: %s", arg.String())
}
stringVal := string(astStringVal)
return &stringVal, nil
}
func termToInt(arg *ast.Term) (*int, error) {
number, ok := arg.Value.(ast.Number)
if !ok {
return nil, fmt.Errorf("cannot convert term to number: %s", arg.String())
}
intval, ok := number.Int()
if !ok {
return nil, fmt.Errorf("cannot convert term to int: %s", arg.String())
}
return &intval, nil
}
После этого аргументы получены и ошибок нет, осталось только выполнить запрос к базе данных. Выше в пользовательской функции я использовал интерфейс HasPermission
. Я реализовал его следующим образом.
func (pg *UserPermissionControllerPg) HasUserPermission(userId int, serviceName, action string) (bool, error) {
rule := struct {
Id int `json:"id"`
}{}
query := `SELECT r.id FROM rules AS r
LEFT JOIN service AS s ON r.service_id=s.service_id
LEFT JOIN roles AS rr ON r.role_id=rr.id
WHERE r.user_id=$1
AND s.service_name=$2
AND $3=ANY(rr.actions)`
if err := pg.db.GetContext(context.Background(), &rule, query, userId, serviceName, action); err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("HasUserPermission: %v", err)
}
return true, nil
}
В этом методе я проверяю наличие необходимых разрешений в ролях пользователей. Как вы помните, существует набор правил, которые привязаны к пользователю и сервису. И каждое такое правило привязано к роли. Запрос ищет хотя бы одну запись в таблице правил, где есть привязка к нужной роли, пользователю и сервису. И если она есть, значит, у пользователя есть правило.
По аналогии можно реализовать функции rego is_user_service_owner
и has_user_group_permission
с той лишь разницей, что поиск правила будет идти другим путем.
Интеграция
Осталось только интегрировать полученный механизм в приложение.
type RegoAuthController struct {
rego *rego.Rego
}
func NewRegoController(
policiesPath []string,
ruleName string,
funcs ...func(*rego.Rego)) *RegoAuthController {
opts := []func(*rego.Rego){
rego.Query(ruleName),
rego.Load(policiesPath),
}
if len(opts) > 0 {
opts = append(opts, funcs...)
}
return &RegoAuthController{
rego: rego.New(opts...),
}
}
Я создал структуру, в которой есть ссылка на объект Rego. И в конструкторе выполнил его начальную настройку. В частности, нужно пропустить пути, в которых хранятся файлы с правилами. В нашем примере он один, но вы можете создать сколько угодно. Например, разделить их по доменам. Кроме того, я предусмотрел возможность передачи в конструктор дополнительных функций, которые могут выполнять конфигурацию OPA. В частности, нужно передать дополнительную пользовательскую функцию, которую я описал выше.
func (p *RegoAuthController) IsAllowed(ctx context.Context, input map[string]interface{}) (bool, error) {
query, err := p.rego.PrepareForEval(ctx)
if err != nil {
return false, fmt.Errorf("IsAllowed PrepareForEval: %v", err)
}
resultSet, err := query.Eval(ctx, rego.EvalInput(input))
if err != nil {
return false, fmt.Errorf("eval rego query: %v", err)
}
if len(resultSet) == 0 {
return false, errors.New("undefined result")
}
return resultSet.Allowed(), nil
}
Этот метод запускает правило и передает ему аргументы, отображенные на вход. В правиле, которое я показал вам ранее, мы просто смотрим на эту структуру, которая также вызывается.
Далее по коду, думаю, все понятно. Выполняется подготовленное выражение. Поскольку выполненное правило может иметь несколько результатов в ответе, возвращается result set
. Если в дополнение к is_allowed
, которая у нас есть сейчас, добавить еще какую-нибудь функцию, например is_owner
, то результат ее выполнения также будет представлен в ответе. В данном случае нам это не нужно, поэтому проверьте достоверность всех условий (то есть одного) в вызове resultSet.Allowed()
.
Теперь RegoAuthController
можно использовать либо в обработчике, на который придет вызов правила, либо где-то еще. Это зависит от конкретного приложения. Ниже я покажу пример того, как это может выглядеть.
func (h *Handler) Handle(...) error {
regoHasUserPermissionFunction := policy.NewRegoHasUserPermission(hasPermission, ctx)
authController := policy.NewRegoController(
[]string{h.policyDir + "/auth.rego"},
"data.auth.is_allowed",
regoHasUserPermissionFunction.GetFunction(),
)
isAllowed, err := authController.IsAllowed(ctx, map[string]interface{}{
"userId": in.UserId,
"serviceName": in.ServiceName,
"action": in.Action,
}, nil)
if err != nil {
return fmt.Errorf("handler: %v", err)
}
out.IsAllowed = isAllowed
return nil //nolint:govet
}
В этом примере я создал новый объект структуры RegoController
и настроил его:
- Передал файл, в котором находятся правила (содержимое файла я привел выше в статье). При необходимости можно передать несколько файлов.
- Указывается, с каким правилом работать.
data.auth.is_allowed
Состоит из имени пакета и имени функции. - Передается пользовательской функции как указатель на структуру объекта. Я настроил ее заранее, передав готовое соединение с базой данных (чтобы не устанавливать его заново при каждом запросе). А дальше остается только вызвать метод
IsAllowed
и передать параметры из запроса. Я опускаю некоторые детали реализации, такие как установка соединения с базой данных и работа с http-фреймворком, так как это была бы лишняя информация, которая не относится к теме и может сильно отличаться от проекта к проекту.
Заключение
Я показал весь механизм. Его можно расширить, добавив необходимую функциональность. Например, можно предоставлять права другим сущностям, таким как организация или другие сервисы. Надеюсь, что представленный подход поможет вам в реализации ваших задач. Если есть вопросы, задавайте их в комментариях или мне на еmail.