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

Kate

Administrator
Команда форума
a08962928f72c627c04ce3ea20f45018.jpg

Когда речь заходит про права доступа в приложении, то из этой ситуации появляется два результата:
  • Либо в коде приложения появляются привязки к неким ролям/scope’ам;
  • Либо разработчик обрастает бородой и начинает сыпать фразами вроде abaс, xacml и матрица доступа;
Если вам интересно как можно из подручных средств собрать RBAC на любой сервис соблюдающий REST, то добро пожаловать.
Для чего это вообще нужно?
  • POC: При быстрой реализации Proof of Concept приложений или функций, очень часто реализация безопасности уходит на второй план. А иногда от этих функций требуется хотя бы реализация авторизации, как демонстрация возможности ограничений для пользователей;
  • Ресурсы: При развертывании в условиях ограниченных ресурсов велико желание пренебречь самым тяжеловесными функциями, в том числе вычисления прав доступа. Хочется иметь решение, которым удастся закрыть ключевые функции без трудоемких интеграций с системами безопасности;
  • Firewall: При использовании такой функции в качестве фаервола на входе в кластер можно отчасти гарантировать дополнительный слой безопасности. Например, в кластер могут попасть приложения, которые не реализуют функций безопасности. Таким образом в конфигурацию можно заложить два блока: доверенные приложения, которые гарантируют безопасность и все остальные, которые закрываются данным блоком. Пусть и с серьезными оговорками.
Если коротко, то всю эту статью можно уместить в следующую фразу:
При обработке запроса в Nginx, перед отправлением его в сервис, отправляем запрос доступа в OPA, получаем результат авторизации, если доступ разрешен, то запрос отправляется в сервис.
Если разбор полетов вам неинтересен, то можно сразу перейти к реализации.

Приложение​

Итак, рассмотрим пример с приложением и его размещением.
Предположим, что у нас есть кластер в котором расположены два приложения:
  • API Gateway;
  • Бизнес-приложение с REST API;
2ac461cb8d378d2579b8be9e2aa83772.png

В приложении есть REST API c CRUD-операциями:
  • Получить данные. HTTP-метод GET.
  • Создать данные. HTTP-метод POST.
  • Изменить данные. HTTP-метод PUT.
  • Удалить данные. HTTP-метод DELETE.
4a067fc06177197839df96f5062bddc7.png

Теперь сформируем минимальную матрицу доступа:
  • Читать данные может только пользователь с правами читателя;
  • Читать, создавать и изменять данные только пользователь с правами редактора;
  • А вот выполнять все вышеперечисленные операции и операцию удаления может только администратор;
121a8965d1e280a0d9c715751d2fde39.png

Авторизация​

Теперь разберемся как реализовываются определение возможности доступа к данным.
Для принятия решения потребуются следующие данные:
  • Кто?
  • Что хочет сделать?
  • И с какими данными?
В нашем случае эти данные можно интерпретировать:
  • Пользователь
  • Действие
  • Данные
Результатом будет положительное или отрицательное решение.
На основе этого решения можно сделать вывод, что пользователь может выполнить в рамках бизнес-приложения.
5ac7817b0b15eb4f12232783a203ca67.png

Теперь вернемся к нашему примеру с приложением и разберемся где взять данные для принятия такого решения.
Пользователь. В качестве пользователя очень удобно использовать JWT-токен, как подтвержденный слепок идентификационных данных.
Последнее время большую популярность набирает Keycloak и его реализация SSO Redhat, поэтому в дальнейшем я буду отталкиваться от структуры токена именно Keycloak.
Действие. Маркером действия очень удобно оперировать классической нотацией REST и предполагать, что методы
GET - это чтение, POST/PUT - создание и изменение, DELETE - удаление.
Данные. Данные в случае прокси удобно интерпретировать как роут. То есть тот роут по которому идет обращение и есть наши данные.
d77b7d8d8d3c7322f9b98ae509790ae0.png

Gateway, авторизация и приложение
Теперь начинаем складывать картину из всех вышеперечисленных кубиков.
Если мы хотим на уровне прокси/gateway сделать авторизацию по выполняемым запросам от пользователей, то у нас есть все исходные данные для проверки прав доступа.
То есть если предположить, что Gateway может выполнить запрос авторизации, то остается только добавить новый кубик в схему - модуль авторизации.
6b12ee3950e107a3de0611c3228797b6.png

Таким образом наша цепочка превращается в следующую последовательность:
  1. Пользователь получил свой идентификационный токен и мы предполагаем, что он содержит всю необходимую информацию о пользователе. С этим токеном он выполняет запрос в бизнес-приложение попадая в Gateway.
  2. Gateway надо сформировать запрос прав доступа. Для этого он разбирает запрос на части:
    - Забирает токен из заголовка и десериализует, формируя данные о пользователе;
    - Выделяет HTTP-метод из запроса и говорит, что это то действие которое выполняет пользователь;
    - Из пути запроса формирует данные;
  3. В авторизации заложены три правила, которые говорят, что читателю можно читать данные, редактору читать и изменять данные, а администратору доступно все
  4. Если доступ разрешен, то запрос отправляется в бизнес-приложение.
d7b903b5120b6909cd5add474a4e2154.png

Реализация
Все. С теорией закончили. Если честно, то теории тут гораздо больше чем самой реализации. Чем лично мне и импонирует это решение.
В качестве модуля авторизации я буду использовать 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/
2cc86886f1712357b333baf5f1da88e7.png

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"}
Сначала получим токен для пользователя с правами читателя
5f6efbc89a3ed639a5a3d23259591994.png

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

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

А теперь время неудобных вопросов
  • Можно ли ограничить доступ к конкретным ресурсам, а не просто к конкретным методам?
Частично можно. Посмотреть как
У нас два типа ресурсов: запрашиваемый и возвращаемый
Объект, который запрашивается в момент запроса, можно выделить и отправить в запрос авторизации. В правилах соответственно учитывать еще и параметры объекта.
Для возвращаемого ресурса ответ симметричен, только сформировать запрос доступа придется после обработки запроса приложением.
Но не нужно. Данная реализация основывается на данных доступных в запросе практически без обработки. Практически, потому что десериализация токена - это довольно существенные затраты. Но их можно практически нивелировать сделав кэширование токенов на уровне прокси. Учитывая, что токен обычно живет больше 15 минут - это существенно сократит время на обработку.
А если принести в логику запросов разбор запроса и выделение из тела ключевых данных, то это может существенно замедлить обработку запросов. В дальнейшем же уже столкнетесь с тем, что метод “Дай все доступное для этого пользователя” потребует постобработки.
  • Можно ли этот подход использовать с Graphql или сервисами игнорирующими REST?
Частично можно. Посмотреть как
Выделив из тела запроса функцию Graphql получится более точно определить права доступа.
Но не нужно. Так как это в итоге снова приведет к потере производительности по причинам из первого пункта.

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

 
Сверху