Пошук уроків, статей та іншого контенту
Додасте granular permissions для керування доступом до окремих ресурсів і операцій.
Роль відповідає на запитання: хто користувач?
Дозвіл відповідає на запитання: що саме користувач може зробити?
Наприклад, роль editor може містити такі дозволи:
posts:read
posts:create
posts:updateА роль viewer — лише:
posts:readТакий підхід називають granular permissions, або деталізованими дозволами. Замість перевірки лише ролі застосунок перевіряє конкретну операцію над конкретним типом ресурсу.
Зазвичай дозвіл має формат:
<ресурс>:<операція>Наприклад:
users:read
users:update
posts:delete
comments:moderateДля більш складних правил можна додати область дії:
posts:update:own
posts:update:anyДозвіл posts:update:own може дозволяти редагувати лише власні публікації, а posts:update:any — будь-які.
Перед перевіркою дозволів authentication guard має ідентифікувати користувача та додати його до request.user.
Приклад структури користувача:
interface AuthenticatedUser {
id: string;
email: string;
permissions: string[];
}Після автентифікації запит може мати такий вигляд:
request.user = {
id: 'user-42',
email: 'anna@example.com',
permissions: ['posts:read', 'posts:update'],
};Джерелом дозволів можуть бути:
база даних;
JWT;
об’єднання дозволів користувача та його ролей;
зовнішній сервіс авторизації.
Клієнт не повинен самостійно надсилати дозволи в запиті. Сервер має отримувати їх із перевіреного джерела після успішної автентифікації.
Зробимо декоратор @RequirePermissions(), який зберігатиме потрібні дозволи в metadata маршруту.
// permissions.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);Тепер маршрут може явно описати, який дозвіл йому потрібен:
@RequirePermissions('posts:read')Такий опис залишається поруч із маршрутом, тому правила доступу легко побачити під час читання контролера.
Guard отримує дозволи з metadata та порівнює їх із дозволами користувача.
// permissions.guard.ts
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from './permissions.decorator';
interface AuthenticatedUser {
id: string;
email: string;
permissions: string[];
}
interface AuthenticatedRequest {
user?: AuthenticatedUser;
}
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions =
this.reflector.getAllAndOverride<string[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
// Якщо дозвіл не вказано, цей guard не обмежує маршрут.
if (!requiredPermissions?.length) {
return true;
}
const request =
context.switchToHttp().getRequest<AuthenticatedRequest>();
if (!request.user) {
throw new UnauthorizedException();
}
const grantedPermissions = new Set(request.user.permissions);
const hasAllPermissions = requiredPermissions.every((permission) =>
grantedPermissions.has(permission),
);
if (!hasAllPermissions) {
throw new ForbiddenException(
'Недостатньо дозволів для виконання операції',
);
}
return true;
}
}У цьому прикладі користувач повинен мати всі дозволи, вказані в декораторі.
Наприклад:
@RequirePermissions('posts:read', 'posts:export')вимагає одночасно posts:read і posts:export.
Якщо користувач автентифікований, але дозволу немає, сервер повертає 403 Forbidden. Якщо користувач взагалі не автентифікований, сервер повертає 401 Unauthorized.
// posts.controller.ts
import {
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { PermissionsGuard } from './permissions.guard';
import { RequirePermissions } from './permissions.decorator';
@Controller('posts')
@UseGuards(PermissionsGuard)
export class PostsController {
@Get()
@RequirePermissions('posts:read')
findAll() {
return {
message: 'Список публікацій',
};
}
@Post()
@RequirePermissions('posts:create')
create() {
return {
message: 'Публікацію створено',
};
}
@Patch(':id')
@RequirePermissions('posts:update')
update(@Param('id') id: string) {
return {
message: `Публікацію ${id} оновлено`,
};
}
@Delete(':id')
@RequirePermissions('posts:delete')
remove(@Param('id') id: string) {
return {
message: `Публікацію ${id} видалено`,
};
}
}Authentication guard має виконуватися раніше та заповнювати request.user. Точний спосіб підключення залежить від реалізації автентифікації у застосунку:
@UseGuards(AuthenticationGuard, PermissionsGuard)Або authentication guard можна підключити глобально, а PermissionsGuard — на рівні контролерів чи окремих маршрутів.
Якщо PermissionsGuard використовує dependency injection для Reflector, його потрібно зареєструвати як provider у модулі:
import { Module } from '@nestjs/common';
import { PermissionsGuard } from './permissions.guard';
import { PostsController } from './posts.controller';
@Module({
controllers: [PostsController],
providers: [PermissionsGuard],
})
export class PostsModule {}NestJS створить guard через контейнер залежностей і передасть йому Reflector.
Декоратор можна застосувати до всього контролера:
@Controller('posts')
@UseGuards(PermissionsGuard)
@RequirePermissions('posts:read')
export class PostsController {
@Get()
findAll() {
return [];
}
@Get(':id')
findOne() {
return {};
}
}У такому випадку дозвіл застосовується до всіх маршрутів контролера.
Якщо для конкретного методу вказано власний @RequirePermissions(), він замінює metadata контролера:
@Controller('posts')
@UseGuards(PermissionsGuard)
@RequirePermissions('posts:read')
export class PostsController {
@Get()
findAll() {
return [];
}
@Delete(':id')
@RequirePermissions('posts:delete')
remove(@Param('id') id: string) {
return {
message: `Публікацію ${id} видалено`,
};
}
}Для GET /posts потрібен posts:read, а для DELETE /posts/:id — posts:delete.
Перевірка дозволу posts:update відповідає лише на питання, чи може користувач виконувати операцію оновлення публікацій загалом.
Але іноді потрібно перевірити ще й конкретний ресурс:
чи є користувач автором публікації;
чи належить публікація його організації;
чи має користувач дозвіл змінювати саме цю публікацію.
Таку перевірку краще виконувати після завантаження ресурсу в сервісі.
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
interface Post {
id: string;
authorId: string;
title: string;
}
interface User {
id: string;
permissions: string[];
}
@Injectable()
export class PostsService {
async updatePost(
postId: string,
user: User,
title: string,
): Promise<Post> {
const post = await this.findPost(postId);
const canUpdateAnyPost = user.permissions.includes('posts:update:any');
const canUpdateOwnPost = user.permissions.includes('posts:update:own');
const isOwner = post.authorId === user.id;
if (!canUpdateAnyPost && !(canUpdateOwnPost && isOwner)) {
throw new ForbiddenException(
'Ви не можете змінити цю публікацію',
);
}
post.title = title;
return post;
}
private async findPost(postId: string): Promise<Post> {
const post: Post | undefined = await Promise.resolve({
id: postId,
authorId: 'user-42',
title: 'Початковий заголовок',
});
if (!post) {
throw new NotFoundException('Публікацію не знайдено');
}
return post;
}
}У цьому випадку guard перевіряє загальний доступ до операції, а сервіс — доступ до конкретного ресурсу.
Наприклад, користувач із дозволом posts:update:own може редагувати лише власні публікації. Користувач із posts:update:any може редагувати будь-які.
Контролер передає автентифікованого користувача до сервісу:
import {
Body,
Controller,
Param,
Patch,
Req,
UseGuards,
} from '@nestjs/common';
import { PermissionsGuard } from './permissions.guard';
import { RequirePermissions } from './permissions.decorator';
import { PostsService } from './posts.service';
@Controller('posts')
@UseGuards(PermissionsGuard)
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Patch(':id')
@RequirePermissions('posts:update:own')
update(
@Param('id') id: string,
@Body('title') title: string,
@Req() request: { user: { id: string; permissions: string[] } },
) {
return this.postsService.updatePost(id, request.user, title);
}
}Однак у такій схемі @RequirePermissions('posts:update:own') вимагатиме саме цей дозвіл і не пропустить користувача з posts:update:any.
Для підтримки кількох альтернативних дозволів зручно додати окремий декоратор і режим перевірки.
Іноді достатньо одного з кількох дозволів:
posts:update:own;
posts:update:any.
Створимо декоратор для режиму any:
import { SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const PERMISSIONS_MODE_KEY = 'permissions_mode';
export const RequireAnyPermission = (...permissions: string[]) => {
return (target: object, propertyKey?: string, descriptor?: PropertyDescriptor) => {
SetMetadata(PERMISSIONS_KEY, permissions)(
target,
propertyKey,
descriptor,
);
SetMetadata(PERMISSIONS_MODE_KEY, 'any')(
target,
propertyKey,
descriptor,
);
};
};У TypeScript для NestJS частіше зручніше оформити спільний фабричний декоратор:
import { applyDecorators, SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const PERMISSIONS_MODE_KEY = 'permissions_mode';
export const RequirePermissions = (...permissions: string[]) =>
applyDecorators(
SetMetadata(PERMISSIONS_KEY, permissions),
SetMetadata(PERMISSIONS_MODE_KEY, 'all'),
);
export const RequireAnyPermission = (...permissions: string[]) =>
applyDecorators(
SetMetadata(PERMISSIONS_KEY, permissions),
SetMetadata(PERMISSIONS_MODE_KEY, 'any'),
);Guard тоді враховує режим перевірки:
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
PERMISSIONS_KEY,
PERMISSIONS_MODE_KEY,
} from './permissions.decorator';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions =
this.reflector.getAllAndOverride<string[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions?.length) {
return true;
}
const mode =
this.reflector.getAllAndOverride<'all' | 'any'>(
PERMISSIONS_MODE_KEY,
[context.getHandler(), context.getClass()],
) ?? 'all';
const request = context
.switchToHttp()
.getRequest<{ user?: { permissions: string[] } }>();
if (!request.user) {
throw new UnauthorizedException();
}
const grantedPermissions = new Set(request.user.permissions);
const hasPermission =
mode === 'any'
? requiredPermissions.some((permission) =>
grantedPermissions.has(permission),
)
: requiredPermissions.every((permission) =>
grantedPermissions.has(permission),
);
if (!hasPermission) {
throw new ForbiddenException(
'Недостатньо дозволів для виконання операції',
);
}
return true;
}
}Тепер маршрут може дозволити будь-який із двох варіантів:
@Patch(':id')
@RequireAnyPermission('posts:update:own', 'posts:update:any')
update() {
return {
message: 'Публікацію оновлено',
};
}Це лише перевіряє наявність одного з дозволів. Перевірка того, чи є публікація власною, усе одно має виконуватися в сервісі.
Назви дозволів мають бути стабільними та передбачуваними. Для кожного ресурсу варто заздалегідь визначити дозволені операції:
posts:read
posts:create
posts:update
posts:delete
posts:publish
posts:moderateЯкщо потрібна область дії, використовуйте додаткову частину:
posts:update:own
posts:update:anyНе варто змішувати в одному дозволі кілька незалежних понять:
posts:read-and-updateКраще створити два дозволи:
posts:read
posts:updateТак буде простіше створювати ролі та змінювати їх без неочікуваного розширення доступу.
Інтерфейс може приховати кнопку для користувача без дозволу, але це не є захистом. Користувач все одно може вручну надіслати HTTP-запит.
Перевірка дозволів обов’язково має виконуватися на сервері.
401 Unauthorized — користувач не автентифікований;
403 Forbidden — користувач автентифікований, але не має потрібного дозволу.
Умова на кшталт:
if (user.role === 'admin') {
// доступ
}швидко стає складною, коли з’являються нові ролі. Краще перевіряти конкретну можливість:
if (user.permissions.includes('posts:delete')) {
// доступ
}Дозвіл posts:update не означає автоматично, що користувач може змінити будь-яку публікацію. Для цього потрібна додаткова перевірка власника або області доступу.
Декоратор @RequirePermissions() лише додає metadata. Сам по собі він не блокує запит. Потрібно підключити PermissionsGuard.
PermissionsGuard очікує, що request.user уже існує. Тому authentication guard має виконатися до нього.
Дозвіл на кшталт admin:* може бути зручним, але він ускладнює аудит і збільшує наслідки помилки. За можливості надавайте лише необхідні дозволи.
Granular permissions описують доступ до конкретної операції над ресурсом.
Дозволи зручно називати у форматі ресурс:операція.
Декоратор зберігає потрібні дозволи в metadata маршруту.
PermissionsGuard порівнює metadata з дозволами користувача.
Для кількох обов’язкових дозволів використовується логіка all, для альтернатив — any.
Перевірку доступу до конкретного ресурсу, наприклад власності публікації, потрібно виконувати в сервісі або policy-шарі.
Authentication guard має виконуватися до PermissionsGuard.
Перевірки на сервері є обов’язковими, навіть якщо клієнт приховує недоступні елементи інтерфейсу.