Пошук уроків, статей та іншого контенту
Відокремите ролі від granular permissions і реалізуєте перевірку дозволів для ресурсів та операцій.
Роль — це набір повноважень, який зручно призначати користувачу:
admin;
manager;
member.
Permission описує конкретну дію над конкретним ресурсом:
project:read;
project:update;
project:delete.
Роль не повинна бути єдиною перевіркою доступу. Наприклад, два користувачі з роллю member можуть мати різний доступ:
власник проєкту може його редагувати;
учасник проєкту може лише переглядати його;
користувач, який не має стосунку до проєкту, не може його переглядати.
Тому перевірка доступу зазвичай складається з двох рівнів:
Чи має роль необхідний permission?
Чи дозволений доступ саме до цього екземпляра ресурсу?
can(user, "project:update", project)Перевіряти потрібно не лише роль, а й контекст ресурсу.
Для прикладу використаємо такі правила:
admin може виконувати всі операції;
manager може переглядати та редагувати будь-які проєкти;
member може переглядати проєкт, якщо він є його учасником;
member може редагувати лише власний проєкт;
видаляти проєкти може лише admin.
Типи користувача та ресурсів можна описати у файлі lib/authorization.ts.
// lib/authorization.ts
export type Role = "admin" | "manager" | "member";
export type Permission =
| "project:read"
| "project:update"
| "project:delete";
export type User = {
id: string;
role: Role;
};
export type Project = {
id: string;
ownerId: string;
memberIds: string[];
name: string;
};
const rolePermissions: Record<Role, ReadonlySet<Permission>> = {
admin: new Set([
"project:read",
"project:update",
"project:delete",
]),
manager: new Set([
"project:read",
"project:update",
]),
member: new Set([
"project:read",
"project:update",
]),
};
function hasRolePermission(
user: User,
permission: Permission,
): boolean {
return rolePermissions[user.role].has(permission);
}
export function can(
user: User,
permission: Permission,
project?: Project,
): boolean {
// Спочатку перевіряємо, чи існує такий permission у ролі.
if (!hasRolePermission(user, permission)) {
return false;
}
// Адміністратор не має додаткових обмежень у цьому прикладі.
if (user.role === "admin") {
return true;
}
// Для permission без ресурсу достатньо перевірки ролі.
if (!project) {
return true;
}
switch (permission) {
case "project:read":
if (user.role === "manager") {
return true;
}
return (
project.ownerId === user.id ||
project.memberIds.includes(user.id)
);
case "project:update":
if (user.role === "manager") {
return true;
}
// Звичайний учасник може редагувати лише власний проєкт.
return project.ownerId === user.id;
case "project:delete":
// До цього місця member і manager не дійдуть,
// бо не мають project:delete.
return false;
default:
return false;
}
}Функція can має безпечну поведінку за замовчуванням: якщо permission не знайдено або правило не описане, результатом буде false.
Така перевірка швидко створює жорстко закодовані правила:
if (user.role === "admin" || user.role === "manager") {
// Дозволити редагування
}Проблеми цього підходу:
назви ролей починають дублюватися по всьому застосунку;
додавання нової ролі вимагає змін у багатьох файлах;
неможливо описати умови для конкретного ресурсу;
бізнес-правила змішуються з кодом HTTP-обробників.
Краще, щоб обробник запитів говорив про дію:
if (!can(user, "project:update", project)) {
// Відмовити в доступі
}Тоді правила зосереджені в одному модулі авторизації.
У Next.js перевірку необхідно виконувати на сервері — у Route Handler, Server Action або іншому серверному коді.
Приклад Route Handler для оновлення проєкту:
// app/api/projects/[id]/route.ts
import { NextResponse } from "next/server";
import {
can,
type Project,
type User,
} from "@/lib/authorization";
type RouteContext = {
params: Promise<{ id: string }>;
};
// У реальному застосунку користувач має надходити з перевіреної сесії.
async function getCurrentUser(): Promise<User | null> {
return {
id: "user-1",
role: "member",
};
}
// У прикладі використовується пам'ять процесу.
// У реальному застосунку тут має бути запит до бази даних.
const projects: Project[] = [
{
id: "project-1",
ownerId: "user-1",
memberIds: ["user-2"],
name: "Навчальний проєкт",
},
{
id: "project-2",
ownerId: "user-2",
memberIds: ["user-1"],
name: "Командний проєкт",
},
];
export async function PATCH(
request: Request,
context: RouteContext,
) {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 },
);
}
const { id } = await context.params;
const project = projects.find((item) => item.id === id);
if (!project) {
return NextResponse.json(
{ error: "Project not found" },
{ status: 404 },
);
}
if (!can(user, "project:update", project)) {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 },
);
}
const body: unknown = await request.json();
if (
typeof body !== "object" ||
body === null ||
!("name" in body) ||
typeof body.name !== "string" ||
body.name.trim().length === 0
) {
return NextResponse.json(
{ error: "A non-empty name is required" },
{ status: 400 },
);
}
project.name = body.name.trim();
return NextResponse.json(project);
}У цьому прикладі користувач user-1 є власником project-1, тому може його редагувати. Але для project-2 він є лише учасником, тому отримає 403 Forbidden.
Важливо, що перевірка виконується до зміни даних. Перевірка лише в інтерфейсі не захищає ресурс:
// Це не є захистом.
const canEdit = user.role === "admin";Клієнтський код можна змінити або викликати API напряму, минаючи інтерфейс.
Для читання іноді краще повертати 404, а не 403. Це запобігає розкриттю факту існування ресурсу.
Наприклад, якщо користувач не є учасником приватного проєкту, відповідь:
{
"error": "Forbidden"
}підтверджує, що проєкт існує.
У деяких системах безпечніше поводитися так, ніби ресурс не знайдений:
if (!project || !can(user, "project:read", project)) {
return NextResponse.json(
{ error: "Project not found" },
{ status: 404 },
);
}Це рішення залежить від моделі безпеки застосунку:
401 — користувач не автентифікований;
403 — користувач автентифікований, але дія заборонена;
404 — ресурс не існує або його існування не можна розкривати.
Не потрібно механічно замінювати всі 403 на 404. Для операцій над ресурсом, який користувач уже бачить, 403 часто є коректнішим.
Server Action також є серверною точкою входу, тому має виконувати ту саму перевірку. Не слід покладатися на те, що Action викликається лише з певної сторінки.
// app/projects/actions.ts
"use server";
import {
can,
type Project,
type User,
} from "@/lib/authorization";
async function getCurrentUser(): Promise<User | null> {
// Тут має бути отримання користувача з перевіреної сесії.
return {
id: "user-1",
role: "member",
};
}
async function findProject(id: string): Promise<Project | null> {
// Тут має бути запит до бази даних.
if (id !== "project-1") {
return null;
}
return {
id: "project-1",
ownerId: "user-1",
memberIds: ["user-2"],
name: "Навчальний проєкт",
};
}
export async function renameProject(
projectId: string,
name: string,
) {
const user = await getCurrentUser();
if (!user) {
throw new Error("Authentication required");
}
const project = await findProject(projectId);
if (!project) {
throw new Error("Project not found");
}
if (!can(user, "project:update", project)) {
throw new Error("Forbidden");
}
const normalizedName = name.trim();
if (normalizedName.length === 0) {
throw new Error("Project name cannot be empty");
}
// Оновлення повинно виконуватися лише після перевірки дозволу.
project.name = normalizedName;
return project;
}Server Action може викликатися з форми, але це не змінює вимог до безпеки. Кожен виклик повинен повторно:
отримати поточного користувача;
завантажити ресурс;
перевірити permission з контекстом ресурсу;
провалідувати вхідні дані;
виконати зміну.
Один і той самий permission має перевірятися однаково:
у Route Handler;
у Server Action;
у серверній функції, яку викликають кілька обробників.
Якщо логіка дублюється, вона поступово розходиться. Наприклад, PATCH може перевіряти власника, а Server Action — лише роль. Кращий варіант — винести політику в can або спеціалізовані функції:
export function canUpdateProject(
user: User,
project: Project,
): boolean {
return can(user, "project:update", project);
}Такі функції можуть бути корисними, якщо правило стало складним або permission використовується в багатьох місцях.
Водночас не варто створювати функцію для кожної простої перевірки без потреби. Якщо правило добре читається через can, достатньо універсального API.
Пакетні операції потрібно перевіряти для кожного ресурсу окремо. Не можна дозволити масове оновлення лише тому, що користувач має permission для одного з об'єктів.
Небезпечна логіка:
if (can(user, "project:update")) {
// Оновити всі проєкти з отриманого списку
}Permission без ресурсу означає лише загальну можливість виконувати операцію, але не підтверджує доступ до конкретного проєкту.
Безпечніший підхід:
const allowedProjects = projects.filter((project) =>
can(user, "project:update", project),
);Або для операції, яка повинна бути атомарною, потрібно відхилити весь запит, якщо хоча б один ресурс недоступний:
const allAllowed = requestedProjects.every((project) =>
can(user, "project:update", project),
);
if (!allAllowed) {
throw new Error("Forbidden");
}Вибір між частковим виконанням і повним відхиленням є бізнес-правилом. Його потрібно визначити явно.
Політика має бути закритою за замовчуванням:
невідома роль не отримує доступ;
невідомий permission не отримує доступ;
відсутній ресурс не проходить перевірку;
відсутній контекст для ресурсного permission не повинен автоматично дозволяти операцію;
помилка завантаження ресурсу не повинна трактуватися як дозвіл.
Не використовуйте конструкції, у яких відсутність правила означає дозвіл:
// Небезпечно: відсутній запис трактується як дозвіл.
return permissions[role]?.includes(permission) !== false;Безпечніше:
return permissions[role]?.has(permission) === true;У міру розвитку застосунку кількість permission зростає. Щоб уникати непередбачених змін:
використовуйте union type або інший централізований список permission;
зберігайте правила ролей в одному місці;
не порівнюйте рядки permission, розкидані по компонентах;
додавайте тести для кожної ролі та критичної операції;
окремо тестуйте власника, учасника, менеджера та користувача без доступу.
Permission слід називати дією над ресурсом, а не технічною деталлю інтерфейсу:
project:updateкраще, ніж:
canSeeEditProjectButtonПерший варіант описує політику доступу, а не конкретний компонент.
Приховування кнопки редагування не захищає API. Користувач може надіслати PATCH вручну.
Виправлення: перевіряйте дозвіл у серверній точці входу.
Роль member не відповідає на питання, до якого саме проєкту має доступ користувач.
Виправлення: передавайте ресурс у перевірку can.
Якщо спочатку оновити запис, а потім перевірити permission, дані вже могли бути змінені.
Виправлення: завантажте ресурс і перевірте доступ до операції запису.
userId, надісланий у тілі запиту, не повинен використовуватися як поточний користувач.
Виправлення: отримуйте користувача з перевіреної серверної сесії або іншого надійного механізму автентифікації.
Permission на кшталт project:manage може приховувати кілька різних небезпечних операцій.
Виправлення: розділяйте permission за діями, якщо для них потрібні різні правила або рівні ризику.
Якщо новий permission автоматично дозволяється через fallback, помилка конфігурації стає вразливістю.
Виправлення: використовуйте deny-by-default і повертайте false для невідомих випадків.
Роль — це група permission, а не повна модель авторизації.
Granular permission описує конкретну операцію над ресурсом.
Для ресурсів потрібна перевірка контексту: власника, учасника, організації або іншої умови.
Перевірка має виконуватися на сервері в кожному Route Handler і Server Action.
Логіку політики краще централізувати у функції на кшталт can.
Невідомі ролі, permission і правила повинні забороняти доступ.
401 означає відсутність автентифікації, 403 — відсутність дозволу, а 404 іноді використовується, щоб не розкривати існування приватного ресурсу.