Авторизация для бедных или как сделать RBAC для REST API с помощью OPA

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Когда речь заходит про права доступа в приложении, то из этой ситуации появляется два результата:

  • Либо в коде приложения появляются привязки к неким ролям/scope’ам;

  • Либо разработчик обрастает бородой и начинает сыпать фразами вроде abaс, xacml и матрица доступа; 

Если вам интересно как можно из подручных средств собрать RBAC на любой сервис соблюдающий REST, то добро пожаловать.

Для чего это вообще нужно?

  • POC: При быстрой реализации Proof of Concept приложений или функций, очень часто реализация безопасности уходит на второй план. А иногда от этих функций требуется хотя бы реализация авторизации, как демонстрация возможности ограничений для пользователей;

  • Ресурсы: При развертывании в условиях ограниченных ресурсов велико желание пренебречь самым тяжеловесными функциями, в том числе вычисления прав доступа. Хочется иметь решение, которым удастся закрыть ключевые функции без трудоемких интеграций с системами безопасности;

  • Firewall: При использовании такой функции в качестве фаервола на входе в кластер можно отчасти гарантировать дополнительный слой безопасности. Например, в кластер могут попасть приложения, которые не реализуют функций безопасности. Таким образом в конфигурацию можно заложить два блока: доверенные приложения, которые гарантируют безопасность и все остальные, которые закрываются данным блоком. Пусть и с серьезными оговорками.

Если коротко, то всю эту статью можно уместить в следующую фразу:

При обработке запроса в Nginx, перед отправлением его в сервис, отправляем запрос доступа в OPA, получаем результат авторизации, если доступ разрешен, то запрос отправляется в сервис.

Если разбор полетов вам неинтересен, то можно сразу перейти к реализации.

Приложение

Итак, рассмотрим пример с приложением и его размещением. 

Предположим, что у нас есть кластер в котором расположены два приложения:

  • API Gateway;

  • Бизнес-приложение с REST API;

В приложении есть REST API c CRUD-операциями:

  • Получить данные. HTTP-метод GET.

  • Создать данные. HTTP-метод POST.

  • Изменить данные. HTTP-метод PUT.

  • Удалить данные. HTTP-метод DELETE.

Теперь сформируем минимальную матрицу доступа:

  • Читать данные может только пользователь с правами читателя;

  • Читать, создавать и изменять данные только пользователь с правами редактора;

  • А вот выполнять все вышеперечисленные операции и операцию удаления может только администратор;

Авторизация

Теперь разберемся как реализовываются определение возможности доступа к данным.

Для принятия решения потребуются следующие данные:

  • Кто?

  • Что хочет сделать?

  • И с какими данными?

В нашем случае эти данные можно интерпретировать:

  • Пользователь

  • Действие

  • Данные

Результатом будет положительное или отрицательное решение. 

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

Теперь вернемся к нашему примеру с приложением и разберемся где взять данные для принятия такого решения.

Пользователь. В качестве пользователя очень удобно использовать JWT-токен, как подтвержденный слепок идентификационных данных.

Последнее время большую популярность набирает Keycloak и его реализация SSO Redhat, поэтому в дальнейшем я буду отталкиваться от структуры токена именно Keycloak.

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

GET - это чтение, POST/PUT - создание и изменение, DELETE - удаление.

Данные. Данные в случае прокси удобно интерпретировать как роут. То есть тот роут по которому идет обращение и есть наши данные.

Gateway, авторизация и приложение

Теперь начинаем складывать картину из всех вышеперечисленных кубиков.

Если мы хотим на уровне прокси/gateway сделать авторизацию по выполняемым запросам от пользователей, то у нас есть все исходные данные для проверки прав доступа.

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

Таким образом наша цепочка превращается в следующую последовательность:

  1. Пользователь получил свой идентификационный токен и мы предполагаем, что он содержит всю необходимую информацию о пользователе. С этим токеном он выполняет запрос в бизнес-приложение попадая в Gateway.

  2. Gateway надо сформировать запрос прав доступа. Для этого он разбирает запрос на части:

    - Забирает токен из заголовка и десериализует, формируя данные о пользователе;

    - Выделяет HTTP-метод из запроса и говорит, что это то действие которое выполняет пользователь;

    - Из пути запроса формирует данные; 

  3. В авторизации заложены три правила, которые говорят, что читателю можно читать данные, редактору читать и изменять данные, а администратору доступно все

  4. Если доступ разрешен, то запрос отправляется в бизнес-приложение.

