توی این مطلب میخوام درمورد پیاده سازی یک Identity and Access Proxy صحبت کنم. که برای مطابقت دادن الگوی یک مسیر؛ از ساختمان داده Trie استفاده میکنه. و برای mutate کردن، از مفهوم pipeline. که بر اساس الگوی Chain-of-responsibility پیاده سازی شده. بهتره که با تعریف چندتا مفهوم شروع کنم.
فرض رو بر این بگیرید که سرویسما قراره به زبان جاوا اسکریپت نوشته بشه؛ من مثالهای این مطلب رو با تایپ اسکریپت نوشتم که درک موضوع با خوندن کد همراه باشه.
اجازه بدید اینطوری توضیح بدم:
فرض کنید یه همچین درخواستی اومده سمت سرور شما
GET /my-service/whatever HTTP/1.1 Authorization: bearer some-token
چه خوب میشه اگه بتونید دسترسی به این مسیر رو بر اساس توکنی که دریافت شده اعتبار سنجی کنید نه؟ مثلا اگه توکن برای کاربر admin نبود دسترسی مجاز نیست.
یا فرض کنید آدرس صفحه پرداخت باید فقط برای کاربران ایرانی نمایش داده بشه:
POST /my-service/payment HTTP/1.1 X-Forwarded-For: 83.123.255.56
اگه IP ایران نداشتن بهشون یک پیغام نشون بده که جلوتر نرن.
درواقع همه این مثالها نیازمند داشتن یک نقطه اجرای سیاست (Policy Enforcement Point) هست. یعنی یک سرویس که به عنوان نقطه ورود معرفی میشه (البته بعد از لود بالانسر و reverse proxy) و هدفش اینه که مطمین بشه آدرسی که ازش بازدید میشه توسط اون کاربر مجاز هست. و یا یک سری پیشفرض درمورد کاربر استخراج کنه و اونها رو به درخواستش اضافه کنه (Mutation) که سرویسهایی که در باقی مسیر قرار دارن با توجه به اون اطلاعات فرایند بعدی رو انجام بدن.
مثلا؛ این سرویس میتونه اطلاعات کاربر رو از دیتابیس استخراج کنه و باقی سرویسها در ادامه مسیر نیازی به استخراج مجدد ندارن.
چنین ابزاری معمولا درخواست رو از یک reverse proxy دریافت میکنه مثلا اگر از NGINX استفاده میکنید وقتی که درخواست بهش برسه، به این طریق برای سرویس شما ارسال میکنه. یا اگه مثل من از Traefik استفاده میکنید باید از Forward Authentication Middleware استفاده کنید.
یعنی درواقع یک درخواست به این سرویس Forward میشه. اگر از سمت سرویس IaP نتیجه 200 برگشت داده بشه اون درخواست به مقصد بعدی منتقل میشه. و اگر هر چیزی بجز 200 باشه یعنی خطایی رخ داده و نتیجه رو به کاربر برگشت میده.
سرویس IaP معمولا نتیجه پردازشهای خودش رو به صورت یک یا چند هدر به درخواستی که دریافت کرده اضافه میکنه.
وقتی که از Reverse Proxy استفاده میکنیم. یک سری context از بین میره ... مثلا آدرسی که به دست ما میرسه آدرس اون برنامه هست نه کاربری که درخواست رو زده. برای حل این موضوع rfc7239 رو تعریف کردن. یعنی درواقع یک سری هدر به درخواستها اضافه میشه توسط ابزارهایی مثل NGINX و Traefik که با X-Forwarded شروع میشه. که X یعنی این یک هدر کاستوم هست که جز استاندارد HTTP نیست. و Forwarded بعدش هم یعنی اینکه این هدر از خانواده Forwarded HTTP Extension هست.
با استفاده از Traefik این هدرها رو دریافت میکنیم:
X-Forwarded-Proto X-Forwarded-Host X-Forwarded-Uri X-Forwarded-For X-Forwarded-Method
سرویس IaP معمولا از سه مورد آخر استفاده میکنه. که بفهمه مسیر کجاست؛ متدی که درخواست زدن چیه و Source IP متعلق به چه آدرسی هست.
حالا برای حل کردن فرضیات بالا چه کدی مینویسید؟
const uri = req.headers["x-forwarded-for"] const method = req.headers["x-forwarded-method"] if (uri === "/my-service/whatever" && method === "GET") { doStuff() }
هووووف؛ اگه به همین سادگی بود! نه!. آدرسها قسمتهای مختلف دارن... اگر یه قسمت از آدرس داینامیک بود چطوری میخوای تشخیصش بدی؟ مثلا اگه UUID بود:
GET /my-service/posts/69df78f4-f675-4838-8d69-664c56ed8543
خب کاری نداره که!.. از regular expression استفاده میکنیم!... بازم نهههه!. البته میدونم بعضی از پیاده سازیها ممکنه یک Regex Engine داشته باشن اما این نصیحت رو از من بپذیرید ... «همیشه عبارات با قاعده رو به عنوان آخرین راه حل استفاده کنید».
درسته که ما داریم string matching انجام میدیم. اما دنبال «یک جواب مشخص» هستیم. معمولا موتوری که عبارات با قاعده رو تفسیر میکنه «به هر دری میزنه تا به جواب برسه» یعنی مسیرهای زیادی برای اجرای یک عبارت باقاعده وجود داره. بعلاوه آدمها میتونن با عبارات باقاعده کرم بریزن. در نتیجه پردازش کردن regex نیازمند مفسر خودش هست. حتی بر اساس پیچیدگی بعضی از عبارات؛ ممکنه توسط JIT-less V8 تفسیر بشن که این جهش بین دنیای ++c و جاوا اسکریپت هزینه داره.
اگه رشتههایی که پردازش میکنید پیچیده هستن و راههای دیگه مثل Trie یا Bloom filter کافی نیست. اونوقت بهتره که از یک Regex Engine استفاده کنید.
به کاری که سرویس IaP در این مرحله انجام میده Access Rule Matching گفته میشه. یعنی مطابقت دادن قسمتهای یک آدرس با یک رشته از پیش تعریف شده. برای انجام این عملیات باید یک موتور تصمیمگیری نوشته بشه. یعنی وقتی که این آدرس دریافت میشه:
GET /my-service/posts/69df78f4-f675-4838-8d69-664c56ed8543 pattern: get*my-service*posts*{uuid}
الگویی که با اون مطابقت داره رو پیدا میکنه. به قسمت آخر نگاه کنید که نوشته uuid این یک شرط هست. وظیفه Decision Engine اینه که قسمتهای داینامیک یک الگو، مثل شرطها رو به یک عبارت تفسیر کنه که نتیجه اش True میشه.
حالا بذارید اینطوری بهش نگاه کنیم؛ همه قسمتهای این مسیر استاتیک هست بجز تیکه آخر. میتونیم این قسمتهای داینامیک رو جدا کنیم و براشون یک تابع بنویسیم. مگر یک آدرس چقدر مقدار داینامیک توش استفاده میشه؟ معمولا کمه. حتی اگه عبارت خیلی پیچیده باشه؛ میتونیم یک تابع تعریف کنیم که یک عبارت regex رو اجرا میکنه مزیتش اینه که عبارتمون نیاز نداره تمام قسمتهای استاتیک رو شامل بشه.
درواقع به ازای هر مفهوم داینامیک یک تابع تعریف میکنیم بعد مقدار داینامیک رو برای اون توابع میفرستیم. جواب که True شد یعنی الگوی مطابق این آدرس پیدا شده و اون مسیر مجاز هست.
حالا به این مثال توجه کنید:
GET /my-service/posts/all GET /my-service/posts/69df78f4-f675-4838-8d69-664c56ed8543 GET /my-service/posts/69df78f4-f675-4838-8d69-664c56ed8543/comments DELETE /my-service/posts/69df78f4-f675-4838-8d69-664c56ed8543 POST /my-service/posts/create
نقطه مشترک قضیه چیه؟ GET /my-service/posts داره تکرار میشه. درواقع اگر توی سرویسهاتون از REST پیروی میکنید؛ تعداد قسمتهای تکراری توی یک آدرس زیاده. این دردسر ساز تره تا قسمتهای داینامیک. ما نیاز داریم این رشته رو توی حافظه به شکلی ذخیره کنیم که با تغییر دادن یک قسمت؛ مفسر اون رو به عنوان یک رشته جدید در نظر نگیره. که تصمیم نگیره دوباره براش حافظه بسازه.
چرا؟ مگه حافظه ارث بابامونه؟ نه!. چون طول رشته ثابت نیست پس روی Heap ذخیره میشه. اگر کاری کنیم که طولش ثابت به نظر بیاد؛ دسترسی به Heap یک الگوی قابل تکرار پیدا میکنه. و هرچقدر درسترسی به اون قسمت از حافظه بیشتر اتفاق بیوفته داغ تر میشه. در نتیجه توسط runtime کش میشه.
ترای؛ یک ساختمان داده هست که معمولا برای پیشبینی متن یا اعتبارسنجی یک رشته ازش استفاده میکنن. این ویدیو رو نگاه کنید.
معمولا؛ انبار این ساختمان داده یک Hash table هست که با دادههایی که قراره جستجو بشه پر میشه. یعنی ما همیشه برای رسیدن به جواب «یک راه مشخص» داریم. (درحالی که تفسیر و پیمایش عبارات باقاعده هم slow path داره هم fast path).
کاراکتر C یک کلید هست توی Hash table در نتیجه پیچیدگی زمانی برای دسترسی به C معادل O(1) هست و در بدترین حالت O(n) (بستگی داره اون Hash function که استفاده شده چقدر خوب عمل میکنه). همچنین پیمایش Trie همیشه O(m) هست. به طوری که M تعداد Nodeهای پیمایش شده برای رسیدن به نتیجه هست. مثلا برای جستجوی CAR مقدار M میشه 3.
مهمتر از همه؛ پیمایش فقط وقتی ادامه پیدا میکنه که Node پیدا شده. یعنی اگر CARD READER رو جستجو کنید پیمایش فقط تا CARD ادامه پیدا میکنه چون توی لیستی که بهش دادیم نیست. این خیلی برای یک IaP مهمه. چرا؟ چون خزندهها اینترنت رو تسخیر کردن! اگه یک خزنده سعی داشته باشه آدرسهای مختلف رو امتحان کنه پیمایش اون به محض رسیدن به اولین گرهای که وجود نداره متوقف میشه.
و با اعمال rate limiting بر اساس دفعاتی که 404 اتفاق افتاده، حتی میتونید کسانی که با Residential IP کرم میریزند رو بعنوان خزنده درنظر بگیرید. (مگر درخواستهای واقعی از یک IP در یک بازه زمانی کوتاه؛ چندبار به 404 میخوره؟ یک بار؟ دو بار؟ ده بار؟ ... بیست بار که دیگه عادی نیست).
به عبارات CAr, CAt, CArd دقت کنید ... یعنی ما میتونیم C رو یک گره فرض کنیم که فرزندش میشه A و فرزندان A میشن R,T و در نهایت D فرزند R در نظر گرفته میشه. در نتیجه: الگوی پیمایش قابل تکرار هست.
اما هنوزم یک مشکل اساسی داریم ... UUID که یک عبارت ثابت نیست. یعنی:
get*my-service*posts*{uuid}
رو چطوری توی یک Trie ذخیره کنیم؟ - توی جاوا اسکریپت چی با Hash table پیاده شده؟ Map - خب ما میتونیم رشته "{uuid}" رو بعنوان یک کلید ذخیره کنیم. مقدار هر کلید میتونه یک Object باشه که یک پراپرتی داره تحت عنوان isCondition.
خب موقعی که داریم انبار رو پر میکنیم؛ اول روی کاراکتر ستاره split انجام میدیم. بعد روی {} یک split دیگه انجام میدیم:
const condition = segment.split("{").pop()?.split("}")[0] // {uuid} -> uuid
اگر حاصل این عبارت نتیجه داشت؛ پس این گره بعنوان شرط ثبت میشه.
اما یک مشکل دیگه داریم. وقتی که یک گره پیدا نشه پیمایش متوقف میشه. پس چطوری {uuid} رو پیمایش کنیم؟ - کافیه از آخرین گره پیمایش شده بخوایم که گره بعدی خودش رو برگشت بده. بعد میتونیم پراپرتی isCondition رو چک کنیم. یادتونه گفتم به ازای هر قسمت داینامیک یه فانکشن داریم؟ خب میتونیم به این صورت اونها رو تعریف کنیم.
const conditionMap = { uuid: (s) => s.length >= 21 && s.length <= 36, } conditionMap[condition]() // true || false
قدم بعدی در سرویس IaP اعمال پردازش روی درخواستها و ایجاد تغییر روی اونهاست. البته معمولا یک مرحله دیگه قبل از mutation اتفاق میافته. که Authorization هست اما؛ اگر قراره نتیجه اون عملیات هم روی درخواست تغییر ایجاد کنه میتونه یک mutation به حساب بیاد.
خب، ما قراره یک سری داده از Request بخونیم و یک سری هدر به Response اضافه کنیم البته تغییرات میتونه هرکجای Response اتفاق بیوفته اما عموما توی هدر عاقلانه تره.
آیا میتونیم از این فرایند یک الگو درست کنیم؟ بله.
type IAPPipeline = (req: Request, res: Response) => Promise<void>;
درواقع پایپلاین در اینجا یک تابع انتخابی هست که قراره req رو دریافت کنه و روی res تغییر ایجاد کنه. اما این تعریف تکمیل نیست اگر ما دوتا تابع داشته باشیم باید همون req, res که به تابع اول رسیده به تابع دوم هم پاس داده بشه. اونوقت تبدیل میشه به یک pipeline.
حالا یه مشکل دیگه داریم. اگر A خطا بده چه تضمینی هست که B میتونه به کارش ادامه بده یا نه؟ درواقع؛ بهتره که ما یک مدل اجرا داشته باشیم. اینجاست که میتونیم از Chain-of-responsibility استفاده کنیم.
interface ExecutionUnit { next: ExecutionUnit | null; pipeline: IAPPipeline; setNext(next: ExecutionUnit): void; exec(req: Request, res: Response): Promise<void | never>; }
هر pipeline، باید تبدیل بشه یک «واحد اجرا». هر واحد اجرا دو ویژگی داره:
بعنوان مثال حاصل اجرا شدن یک Pipeline که واحد اجرای اون از نوع CanFail هست منجر به متوقف شدن زنجیر نمیشه بلکه خطاها رو لاگ میکنه و به اجرای واحد بعدی ادامه میده.
درعین حال میتونیم یک تعریف CanPanic هم داشته باشیم که خطاها رو catch نمیکنه. در نتیجه کدی که این چرخه رو اجرا کرده باید خطا رو مدیریت کنه؛ یا مثلا آخرین Middleware میتونه همه خطاها رو مدیریت کنه. و مثلا HTTP 500 برگشت بده.
و حالا باید یک Executor داشته باشیم که یک لیست از Execution Unit رو دریافت میکنه، و به همدیگه لینک میکنه. بعدش اولین عضو توی زنجیر رو اجرا میکنیم. و به ترتیب هر واحد اجرا، واحد بعدی خودش رو فراخوانی میکنه. و اگر این وسط یکی از واحدها CanPanic باشه اجرا متوقف میشه. درغیر این صورت Mutationها به ترتیبی که توی آرایه تعریف شدن اتفاق میوفتن.
قطعا پیاده سازی IaP به این موارد ختم نمیشه و این رشته سر درازی داره. بعنوان مثال اگر آدرس شامل query string بود چطوری تفسیرش کنیم؟
در ابتدا قصد داشتم کدهای مرتبط با این مفاهیم رو توی همین مطلب درج کنم. اما ویرایشگر ویرگول سوهان روح است. برای همین بزودی یک Proof of concept روی حساب گیتهاب خودم منتشر میکنم. شامل تنظیمات Traefik و یک پروژه express که همه این مفاهیم توی اون پیاده سازی شده و میتونید به صورت داکرایز شده اجرا کنید. هرموقع که منتشر بشه از طریق توییتر اطلاع رسانی میکنم.
امیدوارم از مطالعه این مطلب لذت برده باشید. همونطور که من از نوشتنش لذت بردم ?