Пошук уроків, статей та іншого контенту
Створите політики авторизації для перевірки складних правил доступу з урахуванням користувача та ресурсу.
Guard добре підходить для перевірки загальних умов:
користувач автентифікований;
користувач має певну роль;
запит надходить до дозволеного маршруту.
Однак складні правила часто залежать одночасно від:
поточного користувача;
конкретного ресурсу;
параметрів запиту;
стану ресурсу;
ролі користувача саме в цьому ресурсі;
кількох умов, об'єднаних через AND або OR.
Наприклад:
Користувач може редагувати проєкт, якщо він є його власником або редактором, але тільки доки проєкт не архівований. Адміністратор організації може редагувати будь-який проєкт у своїй організації.
Таку логіку краще винести в окрему політику, а не розміщувати в контролері або в одному великому guard.
Політика отримує контекст із користувачем і ресурсом та повертає результат перевірки.
import { ExecutionContext } from '@nestjs/common';
export interface PolicyContext<TResource = unknown> {
user: AuthenticatedUser;
resource: TResource;
request: Request;
executionContext: ExecutionContext;
}
export interface Policy<TResource = unknown> {
handle(context: PolicyContext<TResource>): boolean | Promise<boolean>;
}
export interface AuthenticatedUser {
id: string;
organizationId: string;
organizationRole: 'member' | 'admin';
}
export interface Project {
id: string;
organizationId: string;
ownerId: string;
status: 'active' | 'archived';
members: Array<{
userId: string;
role: 'viewer' | 'editor';
}>;
}Політика не повинна знати про HTTP-відповіді, статус-коди або контролер. Її відповідальність — тільки відповісти, чи дозволена дія.
Створимо декоратор, який зберігатиме в metadata список політик і спосіб отримання ресурсу.
import { SetMetadata, Type } from '@nestjs/common';
export const AUTHORIZATION_METADATA = 'authorization';
export interface ResourceResolver<TResource = unknown> {
resolve(request: Request): Promise<TResource | null>;
}
export interface AuthorizationOptions<TResource = unknown> {
policies: Type<Policy<TResource>>[];
resourceResolver: Type<ResourceResolver<TResource>>;
}
export const Authorize = <TResource>(
options: AuthorizationOptions<TResource>,
) => SetMetadata(AUTHORIZATION_METADATA, options);Декоратор можна застосувати до методу контролера:
@Authorize({
policies: [CanEditProjectPolicy],
resourceResolver: ProjectResourceResolver,
})У metadata зберігаються класи політик, а не їхні екземпляри. Це дозволяє отримати політики через контейнер залежностей NestJS і використовувати в них @Injectable() та інші сервіси.
Політика повинна працювати з конкретним ресурсом, а не лише з ідентифікатором із URL.
import { Injectable } from '@nestjs/common';
@Injectable()
export class ProjectService {
async findById(id: string): Promise<Project | null> {
// Тут зазвичай виконується запит до бази даних.
return {
id,
organizationId: 'org-1',
ownerId: 'user-1',
status: 'active',
members: [
{
userId: 'user-2',
role: 'editor',
},
],
};
}
}
@Injectable()
export class ProjectResourceResolver
implements ResourceResolver<Project>
{
constructor(private readonly projectService: ProjectService) {}
async resolve(request: Request): Promise<Project | null> {
const projectId = request.params.projectId;
if (!projectId) {
return null;
}
return this.projectService.findById(projectId);
}
}Ресурс завантажується до виконання методу контролера. Це важливо: якщо ресурс не існує, політика не повинна намагатися перевіряти доступ до undefined.
Guard виконує такі кроки:
читає metadata;
отримує поточного користувача;
завантажує ресурс;
отримує екземпляри політик із контейнера NestJS;
запускає всі політики;
дозволяє запит лише тоді, коли всі перевірки успішні.
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly moduleRef: ModuleRef,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const options =
this.reflector.getAllAndOverride<AuthorizationOptions>(
AUTHORIZATION_METADATA,
[context.getHandler(), context.getClass()],
);
// На маршруті немає політик — guard нічого не перевіряє.
if (!options) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const user = request.user as AuthenticatedUser | undefined;
if (!user) {
throw new UnauthorizedException();
}
const resourceResolver = this.moduleRef.get(
options.resourceResolver,
{ strict: false },
);
const resource = await resourceResolver.resolve(request);
if (!resource) {
throw new NotFoundException('Ресурс не знайдено');
}
const policyContext: PolicyContext = {
user,
resource,
request,
executionContext: context,
};
for (const policyType of options.policies) {
const policy = this.moduleRef.get<Policy>(
policyType,
{ strict: false },
);
const allowed = await policy.handle(policyContext);
if (!allowed) {
throw new ForbiddenException(
'Недостатньо прав для виконання цієї дії',
);
}
}
return true;
}
}getAllAndOverride() спочатку перевіряє metadata методу, а потім metadata класу. Тому політику, визначену на методі, можна використати замість політики, визначеної на контролері.
Якщо потрібно об'єднати metadata класу та методу, замість getAllAndOverride() можна застосувати getAllAndMerge().
Розглянемо політику редагування проєкту:
архівований проєкт не можна редагувати;
адміністратор тієї самої організації має доступ;
власник проєкту має доступ;
учасник із роллю editor має доступ;
користувач з іншої організації не має доступу.
import { Injectable } from '@nestjs/common';
@Injectable()
export class CanEditProjectPolicy
implements Policy<Project>
{
async handle(
context: PolicyContext<Project>,
): Promise<boolean> {
const { user, resource: project } = context;
if (project.status === 'archived') {
return false;
}
if (project.organizationId !== user.organizationId) {
return false;
}
const isOrganizationAdmin =
user.organizationRole === 'admin';
const isOwner = project.ownerId === user.id;
const isEditor = project.members.some(
(member) =>
member.userId === user.id &&
member.role === 'editor',
);
return isOrganizationAdmin || isOwner || isEditor;
}
}У цій політиці умови всередині методу утворюють складне правило:
ресурс активний
AND
організація користувача збігається з організацією ресурсу
AND
(
користувач є адміністратором
OR користувач є власником
OR користувач є редактором
)Політика повертає false, якщо будь-яка обов'язкова умова не виконана.
Для складніших сценаріїв зручніше розділити правило на кілька незалежних політик.
@Injectable()
export class SameOrganizationPolicy
implements Policy<Project>
{
handle(context: PolicyContext<Project>): boolean {
return (
context.user.organizationId ===
context.resource.organizationId
);
}
}
@Injectable()
export class ActiveProjectPolicy
implements Policy<Project>
{
handle(context: PolicyContext<Project>): boolean {
return context.resource.status === 'active';
}
}
@Injectable()
export class ProjectEditorPolicy
implements Policy<Project>
{
handle(context: PolicyContext<Project>): boolean {
const { user, resource } = context;
const isAdmin = user.organizationRole === 'admin';
const isOwner = resource.ownerId === user.id;
const isEditor = resource.members.some(
(member) =>
member.userId === user.id &&
member.role === 'editor',
);
return isAdmin || isOwner || isEditor;
}
}Тепер авторизація описується декларативно:
@Authorize({
policies: [
SameOrganizationPolicy,
ActiveProjectPolicy,
ProjectEditorPolicy,
],
resourceResolver: ProjectResourceResolver,
})Guard виконує політики послідовно через AND:
SameOrganizationPolicy
AND ActiveProjectPolicy
AND ProjectEditorPolicyЦе має кілька переваг:
кожна політика перевіряє одну відповідальність;
політики можна повторно використовувати;
правила простіше тестувати;
контролер не містить деталей авторизації.
Перед PoliciesGuard має виконатися guard автентифікації, який додає користувача до request.user.
import {
Controller,
Patch,
UseGuards,
} from '@nestjs/common';
@Controller('projects')
@UseGuards(AuthGuard, PoliciesGuard)
export class ProjectsController {
@Patch(':projectId')
@Authorize({
policies: [
SameOrganizationPolicy,
ActiveProjectPolicy,
ProjectEditorPolicy,
],
resourceResolver: ProjectResourceResolver,
})
updateProject() {
return {
message: 'Проєкт можна редагувати',
};
}
}Порядок guard має значення:
AuthGuard встановлює request.user;
PoliciesGuard використовує цього користувача;
метод контролера виконується лише після успішної авторизації.
Якщо PoliciesGuard стане глобальним, маршрути без @Authorize() автоматично пропускатимуться ним. Для відкритих маршрутів це зазвичай очікувана поведінка, але її потрібно врахувати під час проєктування.
Усі політики та resolver повинні бути зареєстровані в модулі.
import { Module } from '@nestjs/common';
@Module({
controllers: [ProjectsController],
providers: [
AuthGuard,
PoliciesGuard,
ProjectService,
ProjectResourceResolver,
SameOrganizationPolicy,
ActiveProjectPolicy,
ProjectEditorPolicy,
],
})
export class ProjectsModule {}Якщо політика або resolver мають залежності, NestJS автоматично передасть їх через конструктор:
@Injectable()
export class CanDeleteProjectPolicy
implements Policy<Project>
{
constructor(
private readonly auditService: AuditService,
) {}
async handle(
context: PolicyContext<Project>,
): Promise<boolean> {
const { user, resource } = context;
const canDelete =
resource.ownerId === user.id &&
resource.status === 'active';
if (canDelete) {
await this.auditService.record({
userId: user.id,
action: 'project.delete.check',
resourceId: resource.id,
});
}
return canDelete;
}
}Такі залежності неможливо коректно передати, якщо створювати політику вручну через new CanDeleteProjectPolicy().
Політика може виконувати асинхронні операції:
перевіряти дозвіл у базі даних;
отримувати членство в організації;
перевіряти ліцензію або тариф;
звертатися до сервісу дозволів.
@Injectable()
export class HasProjectPermissionPolicy
implements Policy<Project>
{
constructor(
private readonly permissionService: PermissionService,
) {}
async handle(
context: PolicyContext<Project>,
): Promise<boolean> {
return this.permissionService.hasPermission({
userId: context.user.id,
resourceType: 'project',
resourceId: context.resource.id,
action: 'update',
});
}
}Guard очікує результат через await, тому синхронні та асинхронні політики можуть використовувати один інтерфейс.
Не варто виконувати довгі або повторювані запити в кожній політиці. Якщо кілька політик використовують той самий ресурс або пов'язані дані, краще завантажити їх один раз у resolver або сервісі.
404 і 403Під час перевірки ресурсу можливі два різні випадки:
ресурс не існує — 404 Not Found;
ресурс існує, але користувач не має доступу — 403 Forbidden.
У прикладі resolver повертає null, а guard генерує NotFoundException.
const resource = await resourceResolver.resolve(request);
if (!resource) {
throw new NotFoundException('Ресурс не знайдено');
}Це зручно для звичайних внутрішніх API. У деяких системах навмисно повертають 404 і для заборонених ресурсів, щоб не розкривати факт їхнього існування. Така політика має бути єдиною для всього API, а не випадковою поведінкою окремих контролерів.
Політики слід тестувати окремо від guard і контролера. Для цього достатньо створити об'єкти користувача та ресурсу.
describe('CanEditProjectPolicy', () => {
const policy = new CanEditProjectPolicy();
const project: Project = {
id: 'project-1',
organizationId: 'org-1',
ownerId: 'owner-1',
status: 'active',
members: [
{
userId: 'editor-1',
role: 'editor',
},
],
};
it('дозволяє власнику редагувати активний проєкт', async () => {
const result = await policy.handle({
user: {
id: 'owner-1',
organizationId: 'org-1',
organizationRole: 'member',
},
resource: project,
request: {} as Request,
executionContext: {} as ExecutionContext,
});
expect(result).toBe(true);
});
it('забороняє користувачу з іншої організації', async () => {
const result = await policy.handle({
user: {
id: 'owner-1',
organizationId: 'org-2',
organizationRole: 'admin',
},
resource: project,
request: {} as Request,
executionContext: {} as ExecutionContext,
});
expect(result).toBe(false);
});
it('забороняє редагування архівованого проєкту', async () => {
const archivedProject = {
...project,
status: 'archived' as const,
};
const result = await policy.handle({
user: {
id: 'owner-1',
organizationId: 'org-1',
organizationRole: 'member',
},
resource: archivedProject,
request: {} as Request,
executionContext: {} as ExecutionContext,
});
expect(result).toBe(false);
});
});Тест політики не повинен запускати NestJS або створювати HTTP-запит. Він перевіряє саме бізнес-правило.
Окремо можна протестувати PoliciesGuard:
metadata містить потрібні політики;
resolver викликається;
відсутній користувач дає 401;
відсутній ресурс дає 404;
відмова політики дає 403;
усі успішні політики дозволяють запит.
@Patch(':projectId')
updateProject(@Req() request: Request) {
if (request.user.id !== request.body.ownerId) {
throw new ForbiddenException();
}
// Логіка контролера
}Такий код швидко дублюється в інших методах і змішує авторизацію з обробкою запиту. Перевірку краще винести в політику.
newconst policy = new CanDeleteProjectPolicy();У цьому випадку NestJS не зможе передати залежності політики. Для політик, які використовують сервіси, застосовуйте ModuleRef або інший механізм DI NestJS.
Не слід визначати ресурс для авторизації з request.body.ownerId. Користувач може змінити це поле.
Ресурс потрібно завантажувати за ідентифікатором маршруту або іншим контрольованим параметром, а його власника та організацію брати з бази даних.
Роль admin сама по собі не завжди означає доступ до будь-якого ресурсу. Потрібно враховувати область дії ролі:
організацію;
команду;
проєкт;
середовище.
Тому політика повинна порівнювати дані користувача з даними ресурсу.
Після першої невдалої політики guard має припинити перевірку. Це:
не виконує зайві запити;
не розкриває додаткову інформацію;
спрощує прогнозування поведінки.
Політики повинні повертати єдиний зрозумілий результат: boolean або Promise<boolean>. Не варто повертати з одних політик Response, з інших — рядки помилок, а з третіх — винятки.
Винятки HTTP-рівня краще централізовано обробляти в guard.
Політика інкапсулює одне складне правило авторизації.
Контекст політики може містити користувача, ресурс і HTTP-запит.
Resolver завантажує ресурс до виконання методу контролера.
PoliciesGuard отримує політики через контейнер залежностей NestJS.
Кілька політик у декораторі можна об'єднати через AND.
Умови OR зручно описувати всередині однієї спеціалізованої політики.
Політики мають бути незалежними від контролерів і HTTP-відповідей.
Найкраще тестувати політики окремо як звичайні класи з бізнес-правилами.