Пошук уроків, статей та іншого контенту
Відокремимо ролі від дозволів і реалізуємо детальну перевірку операцій над ресурсами.
Роль — це іменована група дозволів, наприклад:
admin;
editor;
author;
viewer.
Дозвіл описує конкретну дію над конкретним ресурсом:
читати статтю;
редагувати статтю;
видаляти статтю;
створювати статтю.
Користувачу зазвичай призначають ролі, а не окремі дозволи:
Користувач → ролі → дозволиНаприклад:
editor → articles:read:any
editor → articles:update:anyРоль не повинна бути єдиною умовою доступу. Для детальної перевірки потрібно враховувати:
ресурс;
операцію;
користувача;
власника ресурсу;
область дії дозволу.
Зручний формат дозволу:
ресурс:операція:областьНаприклад:
articles:read:any
articles:update:any
articles:update:own
articles:delete:ownТут:
any — операцію можна виконувати над будь-яким ресурсом;
own — операцію можна виконувати лише над власним ресурсом.
Приклад набору ролей:
admin:
articles:read:any
articles:create:any
articles:update:any
articles:delete:any
editor:
articles:read:any
articles:create:any
articles:update:any
author:
articles:read:any
articles:create:any
articles:update:own
articles:delete:own
viewer:
articles:read:anyВажливо, що author і editor мають різні можливості:
автор може редагувати лише власні статті;
редактор може редагувати будь-які статті;
автор може видаляти власні статті;
редактор у цьому прикладі не має дозволу на видалення.
Користувачеві достатньо зберігати ідентифікатори ролей:
const user = {
id: "user-1",
roles: ["author"],
};Самі дозволи зберігаються в конфігурації ролей:
const roles = {
author: [
"articles:read:any",
"articles:create:any",
"articles:update:own",
"articles:delete:own",
],
};Такий підхід має кілька переваг:
дозволи не дублюються в кожному користувачі;
зміну політики можна зробити централізовано;
роль має зрозуміле бізнес-значення;
перевірка не залежить від тексту ролі в конкретному обробнику.
Перевірка має отримувати не лише користувача та назву операції, а й ресурс.
can(user, "update", "articles", article)Без ресурсу неможливо визначити, чи є стаття власною для цього користувача.
Алгоритм перевірки:
Отримати дозволи всіх ролей користувача.
Перевірити дозвіл із областю any.
Якщо його немає, перевірити дозвіл із областю own.
Для own переконатися, що resource.ownerId === user.id.
Якщо жодна умова не виконана, відмовити в доступі.
Файл permissions.js можна запустити командою node permissions.js.
"use strict";
const roles = Object.freeze({
admin: [
"articles:read:any",
"articles:create:any",
"articles:update:any",
"articles:delete:any",
],
editor: [
"articles:read:any",
"articles:create:any",
"articles:update:any",
],
author: [
"articles:read:any",
"articles:create:any",
"articles:update:own",
"articles:delete:own",
],
viewer: [
"articles:read:any",
],
});
class ForbiddenError extends Error {
constructor(message = "Доступ заборонено") {
super(message);
this.name = "ForbiddenError";
this.statusCode = 403;
}
}
function getUserPermissions(user) {
if (!user || !Array.isArray(user.roles)) {
return new Set();
}
const permissions = new Set();
for (const roleName of user.roles) {
const rolePermissions = roles[roleName];
if (!rolePermissions) {
continue;
}
for (const permission of rolePermissions) {
permissions.add(permission);
}
}
return permissions;
}
function can(user, action, resourceName, resource = null) {
const permissions = getUserPermissions(user);
const permissionForAnyResource =
`${resourceName}:${action}:any`;
if (permissions.has(permissionForAnyResource)) {
return true;
}
const permissionForOwnResource =
`${resourceName}:${action}:own`;
const isOwner =
resource !== null &&
resource.ownerId === user.id;
return permissions.has(permissionForOwnResource) && isOwner;
}
function authorize(user, action, resourceName, resource = null) {
if (!can(user, action, resourceName, resource)) {
throw new ForbiddenError(
`Користувач не може виконати "${action}" над ресурсом "${resourceName}"`
);
}
}
const articles = [
{
id: "article-1",
title: "Власна стаття автора",
ownerId: "user-author",
status: "draft",
},
{
id: "article-2",
title: "Стаття іншого користувача",
ownerId: "user-editor",
status: "published",
},
];
const users = {
author: {
id: "user-author",
roles: ["author"],
},
editor: {
id: "user-editor",
roles: ["editor"],
},
viewer: {
id: "user-viewer",
roles: ["viewer"],
},
};
function updateArticle(user, articleId, changes) {
const article = articles.find((item) => item.id === articleId);
if (!article) {
throw new Error("Статтю не знайдено");
}
// Перевіряємо доступ саме до знайденого ресурсу.
authorize(user, "update", "articles", article);
Object.assign(article, changes);
return article;
}
function deleteArticle(user, articleId) {
const articleIndex = articles.findIndex(
(item) => item.id === articleId
);
if (articleIndex === -1) {
throw new Error("Статтю не знайдено");
}
const article = articles[articleIndex];
// Для автора перевірка врахує ownerId.
authorize(user, "delete", "articles", article);
const [deletedArticle] = articles.splice(articleIndex, 1);
return deletedArticle;
}
function tryOperation(description, operation) {
try {
const result = operation();
console.log(`Дозволено: ${description}`);
console.log(result);
} catch (error) {
if (error instanceof ForbiddenError) {
console.log(`Заборонено: ${description}`);
console.log(error.message);
return;
}
throw error;
}
}
tryOperation(
"автор редагує власну статтю",
() => updateArticle(users.author, "article-1", {
title: "Оновлений заголовок",
})
);
tryOperation(
"автор редагує чужу статтю",
() => updateArticle(users.author, "article-2", {
title: "Несанкціонована зміна",
})
);
tryOperation(
"редактор редагує чужу статтю",
() => updateArticle(users.editor, "article-1", {
status: "published",
})
);
tryOperation(
"переглядач видаляє статтю",
() => deleteArticle(users.viewer, "article-1")
);
tryOperation(
"автор видаляє власну статтю",
() => deleteArticle(users.author, "article-1")
);У цьому прикладі:
автор може оновити article-1, бо є її власником;
автор не може оновити article-2;
редактор може оновити будь-яку статтю;
переглядач не має дозволу на видалення;
автор може видалити власну статтю.
Перевірку потрібно виконувати після отримання ресурсу з бази даних, але до його зміни.
Умовна структура обробника:
async function handleUpdateArticle(request, response) {
const user = request.user;
const articleId = request.params.id;
const article = await articleRepository.findById(articleId);
if (!article) {
response.statusCode = 404;
response.end("Статтю не знайдено");
return;
}
try {
authorize(user, "update", "articles", article);
} catch (error) {
if (error instanceof ForbiddenError) {
response.statusCode = 403;
response.end("Доступ заборонено");
return;
}
throw error;
}
const updatedArticle = await articleRepository.update(
articleId,
request.body
);
response.statusCode = 200;
response.end(JSON.stringify(updatedArticle));
}Не можна перевіряти лише наявність ролі:
if (user.roles.includes("author")) {
// Небезпечно: не перевірено власника статті
}Така перевірка дозволила б автору змінювати будь-які статті.
Натомість перевіряється конкретна операція над конкретним ресурсом:
authorize(user, "update", "articles", article);Під час створення ресурсу власника ще немає, тому перевіряється дозвіл на створення:
function createArticle(user, data) {
authorize(user, "create", "articles");
const article = {
id: crypto.randomUUID(),
title: data.title,
ownerId: user.id,
status: "draft",
};
return article;
}Після перевірки власника потрібно визначати на сервері. Не варто брати його з тіла запиту:
// Небезпечно
const article = {
title: request.body.title,
ownerId: request.body.ownerId,
};Користувач може підставити чужий ownerId. Власником нового ресурсу зазвичай має бути автентифікований користувач:
const article = {
title: request.body.title,
ownerId: request.user.id,
};Зручно розділити код на три частини:
Конфігурація ролей — описує, які дозволи належать ролям.
Модуль авторизації — відповідає на питання, чи дозволена операція.
Сервіс ресурсу — завантажує ресурс, викликає перевірку та виконує зміну.
Наприклад, сервіс не повинен сам розбирати всі ролі:
if (user.roles.includes("admin") || user.roles.includes("editor")) {
// логіка доступу змішана з логікою бізнес-операції
}Краще викликати одну функцію авторизації:
authorize(user, "update", "articles", article);Тоді зміна політики не потребуватиме пошуку всіх перевірок ролей у коді.
Приховування кнопки «Видалити» в інтерфейсі не захищає API. Користувач може вручну надіслати HTTP-запит.
Перевірка дозволів обов’язково має виконуватися на сервері.
Роль — це група дозволів, а не сама бізнес-умова. Перевіряйте операцію:
authorize(user, "delete", "articles", article);а не назву ролі:
user.roles.includes("author");Дозвіл articles:update:own без перевірки ownerId фактично може стати дозволом на редагування будь-якого ресурсу.
Якщо роль невідома або користувач не має дозволів, результатом має бути відмова:
return false;Не слід дозволяти операцію, якщо перевірка не змогла знайти відповідне правило.
Роль не можна брати з request.body, query-параметрів або довільного HTTP-заголовка. Дані про автентифікованого користувача мають надходити з надійного шару автентифікації.
Небезпечно спочатку змінити ресурс, а потім перевірити дозвіл. Перевірка має виконуватися до запису в базу даних.
Роль — це група дозволів, призначена користувачу.
Дозвіл описує конкретну операцію над конкретним ресурсом.
Область any дозволяє працювати з будь-яким ресурсом.
Область own вимагає перевірки власника.
Авторизація повинна враховувати не лише користувача, а й сам ресурс.
Для створення ресурсів власника потрібно визначати на сервері.
Перевірку доступу слід виконувати до будь-якої зміни ресурсу.
У бізнес-коді краще перевіряти дозволи через єдину функцію authorize, а не розподіляти перевірки ролей по всьому застосунку.