
Когда речь заходит про права доступа в приложении, то из этой ситуации появляется два результата:
Либо в коде приложения появляются привязки к неким ролям/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