Реализация

Все. С теорией закончили. Если честно, то теории тут гораздо больше чем самой реализации. Чем лично мне и импонирует это решение.

В качестве модуля авторизации я буду использовать OPA - https://www.openpolicyagent.org

Для Gateway возьму Nginx - http://nginx.org

Для ремарки скажу, что OPA набирает популярность в фильтрации запросов и есть модули под Envoy - https://github.com/open-policy-agent/opa-envoy-plugin, Traefic - https://doc.traefik.io/traefik-enterprise/v2.4/middlewares/opa/

Nginx

Основная конфигурация Nginx в моем случае не содержит никаких дополнительных манипуляций.

nginx.conf
load_module modules/ngx_http_js_module.so;

user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/*.conf;
}

JWT

В качестве издателя токенов я использую Keycloak. Но для наглядности взаимодействия в Nginx добавлены следующие методы:

  • /jwt/create - Создать JWT-токен без ролей;

  • /jwt/create/viewer - Создать JWT-токен с ролью читателя: “viewer”;

  • /jwt/create/editor - Создать JWT-токен с ролью редактора: “editor”;

  • /jwt/create/admin - Создать JWT-токен с ролью администратора: “admin”;

  • /jwt/roles - Посмотреть роли в выданном токене;

Конфигурация Nginx с вызовом методов jwt.js.

jwt.conf
## jwt
js_import /etc/nginx/conf.d/jwt.js;

js_set $generateJwt jwt.generateJwt;

server {
    listen 8081;

    ### jwt view roles
    location /jwt/roles {
        return 200 $jwt_payload_roles;
    }

    ### create jwt token
    location /jwt/create {
        return 200 $generateJwt;
    }
}
jwt.js
function generate_hs256_jwt(claims, key, valid) {
    var header = { typ: "JWT",  alg: "HS256" };
    var claims = Object.assign(claims, {exp: Math.floor(Date.now()/1000) + valid});

    var s = [header, claims].map(JSON.stringify)
                            .map(v=>v.toString('base64url'))
                            .join('.');

    var h = require('crypto').createHmac('sha256', key);

    return s + '.' + h.update(s).digest('base64url');
}

function generateJwt(r) {
    var uri = ""
    if (r.uri != "/jwt/create") {
        uri = r.uri.replace('/jwt/create/','');
    }
    
    var token = jwt([uri]);

    return token;
}

function jwt(roles) {
    var claims = {
        iss: "nginx",
        sub: "alice",
        foo: 123,
        bar: "qq",
        zyx: false,
        realm_access: {
            roles: roles
        }
    };

    return generate_hs256_jwt(claims, 'foo', 600);
}

export default {generateJwt};

API

В качестве API-приложения сделан роут /security/

api.js
function getReponse(r) {
    r.return(200, "Success!");
}

export default {getReponse};

OPA

rbac.rego
package httpapi.rbac

import input as req

import data.roles

default allow = false

allow {
  # check role
  role := req.user[_]
  user_roles = roles[role]

  # check route
  user_roles[k]
  glob.match(k, [], req.path)

  # check method
  user_roles[k][_] = req.method
}

Описание ролей и методов:

{
  "roles": {
    "viewer": {
      "/security/*": ["GET"]
    },
    "editor": {
      "/security/*": ["GET", "POST", "PUT"]
    },
    "admin": {
      "/security/*": ["GET", "POST", "PUT", "DELETE"]
    }
  }
}

Nginx + OPA

rbac.conf
js_import /etc/nginx/conf.d/rbac.js;
js_import /etc/nginx/conf.d/api.js;

js_set $jwt_payload_roles rbac.jwt_payload_roles;

server {
    listen 8080;

    # proxy to jwt api
    location /jwt {
        proxy_pass http://127.0.0.1:8081/jwt;
    }

    # sample api
    location /security {
        auth_request /_authz;
        js_content api.getReponse;
    }

    ### authorization
    location = /_authz {
        internal;
        js_content rbac.authz;
    }

    location = /_opa {
        internal;
        proxy_pass http://opa:8181/v1/data/httpapi/rbac;
    }
}
rbac.js
function authz(req, res) {
  // get roles
  var roles = jwt_payload_roles(req)
  if(roles == null) {
    req.return(401);
    return;
  }

  var opa_data = {
    "input": {
      "user": roles,
      "path": req.variables.request_uri,
      "method": req.variables.request_method
    }
  };

  var opts = {
    method: "POST",
    body: JSON.stringify(opa_data)
  };

  req.subrequest("/_opa", opts, function(opa) {
    req.error("OPA response: " + opa.responseBody);

    var body = JSON.parse(opa.responseBody);
    if (!body.result)  {
      req.return(403);
      return;
    }

    if (!body.result.allow) {
      req.return(403);
      return;
    } else {
      req.return(200);
    }
  });
}

function jwt(data) {
  var parts = data.split('.').slice(0,2)
      .map(v=>Buffer.from(v, 'base64url').toString())
      .map(JSON.parse);
  return { headers:parts[0], payload: parts[1] };
}

function jwt_payload_roles(r) {
  if (r.headersIn.Authorization == null) {
    return
  }

  return jwt(r.headersIn.Authorization.slice(7)).payload.realm_access.roles;
  // when the token is provided as the "myjwt" argument
  // return jwt(r.args.myjwt).payload.sub;
}

export default {authz, jwt_payload_roles};

Использование

Для удобства я собрал все запросы в одну коллекцию Postman

Импортировать коллекцию
{"info":{"_postman_id":"bf317dda-05db-4c0e-bbae-8b745aa65981","name":"Nginx And Opa Authorization","schema":"https://schema.getpostman.com/json/collection/v2.0.0/collection.json"},"item":[{"name":"JWT","item":[{"name":"View roles in JWT","id":"d28de3b0-f439-454e-9e3c-4e1ccfc7e71d","request":{"auth":{"type":"bearer","bearer":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJuZ2lueCIsInN1YiI6ImFsaWNlIiwiZm9vIjoxMjMsImJhciI6InFxIiwienl4IjpmYWxzZSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInZpZXdlciIsInRlc3QiXX0sImV4cCI6MTYzNzMxOTUxNX0.mZu9cbUccknBduyFXa10URmdm1RmgoGCbiPT654RBMI"}},"method":"GET","header":[],"url":"http://localhost:8080/jwt/roles"},"response":[]},{"name":"Create JWT without roles","id":"31654055-bf71-49b9-9caa-4fd0a1015a10","request":{"method":"GET","header":[],"url":"http://localhost:8080/jwt/create"},"response":[]},{"name":"Create JWT Viewer","id":"25276dc6-a618-4695-8d9f-32f701037b56","request":{"method":"GET","header":[],"url":"http://localhost:8080/jwt/create/viewer"},"response":[]},{"name":"Create JWT Editor","id":"9a6fdfbc-f4d0-4f3d-8628-50135bcca9b4","request":{"method":"GET","header":[],"url":"http://localhost:8080/jwt/create/editor"},"response":[]},{"name":"Create JWT Admin","id":"c8c1934d-fb23-414e-bb89-c6adc1f2dcc1","request":{"method":"GET","header":[],"url":"http://localhost:8080/jwt/create/admin"},"response":[]}],"id":"c7c6e2b1-e62a-4819-8697-0a64224be510"},{"name":"API","item":[{"name":"security","id":"ade4a701-c101-43d9-bc86-895063df7d8a","request":{"auth":{"type":"bearer","bearer":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJuZ2lueCIsInN1YiI6ImFsaWNlIiwiZm9vIjoxMjMsImJhciI6InFxIiwienl4IjpmYWxzZSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImFkbWluIl19LCJleHAiOjE2MzczMTk3NzV9.eVfk0rMf1XprEsuOlCwdKIcPxKRYigUIs5wAu1aEpC8"}},"method":"GET","header":[],"url":"http://localhost:8080/security/someid"},"response":[]},{"name":"security","id":"89462dd5-ae88-48cc-9f39-cccc51f95bcc","request":{"auth":{"type":"bearer","bearer":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJuZ2lueCIsInN1YiI6ImFsaWNlIiwiZm9vIjoxMjMsImJhciI6InFxIiwienl4IjpmYWxzZSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImFkbWluIl19LCJleHAiOjE2MzczMTk3NzV9.eVfk0rMf1XprEsuOlCwdKIcPxKRYigUIs5wAu1aEpC8"}},"method":"POST","header":[],"url":"http://localhost:8080/security/someid"},"response":[]},{"name":"security","id":"df04e356-ac6c-405f-ba98-57052545575d","request":{"auth":{"type":"bearer","bearer":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJuZ2lueCIsInN1YiI6ImFsaWNlIiwiZm9vIjoxMjMsImJhciI6InFxIiwienl4IjpmYWxzZSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImFkbWluIl19LCJleHAiOjE2MzczMTk3NzV9.eVfk0rMf1XprEsuOlCwdKIcPxKRYigUIs5wAu1aEpC8"}},"method":"PUT","header":[],"url":"http://localhost:8080/security/someid"},"response":[]},{"name":"security","id":"3f0dbb2a-d31a-4380-ae71-d890904dfdb3","request":{"auth":{"type":"bearer","bearer":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJuZ2lueCIsInN1YiI6ImFsaWNlIiwiZm9vIjoxMjMsImJhciI6InFxIiwienl4IjpmYWxzZSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImFkbWluIl19LCJleHAiOjE2MzczMTk3NzV9.eVfk0rMf1XprEsuOlCwdKIcPxKRYigUIs5wAu1aEpC8"}

Сначала получим токен для пользователя с правами читателя

Затем используем этот токен в заголовке авторизации и отправим GET запрос

Потом попробуем с этим же токеном вызвать метод POST

А теперь время неудобных вопросов

  • Можно ли ограничить доступ к конкретным ресурсам, а не просто к конкретным методам?

Частично можно. Посмотреть как

У нас два типа ресурсов: запрашиваемый и возвращаемый

Объект, который запрашивается в момент запроса, можно выделить и отправить в запрос авторизации. В правилах соответственно учитывать еще и параметры объекта.

Для возвращаемого ресурса ответ симметричен, только сформировать запрос доступа придется после обработки запроса приложением.

Но не нужно. Данная реализация основывается на данных доступных в запросе практически без обработки. Практически, потому что десериализация токена - это довольно существенные затраты. Но их можно практически нивелировать сделав кэширование токенов на уровне прокси. Учитывая, что токен обычно живет больше 15 минут - это существенно сократит время на обработку.

А если принести в логику запросов разбор запроса и выделение из тела ключевых данных, то это может существенно замедлить обработку запросов. В дальнейшем же уже столкнетесь с тем, что метод “Дай все доступное для этого пользователя” потребует постобработки.

  • Можно ли этот подход использовать с Graphql или сервисами игнорирующими REST?

Частично можно. Посмотреть как

Выделив из тела запроса функцию Graphql получится более точно определить права доступа.

Но не нужно. Так как это в итоге снова приведет к потере производительности по причинам из первого пункта.

Репозиторий с проектом

Полезные ссылки:

Nginx + OPA на запросах с клиентскими сертификатами

RBAC на Nginx Plus + OPA. Похожая конфигурация, только на коммерческой версии Nginx

Источник: https://habr.com/ru/post/591985/


Интересные статьи

Интересные статьи

Сегодня многие крупные компании для увеличения производительности открывают доступ сотрудников с рабочих компьютеров в Интернет. Однако не все сотрудники используют его п...
Введение Эта статья о том, как мы совместно с роснефтёвой «дочкой» «Самаранефтехимпроект» и Казанским Федеральным Университетом в сентябре 2020 года провели «Хакатон трёх городов», на ...
Увидело свет очередное обновление небольшой библиотеки для встраивания асинхронного HTTP-сервера в C++ приложения: RESTinio-0.6.12. Хороший повод рассказать о том, как в этой версии с пом...
Этот пост будет из серии, об инструментах безопасности, которые доступны в Битриксе сразу «из коробки». Перечислю их все, скажу какой инструмент в какой редакции Битрикса доступен, кратко и не очень р...
Согласно многочисленным исследованиям поведения пользователей на сайте, порядка 25% посетителей покидают ресурс, если страница грузится более 4 секунд.