اگر در حال توسعه یک اپلیکیشن جدی با NestJS هستید، احتمالاً با Decorator آشنای @Roles('admin') شروع کرده اید. این روش ساده و قشنگ است؛ اما فقط تا وقتی خوب است که پروژه تان کوچک باشد و تعداد نقش ها به چندتا محدود شود. بیایید صادق باشیم: در دنیای واقعی، امنیت به این سادگی نیست و «نقش ها» به تنهایی قادر به بیان پیچیدگی های دسترسی کاربران نیستند.
جایی که RBAC میشکند:
چند سناریوی واقعی را در نظر بگیرید:
کاربر باید بتواند پست خودش را ویرایش کند، اما نه پست دیگران را.
مدیر بخش باید بتواند مقالات «پیش نویس» را ببیند، اما نه مقالات «منتشرشده» را.
کاربر VIP باید به بخش نظرات دسترسی داشته باشد، اما فقط به شرطی که ظرف سی روز گذشته بیش از یک «اِخطار» دریافت نکرده باشد.
یک نفر باید بتواند فایلی را در پروژه اش حذف کند، اما دقیقاً تا ۲۴ ساعت پس از انتشارش، نه بیشتر و نه کمتر.
در تمام این مثالها، داشتن «نقش» کافی نیست؛ آنچه تعیین کننده است، ویژگی ها (Attributes) هستند: مالکیت، وضعیت، زمان، تعداد خطاها و… .
اینجاست که RBAC (Role-Based Access Control) شکست میخورد و پای ABAC (Attribute-Based Access Control) به میان میآید. و ابزار محبوب ما برای پیاده سازی این الگو در اکوسیستم NestJS، نبودِ کتابخانه ای بهتر از CASL است.
چرا CASL؟ (و نه یک Guard سفارشی دیگر):
خیلی از تیم ها به جای این کار، یک Guard مینویسند که داخلش صد خط if/else هست و مستقیماً با دیتابیس و Doctrine درگیر میشود. مشکل اینجاست که:
منطق امنیت در گوشه وکنار Controllerها و Serviceها پخش میشود.
تغییر یک قانون یعنی دست زدن به کدهایی که ربطی به امنیت ندارند.
تست کردن همه ی این شاخه های شرطی، یک معجزه ی واقعی است.
CASL این مسائل را حل میکند. فلسفه ی آن ساده اما قدرتمند است: شما اجازه ها را به شکل «جمله» تعریف میکنید؛ همانطور که یک انسان اجازهاش را بیان میکند:
این یعنی منطق امنیت از بدنه ی بیزینس جدا میشود و در یک نقطه ی واحد، متمرکز و قابل آزمایش قرار میگیرد.
قدم اول: تعریف توانمندی ها (Ability Factory):
ابتدا باید مشخص کنیم هر موجودی (کاربر، سیستم، سرویس) چه کارهایی میتواند انجام دهد. یک AbilityFactory میسازیم؛ اینجا قلب تمام قوانین امنیتی ماست:
// ability.factory.ts import { Ability, AbilityBuilder, AbilityClass, ExtractSubjectType, InferSubjects } from '@casl/ability'; import { Injectable } from '@nestjs/common'; import { Action } from './action.enum'; import { Article } from '../articles/article.entity'; import { User } from './user.entity'; type Subjects = InferSubjects<typeof Article | typeof User> | 'all'; export type AppAbility = Ability<[Action, Subjects]>; @Injectable() export class AbilityFactory { defineAbility(user: User): AppAbility { const { can, cannot, build } = new AbilityBuilder<AppAbility>(Ability as AbilityClass<AppAbility>); if (user.isAdmin) { // ادمین همهکاره است، اما حتی او هم نمیتواند مقاله منتشرشده را پاک کند! can(Action.Manage, 'all'); cannot(Action.Delete, Article, { isPublished: true }); } else { // همهی کاربران عادی میتوانند بخوانند... can(Action.Read, 'all'); // ...مقالات خودشان را ویرایش کنند... can(Action.Update, Article, { authorId: user.id }); // ...و فقط پیشنویسهای خودشان را حذف کنند. can(Action.Delete, Article, { authorId: user.id, isPublished: false }); // و یک قانون ظریف: اگر کاربر آفلاین است، هیچ قابلیتی ندارد. if (user.status === 'suspended') { cannot(Action.Manage, 'all'); } } return build(); } }
دقت کنید: قوانین فقط «چه کسی چه کاری میتواند» را مشخص میکنند و هیچ ربطی به نحوه ی اجرای کار در دیتابیس ندارند. این یعنی در تست، میتوانید بدون هیچ دیتابیسی، تمام این قوانین را صرفاً با یک شیء User ساده بررسی کنید.
قدم دوم: Guard جادویی (The Shield):
حالا به جای اینکه در هر Controller کلی if/else بنویسیم، یک Guard میسازیم که از Metadatas استفاده میکند و قوانین را از Decorators میخواند:
// abilities.guard.ts import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AppAbility, AbilityFactory } from './ability.factory'; export const CHECK_ABILITY = 'check_ability'; export interface RequiredRule { action: string; subject: any; } @Injectable() export class AbilitiesGuard implements CanActivate { constructor( private reflector: Reflector, private abilityFactory: AbilityFactory, ) {} async canActivate(context: ExecutionContext): Promise<boolean> { const rules = this.reflector.getAllAndOverride<RequiredRule[]>(CHECK_ABILITY, [ context.getHandler(), context.getClass(), ]); if (!rules?.length) { return true; // بدون قانون، همهچیز آزاد (یا برای مسیرهای عمومی) } const { user } = context.switchToHttp().getRequest(); const ability = this.abilityFactory.defineAbility(user); const allowed = rules.every((rule) => ability.can(rule.action, rule.subject)); if (!allowed) { throw new ForbiddenException('شما مجاز به انجام این عملیات نیستید.'); } return allowed; } }
برای اینکه آرگومانها بتوانند dynamic باشند (مثلاً subject واقعی همان Recordای که کاربر میخواهد ویرایش کند)، میتوانید subject را از Req یا Param بخوانید و به CASL بدهید تا کل شرطها را روی آن اعمال کند – این همان کاری است که در اصلاحات انجام میشود و CASL به صورت درونی handle میکند.
قدم سوم: Decorator تمیز و استفاده در Controller
یک Decorator ساده میسازیم تا کد Controller شبیه یک شعر شود:
// permissions.decorator.ts import { SetMetadata } from '@nestjs/common'; export const CheckAbilities = (...rules: RequiredRule[]) => SetMetadata(CHECK_ABILITY, rules);
و استفادهی نهایی، تمیز و باکلاس:
// articles.controller.ts @UseGuards(JwtAuthGuard, AbilitiesGuard) @Controller('articles') export class ArticlesController { @Patch(':id') @CheckAbilities({ action: Action.Update, subject: Article }) async update(@Param('id') id: string) { // اگر کاربر اجازه نداشته باشد، قبل از رسیدن به اینجا 403 میگیرد. return this.articleService.update(id); } @Delete(':id') @CheckAbilities({ action: Action.Delete, subject: Article }) async remove(@Param('id') id: string) { return this.articleService.remove(id); } }
نکته ی طلایی: برای قوانین پویا (مثل «فقط مدرک خودش»)، subject را با داده ی به روز از دیتابیس به Guard تزریق کنید؛ اینطوری CASL همه ی شرط ها را روی همان شیء اجرا میکند و امنیت واقعاً دقیق میشود.
چرا این روش «خفن» است؟
۱.امنیت متمرکز: تمام قوانین دسترسی شما در یک فایل (AbilityFactory) جمع است، نه پخش شده در کل پروژه. هر تغییر، یک جا و قابل ردیابی.
۲. تست پذیری بالا: به راحتی میتوانید Unit Test بنویسید که فقط یک کاربر ساختگی و قوانین را چک کند، بدون اینکه با HTTP، دیتابیس یا Redis درگیر شوید.
۳. توسعه پذیری افسانه ای: اضافه کردن یک شرط جدید (مثل محدودیت زمانی ویرایش یا شرط تعداد اخطار) تنها چند خط در AbilityFactory است و نیازی به دستزدن به Controller یا Service نیست.
۴. حذف مسیرهای خطرناک: دیگر ناگهان یک گارد فراموش شده یا یک if/else گمشده در میان کدها وجود ندارد؛ همه چیز تحت پوشش یک سیستم واحد است.
من مهدی قوسیان هستم و خوشحال میشم نظراتتون بنویسین این پست رو به دوستاتون ارسال کنین.