Когда речь заходит про права доступа в приложении, то из этой ситуации появляется два результата:
Либо в коде приложения появляются привязки к неким ролям/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 может выполнить запрос авторизации, то остается только добавить новый кубик в схему - модуль авторизации.
Таким образом наша цепочка превращается в следующую последовательность:
Пользователь получил свой идентификационный токен и мы предполагаем, что он содержит всю необходимую информацию о пользователе. С этим токеном он выполняет запрос в бизнес-приложение попадая в Gateway.
-
Gateway надо сформировать запрос прав доступа. Для этого он разбирает запрос на части:
- Забирает токен из заголовка и десериализует, формируя данные о пользователе;
- Выделяет HTTP-метод из запроса и говорит, что это то действие которое выполняет пользователь;
- Из пути запроса формирует данные;
В авторизации заложены три правила, которые говорят, что читателю можно читать данные, редактору читать и изменять данные, а администратору доступно все
Если доступ разрешен, то запрос отправляется в бизнес-приложение.
Реализация
Все. С теорией закончили. Если честно, то теории тут гораздо больше чем самой реализации. Чем лично мне и импонирует это решение.
В качестве модуля авторизации я буду использовать 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
Комментарии (8)
Dekmabot
27.11.2021 14:41Выглядит более чем привлекательно, ведь если заводить все ресурсы в keycloak и там же вести scope`ы доступа к ним, настроить группы и работать по jwt, на первый взгляд и правда кажется что можно не париться об этом в приложении.
Но сдаётся мне что приложение всё равно должно будет сверяться с тем же keycloak в различных выборках. Тем не менее, выглядит любопытно)
melnikovio Автор
27.11.2021 14:54+1Пробовал я как-то авторизацию со всеми authorization/scope и прочими плюшками из кейклока. Гибкая штука, но перфоманс у неё отвратительный. И когда я искал инфу как сделать побыстрее наткнулся на обсуждение в группе кейклока, где авторы сказали - нам надо было сделать гибко, а не быстро. Хотите скорости - делайте кэш.
Dekmabot
27.11.2021 15:08А если кеш - то можно и скоупы локально в приложении хранить, а тогда keycloak - просто для аутентификации, а значит овчинка выделки не стоит.
Идея-то хорошая, просто реализация пока так себе)
melnikovio Автор
27.11.2021 15:19А локально кэш придётся пересобирать. Тогда кейлоку придётся приложение уведомлять об изменениях в пользователях, ролях, правилах, скоупах.
В общем борода начнёт расти…
korsetlr473
А как лучше сделать то что вы предлагаете для авторизации per контент(допустим как куплена или нет статья) или per creator (допустим куплена подписка на человека с доступом ко всем статьям).
Неужто перед каждым запросом лезть в базу и чекать в биллинге и других проемов нету ?
melnikovio Автор
В контексте статьи я бы подумал в первую очередь как нести эту информацию в токене - создатель, купил доступ и т.п.
Только надо обязательно учитывать, что жирные токены приведут к потере перфоманса. Тут надо искать баланс.
Есть ещё один вариант - это подумать как сделать PIP - https://www.axiomatics.com/policy-information-point-in-five-minutes/