RBAC с помощью OPA

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(&rego.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.

Оцените статью
devanswers.ru
Добавить комментарий