Организация доступа к REST API на основе Модели контроля доступа

Разграничение доступа к Node.js web-приложению согласно прав пользователей с использованием библиотеки Casbin

Авторизация пользователя

В рамках задачи разграничения доступа к API можно выделить три основных пункта:

  1. Модель контроля доступа.

  2. Алгоритм (правило, политика) определения доступности метода API для пользователя.

  3. Способ реализации: самописный инструмент, готовая библиотека, отдельный сервис SAAS, FAAS и т.д.

Модель контроля доступа

Как правило при выборе Модели контроля доступа рассматриваю:

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

RBAC оперирует ролями вместо отдельных пользователей. При этом пользователи могут входить в одну или более групп.

ABAC подразумевает работу не с сущностями системы (ресурсами), а проверку отдельных атрибутов этих сущностей. К примеру, ABAC это когда доступ дается к объекту на основе информации об авторе объекта.

Приведенные выше модели ни в коем случае не являются исчерпывающими, и даже для умеренно сложной системы может потребоваться сочетание моделей.

Для большинства многопользовательских систем удобной будет модель RBAC так как позволяет распределять права на основе групп. При этом необходимо учитывать специфику и планы по развитию.

Выбрать какую-либо одну модель мы можем только если границы и функции системы чётко определены и не будут изменяться. В остальных случаях оптимальным будет фокус на наиболее подходящих моделях с архитектурой, способной на дальнейшее расширение.

Определение доступности метода API

Если механизм разграничения прав создается для нового приложения, т.е. самой API ещё нет, то это не является проблемой. Мы можем определить контракт и следовать ему. Скажем в модели RBAC определяем для группы author возможность читать и писать объекты article. При этом договариваемся, что для чтения используем HTTP метод GET, а для записи PUT. Или мы можем добавлять префиксы к URL и на основе них понимать о сущности и типе действия.

Более сложная ситуация связана с реализацией для существующего API. При этом чаще всего изменение самой API крайне нежелательно. В самом худшем случае нужно описать взаимодействие с каждым методом API для каждой группы. Компромиссным решением является:

  1. Наличие общих проверок по правилам, которым удовлетворяет основная масса API методов.

  2. Специфические проверки для отдельных API методов.

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

Реализации проверки прав доступа

Использование сторонних сервисов, таких как keycloak является не столько техническим, сколько организационным выбором.

Самописный инструмент - это диаметрально противоположное решение. Способ затратный и сопряжённый с рисками. В большинстве случаев его использование обосновано только при отсутствии доступных готовых решений или при необходимости соблюдения ряда дополнительных требований и правил (опять же организационный выбор).

Оптимальным, при отсутствия явных противопоказаний, является использование готовых библиотек. Этот подход наиболее сбалансирован с точки зрения затрат и гибкости.

Поиск существующих Node.js библиотек

В OpenSource существует ряд библиотек для разграничения доступа, ниже представлены три из них:

Наименование

Модель контроля доступа

Способ описания прав доступа

CASL

ABAC

С помощью объектов в коде приложения

Access Control

RBAC, ABAC

JSON

Casbin

ACL, RBAC, ABAC, смешанная

PERM

Остановимся на Casbin как наиболее универсальной библиотеке, использующей дополнительную абстракцию над моделями доступа. Благодаря файлу конфигурации основанному на метамоделе PERM (Policy, Effect, Request, Matchers) переключение механизма авторизации заключается в изменении конфигурации.

Пример приложения с разграничением прав доступа на основе ролей

Допустим, что у нас есть API сервис написанный на платформе Node.js, который состоит из двух контроллеров и нескольких методов.

