هندسة
معمارية SaaS متعددة المستأجرين: أين تضع حدّ المستأجر؟
By Clasify Team · · 5 min read
كل منصة SaaS متعددة المستأجرين تبدأ بالخطة نفسها: أضف عمود companyId إلى الجداول التي تحتاجه، وتذكّر أن تُرشِّح به. تظل الخطة صالحة إلى أن ينسى أحدهم — والنتيجة ليست تعطُّلًا. النتيجة أن عميلًا يقرأ بيانات عميل آخر بهدوء، في استعلام يعود بالحالة 200 ويبدو طبيعيًا تمامًا في السجلّات.
نحن نُشغّل Clasify، وهي منصة white-label كل أكاديمية فيها مستأجر منفصل بهويته البصرية وبياناته. هذه هي الطريقة التي رسمنا بها حدّ المستأجر، والخطأ الذي وقعنا فيه، والاختبار الذي يمنع تكراره الآن.
"تذكَّر شرط WHERE" ليست معمارية
الترشيح اليدوي يفشل فشلًا مفتوحًا. غياب شرط companyId لا يُطلق خطأً، بل يُوسِّع مجموعة النتائج فحسب. ومراجعة الكود لا تلتقطه بشكل موثوق، لأن الاستعلام الخاطئ والصحيح يفترقان بشرط واحد يسهل جدًا تجاوزه بالقراءة.
فالسؤال المفيد ليس "كيف نتذكّر؟" بل "أين نضع الحدّ حتى يستحيل النسيان؟"
هناك ثلاث إجابات معتادة: قاعدة بيانات لكل مستأجر، أو schema لكل مستأجر، أو schema واحدة مشتركة بعمود تمييز. اخترنا الثالثة — ثم نقلنا التنفيذ خارج كود التطبيق بالكامل.
الحدّ يسكن في طبقة البيانات، لا في المسارات
قطعتان تقومان بالعمل.
الأولى سياق مرتبط بالطلب، مبني على AsyncLocalStorage في Node. يفتح middleware مخزنًا يعيش طوال عمر كل طلب، ثم تملأ طبقتا المستأجر والمصادقة معرّف الشركة عند التعرّف على هوية المُستدعي. ولأن المخزن async-local، يستطيع أي تابع داخل الطلب — مهما بَعُد في سلسلة الاستدعاء — قراءة المستأجر الحالي دون تمريره كوسيط.
الثانية امتداد لعميل Prisma يلتفّ حول كل استعلام. قبل أن تصل القراءة إلى قاعدة البيانات، يفحص الموديل مقابل قائمة الموديلات المملوكة للمستأجرين، وعند التطابق يحقن معرّف المستأجر الحالي في شرط where.
والنتيجة أن المسار الذي ينسى الترشيح يُصدر استعلامًا مُرشَّحًا رغم ذلك. طبقة البيانات تتوقف عن الوثوق بمن يستدعيها.
الفشل مُغلقًا، في كل البيئات
القرار اللافت ليس الحقن، بل ما يحدث حين لا يوجد مستأجر ليُحقن.
الاستعلام المحمي الذي يجري داخل طلب بلا مستأجر مُحدَّد وبلا تجاوز صريح يرمي استثناءً. ليس تحذيرًا، ولا تراجعًا ليّنًا في بيئة التطوير: يرمي في التطوير تمامًا كما يرمي في الإنتاج.
وهذه النقطة الأخيرة أهم مما تبدو. الحارس الذي يُحذِّر محليًا ويرمي في الإنتاج هو حارس تلتقي به أول مرة في الإنتاج. وجعل البيئتين تتصرفان تصرفًا واحدًا يعني أن الجدار يُكتشف على حاسوب محمول، بيد من كتب الاستعلام للتو.
ثلاث حالات مشروعة، وهي متمايزة عن قصد:
- لا مخزن أصلًا — مهمة خلفية أو سكربت أو كود إقلاع. ليس طلبًا من عميل، فلا يُحمى شيء تلقائيًا وعلى المُستدعي تمرير معرّف الشركة صراحةً.
- مخزن بمستأجر — مسار الطلب المعتاد. يُحمى تلقائيًا.
- تجاوز صريح — وصول موثوق عابر للمستأجرين لأعمال المشرف العام والنظام. اشتراك اختياري، بالاسم، عند موضع الاستدعاء.
الحارس الذي يُحذِّر في التطوير ويرمي في الإنتاج هو حارس تلتقي به أول مرة في الإنتاج.
القائمة هي الجزء الذي يتآكل
لكل آلية من هذا النوع نقطة رخوة، ونقطتنا هي القائمة نفسها. فالحماية التلقائية لا تشمل إلا الموديلات المدرجة فيها. أما جدول جديد يحمل معرّف شركة ولم يُضِفه أحد إلى القائمة فهو غير محمي — ويفشل فشلًا مفتوحًا وصامتًا، بالطريقة نفسها التي أردنا الهرب منها.
وهذا ليس افتراضًا. كشفت مراجعة لعزل المستأجرين في كودنا عن جدول تقييمات يحمل معرّف شركة ولا يظهر في أي من القائمتين. كُتب، ورُوجع، وظلّ بلا حماية بهدوء.
ولم يكن العلاج أن نزيد حذرنا، بل اختبار يقرأ schema الخاصة بـ Prisma ومصدر القائمتين، ويجد كل موديل فيه عمود معرّف شركة، ثم يؤكد أن كلًّا منها إما محمي تلقائيًا وإما مستثنى صراحةً بسبب مكتوب. وأي جدول جديد مملوك لمستأجر لا يمسّ أيًّا من القائمتين يفشل الآن عند الـ commit.
وهو يحلّل المصدر بدل استيراده، فلا يحتاج قاعدة بيانات ولا عميلًا مولَّدًا كي يعمل. والحارس البطيء أو المزعج في تشغيله حارس يبدأ الناس في تخطّيه.
اليوم هذا يعني 48 موديلًا، منها 37 تحمل معرّف شركة: 24 محمية تلقائيًا، و13 مستثناة صراحةً، ولا شيء خارج الحساب. والرقم المهم هو الأخير.
الاستثناءات هي موضع التصميم الحقيقي
تبدو قائمة الاستثناءات كأنها قائمة ثغرات. وهي عمليًا الموضع الذي تكتشف فيه ما هو نموذج الإيجار لديك فعلًا، لأن كل مدخل فيها حالة تكون فيها إجابة "احمِه بحدود المستأجر الحالي" هي الإجابة الخاطئة.
بعضها بديهي بمجرد كتابته. المستخدمون يُبحث عنهم بالبريد الإلكتروني عند تسجيل الدخول، قبل معرفة أي مستأجر — وحماية هذا البحث بحدود مستأجر تجعل تسجيل الدخول مستحيلًا. وجدول النطاقات المخصصة هو ما يحدد المستأجر ابتداءً، فلا يمكن أن يشترط وجود واحد.
وبعضها فاجأنا فعلًا. أجهزة الإشعارات المسجّلة تحمل معرّف شركة، فالانعكاس الأول أن نحميها. لكن مفتاح المطابقة عند تسجيل جهاز هو الـ token، وهو عام عن قصد: الهاتف نفسه قد يخصّ معلمًا في أكاديمية، ثم يُسجَّل خروجه ويدخل بحساب شخص في أكاديمية أخرى على التطبيق ذاته. وحماية هذا البحث تلقائيًا ستحقن فيه مستأجر المُستدعي، فيُخطئ الصف القائم ويصطدم بقيد التفرّد على الـ token. لذلك يبقى التسجيل عامًا عن عمد، بينما تحمي كل التوابع الأخرى في ذلك المستودع نفسها صراحةً.
هذا النوع من التفاصيل لا يُتعلَّم إلا بتشغيل النظام. وهو أيضًا سبب أن كل استثناء يحمل سببًا مكتوبًا لا اسمًا فقط — فالسبب هو ما يمنع أحدهم من "إصلاحه" بعد ستة أشهر.
متى تحتاج هذا فعلًا
ليس كل منتج يحتاجه. فإن كان المستأجرون كبارًا وقليلين ويريدون فصلًا فيزيائيًا لبياناتهم — لأسباب تنظيمية غالبًا — فقاعدة بيانات لكل مستأجر أسهل في الشرح، وأسهل في النسخ الاحتياطي والاستعادة لعميل واحد في كل مرة. والثمن هو الترحيلات وإدارة الاتصالات مضروبةً في عدد عملائك.
أما schema واحدة مشتركة بحدّ مُنفَّذ فتقايض بالعكس: ترحيل واحد، ومجمع اتصالات واحد، ونشر واحد، وسلامة النموذج كلها متكئة على كود يجب أن تُتقنه مرة ثم تحميه بالاختبارات.
بنينا منصتنا بهذه الطريقة لأن مستأجرينا كثيرون وصغار، حيث ستلتهم البنية التحتية المخصصة لكل مستأجر تكلفة تشغيل المنتج.
وإن كنت تختار الآن، فالسؤال المفيد ليس أي نمط أفضل تجريدًا، بل: حين ينسى أحدهم، ماذا يحدث؟ صمِّمه بحيث تكون الإجابة "استثناء" لا "مجموعة نتائج أوسع".
هذا نوع العمل الذي ننفّذه لفرق أخرى ضمن تطوير منصات SaaS، وكيف بنينا Clasify يغطي بقية المعمارية.
كل المقالات