TenantForge — اسکلت یک بک‌اند SaaS چندمستأجری

جداسازی مستأجرها را به‌جای «یادت باشد WHERE بگذاری» به Row-Level Security خودِ پستگرس می‌سپارد — به‌علاوه‌ی توکن‌های چرخشی، RBAC و ردِ ممیزی.

نتیجه

یک ادعا، و یک مجموعه تست که کارش شکستن همان ادعاست: هر درخواست فقط همان کارگاهی را می‌بیند که توکنش نام برده — و این را پستگرس تضمین می‌کند، نه کد.

مسئله

در یک بک‌اند چندمستأجری، جواب همیشگی این است که «همیشه بر اساس tenant_id فیلتر کن» — و این جواب فقط یک WHERE فراموش‌شده با یک نشت داده فاصله دارد: در یک endpoint تازه، در یک JOIN که با عجله نوشته شده، در یک COUNT(*) برای داشبورد. تکیه‌اش به این است که همه‌ی توسعه‌دهنده‌ها، تا ابد، یادشان بماند؛ و باز خراب می‌شود: نشانه‌ی باگ، دادهٔ بیشتر است نه کمتر. پس چیزی crash نمی‌کند و هیچ تستی قرمز نمی‌شود، مگر کسی دقیقاً همان تست را نوشته باشد.

یک دیتابیس، یک اسکیما، یک ستون tenant_id روی هر جدولِ متعلق به مستأجر، و Row-Level Security پستگرس که آن را تحمیل می‌کند. تنها ادعای این مخزن این است که هر درخواست فقط همان کارگاهی را می‌بیند که توکنش نام برده — و مجموعه تست‌ها برای شکستن همین ادعا نوشته شده‌اند.

سه چیز RLS را واقعی می‌کند، نه تزئینی. اپلیکیشن با نقشی وصل می‌شود که نمی‌تواند از policy فرار کند — اولین migration آن نقش را با NOSUPERUSER و NOBYPASSRLS می‌سازد، چون «ما RLS داریم» تا وقتی نتوانی بگویی با چه نقشی وصل می‌شوی هیچ ارزشی ندارد. همه‌ی policyها FORCE شده‌اند، چون بدون آن مالکِ جدول از policyهای خودش معاف است — و مالک دقیقاً همان نقشی است که migration و seed با آن اجرا می‌شوند. مستأجر یک تنظیم محدود به تراکنش است، نه یک پارامتر کوئری: وابستگیِ درخواست set_config('app.current_tenant', …, true) را اجرا می‌کند، پس مقدار با تراکنش می‌میرد و روی برداشت بعدی از connection pool نشت نمی‌کند.

هر policy هر دو نیمه را دارد. USING دلیل این است که یک شناسه از کارگاه دیگر به هیچ نمی‌رسد؛ WITH CHECK دلیل این است که سرویسی که tenant_id را اشتباه حساب کرده INSERTاش رد می‌شود، نه اینکه بی‌صدا یک سطرِ نشتی ذخیره کند. و بسته خراب می‌شود: اگر تنظیم ست نشده باشد، گزاره NULL است و نشستِ نامقید صفر سطر می‌خواند. حالت پیش‌فرضِ نشستی که یادش رفته خودش را معرفی کند، کوری است.

«یک دیتابیس به‌ازای هر مستأجر» و «یک اسکیما به‌ازای هر مستأجر» هر دو بررسی شده‌اند و در README با دلیل رد شده‌اند، نه با دستِ رد: N بار اجرای migration در هر دیپلوی، داستانِ شکستِ نیمه‌کاره، و search_path به‌عنوان حالت سراسریِ باربر. هزینه‌ی واقعیِ راه انتخاب‌شده هم نوشته شده: policyها در کد پایتون دیده نمی‌شوند، پس تازه‌وارد یک متد repository بدون فیلتر مستأجر می‌بیند و باید بداند که دیتابیس دارد یکی اضافه می‌کند. برای همین تست‌ها مستقیم سراغ attributeهای نقش و pg_class و pg_policies می‌روند.

بقیه‌اش کار امنیتیِ معمولی است که درست انجام شده. Argon2id با پروفایل OWASP و rehash هنگام ورود. توکن refresh جِی‌دابلیوتی نیست — رشته‌ی تصادفیِ مبهمی است که به‌شکل چکیده‌ی HMAC کلیددار ذخیره می‌شود، پس جدولِ نشت‌کرده هیچ اعتبارنامه‌ی قابل استفاده‌ای نمی‌دهد؛ و چرخش، استفاده‌ی دوباره را می‌گیرد: ارائه‌ی توکنِ مصرف‌شده یعنی نسخه‌ای فرار کرده، پس کل خانواده باطل می‌شود و صاحب واقعی متوجه می‌شود، به‌جای اینکه بی‌صدا نشستش را با یک دزد شریک شود. توکن دسترسی بی‌حالت می‌ماند و در عین حال با claimِ token_version قابل ابطال است. کلیدها UUID هستند، پیام خطای ورود برای همه‌ی علت‌ها یکی است (به‌علاوه‌ی یک verify ساختگی در مسیر کاربرِ ناشناس تا جواب از راه تأخیر لو نرود)، و برای دادهٔ کارگاهِ دیگری پاسخ ۴۰۴ است نه ۴۰۳ — چون ۴۰۳ دقیقاً همان سؤالی را جواب می‌دهد که یک شمارشگر می‌پرسد.