import http from 'http';
//#region SERVER
const hostname = '127.0.0.1';
const port = 3005;
const server = http.createServer((request, response) => {
authMiddleware(request, response);
switch (true) {
case request.url.startsWith('/article'):
articleController(request, response);
break;
case request.url.startsWith('/admin_panel'):
adminController(request, response);
break;
default:
notFound(request, response);
break;
}
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
//#endregion SERVER

//#region CONTROLLERS
function articleController(req, res) {
if (req.method === 'GET' && req.url === '/article') {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Read an article');
return;
}
if (req.method === 'POST' && req.url === '/article/find') {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Find an article by filter');
return;
}
if (req.method === 'POST' && req.url === '/article/write') {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Write a new article');
return;
}
notFound(req, res);
}
function adminController(req, res) {
if (req.method === 'GET' && req.url === '/admin_panel') {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Admin panel');
return;
}
if (req.method === 'PUT' && req.url === '/admin_panel/author') {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Create a new author');
return;
}
notFound(req, res);
}
function notFound(req, res) {
res.statusCode = 404;
res.end();
}
//#endregion CONTROLLERS

У нас уже есть middleware в которой проверяется токен пользователя.

//#region MIDDLEWARE
function authMiddleware(req, res) {
// Get user token
// const token = req.headers['authorization'];
// Validate token etc...
// Get user name and groups from the token
// for simplification we will use values direct from headers
const name = req.headers['name'];
const groups = req.headers['groups'];
if (name null || groups null) {
res.statusCode = 401;
res.end(JSON.stringify({ message: 'Unauthorized' }));
}
}
//#endregion MIDDLEWARE

По условиям задачи у нас будет три группы: admin, author, user. Первой доступно всё, вторая может работать только со статьями, а последняя подразумевает ограниченный доступ (только на чтение) к статьям. Каждый пользователь может обладать от одной до нескольких групп (в системе подразумевается появление специальных групп доступа к отдельным ресурсам).

Переходим к реализации проверки доступа на основе групп пользователя с помощью библиотеки Casbin. Первое, что требуется это указать конфигурацию (CONF).

[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = ,
g2 = ,
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && (g2(r.obj, p.obj) || keyMatch(r.obj, p.obj)) && keyMatch(r.act, p.act)

Из особенностей:

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

В общем случае для REST API за контракт удобно принять следующие утверждения:

Руководствуясь конфигурацией и этими правилами создадим политику.

p, group:user, entity:article, read
p, group:author, entity:article, write
p, group:admin, *, *
p, group:user, /article/find, *
g, group:author, group:user
g, group:admin, group:author
g2, /article, entity:article
g2, /article/*, entity:article

Явным исключением выделяется p, group:user, /article/find, *, что обосновано методом в API http://127.0.0.1:3005/article/find, который хоть и выполняет только чтение, но использует POST.

В остальном политика соответствует описанному выше контракту.

Осталось добавить логику проверки в middleware.

const sub = name; // use a user name as subject
const obj = req.url; // use URL as object
const act = req.method === 'GET' ? 'read' : 'write'; // GET is 'read', others are 'write'
const enforcer = await newEnforcer(newModel(model), new StringAdapter(policy));
enforcer.addNamedMatchingFunc('g2', Util.keyMatchFunc);
const addGroupsToUser = async (sub, groups) => {
await Promise.all(groups.map((group) => enforcer.addRoleForUser(sub, group:${group})));
};
await addGroupsToUser(name, groups);
const isPermitted = await enforcer.enforce(sub, obj, act);
if (!isPermitted) {
res.statusCode = 403;
res.end(JSON.stringify({ message: 'Forbidden' }));
}

На строке enforcer.addNamedMatchingFunc('g2', Util.keyMatchFunc); происходит добавление функции keyMatchFunc для группы g2 . Это необходимо сделать для того, чтобы мы могли указывать конструкции вида /article/* для второй группы.

Второй особенностью в коде является функция addGroupsToUser. Суть её работы состоит в соотнесении текущего пользователя с его группами на уровне механизма авторизации Casbin.

Для проверки авторизации отправим следующие запросы чтобы проверить, что доступ есть:

curl http://127.0.0.1:3005/article -i -H "name: bob" -H "groups: user"
curl http://127.0.0.1:3005/article/find -X POST -i -H "name: bob" -H "groups: user"
curl http://127.0.0.1:3005/article -i -H "name: alice" -H "groups: user,author"
curl http://127.0.0.1:3005/article/write -X POST -i -H "name: alice" -H "groups: user,author"
curl http://127.0.0.1:3005/article/write -X POST -i -H "name: tom" -H "groups: admin,author"
curl http://127.0.0.1:3005/admin_panel/author -X PUT -i -H "name: tom" -H "groups: admin,author"
curl http://127.0.0.1:3005/admin_panel -i -H "name: tom" -H "groups: admin,author"

И несколько, для проверки запрета:

curl http://127.0.0.1:3005/article/write -X POST -i -H "name: bob" -H "groups: user"
curl http://127.0.0.1:3005/admin_panel -i -H "name: alice" -H "groups: user,author"

В итоге достигнут желаемый результат - admin имеет доступ ко всему, author может работать только со статьями, а user обладает ограниченным доступом только на чтение. Полный код примера можно найти по этой ссылке.

Примечания

NB 1: Альтернативой middleware может служить обработчик на каждом отдельном методе API. К примеру, через декоратор при OOP архитектуре, как это показано в библиотеке routing-controllers. К плюсам можно отнести лучшую читаемость кода, его декларативность, возможность передачи дополнительных параметров. К минусам - больше правок существующего кода, обязательность декоратора для каждого метода.

NB 2: У Casbin есть песочница, в которой можно проверить разные конфигурации и политики к ним без написания кода.

Материалы

  1. Теория

  2. Инструменты

  3. Практика