لنفرض أنك بنيت تطبيقاً للفواتير لشركة واحدة، ونجح التطبيق، ثم طلبت شركة ثانية نفس التطبيق، ثم ثالثة وعاشرة. أول حل يخطر على البال أن تنسخ السيرفر وقاعدة البيانات لكل شركة، وهذا يعمل مع خمس شركات، ولكن مع مئة شركة تجد نفسك تحدث مئة نسخة، وتراقب مئة قاعدة بيانات، وتدفع ثمن سيرفرات لا تعمل شيئاً أغلب اليوم، وكل نسخة على إصدار مختلف لأن التحديث لم يصلها بعد. والحل الذي وصلت إليه كل شركات البرمجيات كخدمة SaaS تقريباً هو أن يخدم تطبيق واحد كل العملاء معاً، وهذا ما يسمى تعدد المستأجرين Multi-Tenancy. وقد ذكرنا في المدخل إلى تصميم الأنظمة كيف يكبر النظام من سيرفر واحد، وفي قصة Stack Overflow رأينا تطبيقاً واحداً متعدد المستأجرين يخدم كل مواقع Stack Exchange ويعرف الموقع المطلوب من اسم النطاق. وفي هذا المقال نشرح الفكرة من أساسها: ما هو المستأجر Tenant، وكيف تعزل بيانات كل مستأجر عن غيره، ولماذا يكفي استعلام واحد نسي شرطاً صغيراً حتى يرى عميل فواتير عميل آخر.
هذا المقال جزء من سلسلة من مقالين:
- تعدد المستأجرين Multi-Tenancy في SaaS للمبتدئين: المفاهيم والبنية (الموضوع الحالي).
- بناء تطبيق SaaS متعدد المستأجرين عملياً: تطبيق سياسات الصفوف Row-Level Security في PostgreSQL، وأتمتة إنشاء المستأجر Tenant Provisioning، وترحيل الجداول على كل المستأجرين، والنسخ الاحتياطي والتصدير والحذف لكل مستأجر، ونقل مستأجر بين قواعد البيانات، مع مختبر كامل.
وسوف نناقش في هذا المقال ما يلي:
- ما هو المستأجر Tenant، والفرق بين مستأجر واحد لكل نسخة Single-tenant ونسخة واحدة لكل المستأجرين Multi-tenant، ولماذا تختار شركات SaaS الثانية، وما هو الخطر الحقيقي فيها.
- نماذج العزل الثلاثة بمصطلحات AWS: المعزول Silo والمشترك Pool والهجين Bridge، وكيف تختار نموذجاً لكل طبقة، وكيف ترتبط بالباقات Tiers.
- ثلاثة خيارات لبيانات كل مستأجر Tenant في PostgreSQL: قاعدة بيانات لكل مستأجر، أو مخطط Schema لكل مستأجر، أو جداول مشتركة مع عمود
tenant_idوسياسات الصفوف Row-Level Security حارساً، ومقارنة صادقة بينها. - سياق المستأجر Tenant Context: كيف يعرف كل طلب مستأجره من النطاق الفرعي أو نطاق العميل أو التوكن، وكيف يحمل هذه المعلومة إلى قاعدة البيانات وال Cache والسجلات والطوابير.
- الهوية والصلاحيات: دخول يعرف المستأجر Tenant، ومستخدم في أكثر من مستأجر، ودخول موحد SSO وتعيين الحسابات SCIM لكل عميل.
- الجار المزعج Noisy Neighbor والعدل بين المستأجرين Tenants: الحدود Quotas والطوابير والمراقبة والفوترة لكل مستأجر.
- إعدادات كل مستأجر Tenant دون نسخة خاصة من الكود، ودورة حياة المستأجر من التسجيل إلى الحذف.
- كيف فعلتها Notion وSlack وFigma، والمشاريع مفتوحة المصدر التي تطبق هذه الأنماط، ودليل قرار، وتجربة صغيرة تريك التسرب بعينك.
ما هو المستأجر Tenant؟
المستأجر Tenant في نظام SaaS هو العميل الذي اشترك في الخدمة، وغالباً يكون مؤسسة كاملة: شركة أو مستشفى أو مدرسة، لها موظفوها وإعداداتها وبياناتها وفاتورتها، وليس مستخدماً واحداً. لذلك عندما تشترك شركة acme في تطبيق الفواتير فهي مستأجر واحد، وموظفوها العشرون مستخدمون Users داخل هذا المستأجر، وكل واحد منهم يرى بيانات acme وحدها. والتسمية تأتي من عمارة الشقق: بناية واحدة يتشارك سكانها المصعد والكهرباء والماء، ولكل شقة باب مقفل لا يفتحه إلا أهلها، فالبناية هي التطبيق، والشقق هي المستأجرون، والباب المقفل هو العزل Isolation الذي سوف يأخذ أغلب هذا المقال. وفي بعض المنتجات يسمى المستأجر باسم آخر، مثل مساحة العمل Workspace في Slack وNotion، أو المؤسسة Organization في GitHub، أو الحساب Account، والفكرة نفسها.
وهناك طريقتان لخدمة أكثر من عميل:
- مستأجر Tenant واحد لكل نسخة Single-tenant: لكل عميل نسخته من التطبيق وقاعدة بياناته، وأحياناً سيرفره، فالعزل كامل، ولكنك تدير عدد النسخ بعدد العملاء.
- نسخة واحدة لكل المستأجرين Multi-tenant: تطبيق واحد وقاعدة بيانات واحدة أو عدد قليل منها يخدم كل العملاء، وكل سطر في قاعدة البيانات يعرف لمن ينتمي.
الصورة التالية تبين الطريقتين بنفس العملاء الثلاثة:

لماذا تختار شركات SaaS تعدد المستأجرين Multi-Tenancy؟
الأسباب الثلاثة الأساسية عملية جداً:
- التكلفة Cost: أغلب العملاء صغار ويستخدمون التطبيق ساعات قليلة في اليوم، فإذا أخذ كل واحد سيرفراً وقاعدة بيانات فأنت تدفع ثمن موارد خاملة، أما في النسخة المشتركة فيملأ استخدام عميل وقت فراغ عميل آخر، وكما تقول إرشادات Azure لبيانات الأنظمة متعددة المستأجرين Tenants فكلما زادت كثافة المستأجرين على البنية قلت تكلفة تشغيلها.
- التشغيل Operations: نسخة واحدة تراقبها وتنسخها احتياطياً وتصلح ثغراتها مرة واحدة، بدل مئة نسخة تنسى إحداها.
- إصدار واحد للجميع: كل عميل يحصل على الميزة الجديدة وإصلاح الثغرة في نفس اليوم، وهذا ما تسميه مبادئ AWS SaaS Lens بيئة واحدة يعمل عليها كل العملاء دون نسخ خاصة بعميل معين One-off Versions، لأن النسخ الخاصة تقتل سرعة الشركة في التطوير.
وفي المقابل يوجد خطر واحد كبير يجب أن تراه بوضوح من اليوم الأول: أن يرى مستأجر Tenant بيانات مستأجر آخر. فالبيانات كلها في نفس الجداول، والفاصل بينها شرط WHERE tenant_id = ... في كل استعلام، ويكفي أن ينساه مطور مرة واحدة في تقرير جديد. ووثيقة عزل المستأجرين Tenant Isolation في AWS SaaS Lens تصف عبور هذا الحد بأي شكل بأنه حدث كبير قد لا تتعافى منه شركة SaaS، والسبب أن العميل يغفر بطء الصفحة ولا يغفر أن يرى منافسه فواتيره. لذلك كل ما في بقية المقال هو إجابة على سؤال واحد: كيف تحصل على وفر النسخة المشتركة دون أن تدفع ثمنه من عزل البيانات؟
نماذج العزل الثلاثة: المعزول Silo والمشترك Pool والهجين Bridge
تستخدم وثيقة AWS SaaS Lens ثلاثة مصطلحات واضحة لنماذج العزل، وسوف نستخدمها في بقية المقال:
- النموذج المعزول Silo: موارد مخصصة لكل مستأجر Tenant، مثل قاعدة بيانات خاصة به، أو بنية كاملة مستقلة. ولاحظ النقطة المهمة التي تذكرها الوثيقة: حتى في هذا النموذج تبقى الهوية وتسجيل العملاء Onboarding والتشغيل مشتركة بين كل المستأجرين ومدارة من مكان واحد، وهذا ما يجعله SaaS وليس خدمة مدارة Managed Service يشغل فيها كل عميل نسخة مختلفة بإجراءات مختلفة.
- النموذج المشترك Pool: المستأجرون Tenants يتشاركون نفس الموارد، الحوسبة والتخزين والطوابير، وهذا هو المعنى الكلاسيكي لتعدد المستأجرين، ومنه يأتي الوفر في التكلفة والإدارة.
- النموذج الهجين Bridge: الاعتراف بأن أغلب الأنظمة ليست معزولة كلها ولا مشتركة كلها، فبعض الخدمات معزولة وبعضها مشترك، والوثيقة تذكر أن طبيعة بيانات خدمة معينة من ناحية الأنظمة واللوائح أو تأثير الجار المزعج قد تدفعها إلى العزل، بينما تبقى خدمة أخرى مشتركة لأن تكلفتها ونمط استخدامها يناسبان ذلك.
الصورة التالية تبين النماذج الثلاثة على طبقات الحوسبة والبيانات والشبكة:

العزل في كل طبقة: الحوسبة Compute والبيانات Data والشبكة Network والهوية Identity
الخطأ الشائع أن تسأل «هل نظامنا Silo أم Pool؟» كأنه قرار واحد، والصحيح أن تسأل السؤال لكل طبقة على حدة، وهذا ما تنص عليه مبادئ AWS عندما تطلب أن تقسم كل خدمة حسب حملها وحاجتها للعزل، وفيما يلي ما يعنيه العزل في كل طبقة:
- الحوسبة Compute: في النموذج المشترك تخدم نفس العمليات طلبات كل المستأجرين، وفي المعزول يكون لكل مستأجر Tenant عملياته أو Containers خاصة به. وفي Kubernetes تشرح الوثائق الرسمية لتعدد المستأجرين أن العزل طيف وليس خياراً ثنائياً: أبسطه Namespace لكل مستأجر مع الصلاحيات RBAC والحصص Resource Quotas وسياسات الشبكة Network Policies، وأقوى منه لوحة تحكم افتراضية لكل مستأجر Virtual Control Plane مثل vCluster (بالرخصة Apache-2.0)، وأقوى منها Cluster كامل لكل مستأجر.
- البيانات Data: قاعدة بيانات لكل مستأجر Tenant، أو مخطط Schema لكل مستأجر، أو جداول مشتركة، وهذه الطبقة هي الأهم ولها قسم كامل بعد قليل.
- الشبكة Network: في المعزول قد يكون لكل مستأجر Tenant شبكته الخاصة VPC أو على الأقل سياسات شبكة تمنع خدماته من الوصول إلى خدمات غيره، وفي المشترك تكون الشبكة واحدة والفصل في التطبيق.
- الهوية Identity: كل طلب يجب أن يحمل هوية المستخدم ومستأجره Tenant معاً، وهذه الطبقة مشتركة تقريباً في كل الأنظمة لأن تسجيل الدخول والفوترة وتسجيل العملاء تدار من مكان واحد، وسوف نشرحها في قسم الهوية.
وهذا يعني أن نظاماً واحداً قد يشغل كل المستأجرين Tenants على نفس التطبيق Pool في الحوسبة، ويعطي العميل الحكومي قاعدة بيانات خاصة Silo في البيانات، وهذا بالضبط ما يسمى Bridge.
مستوى العزل جزء مما يدفعه العميل في الباقات Tiering
بما أن الاختيار يكون لكل طبقة، فمن الطبيعي أن يكون أيضاً لكل باقة، وتذكر مبادئ AWS أن بعض الخدمات تعمل بنموذج Silo لعملاء الباقة الأعلى Premium Tier كجزء من قيمة هذه الباقة، وتذكر إرشادات Azure مثالاً مشابهاً: قاعدة بيانات مشتركة لأغلب المستأجرين Tenants، ونسخة مخصصة لمن يدفع أكثر أو له متطلبات غير عادية. والصورة التالية تبين ثلاث باقات على نفس الكود:

في الإعداد أعلاه لاحظ التالي:
- الكود واحد، وطبقة التحكم واحدة تنشر الإصدار على كل الباقات، والفرق كله في الإعدادات ومكان التشغيل، وإذا احتجت إلى
if tenant == 'acme'في الكود فقد بدأت تبني نسخة خاصة، وسوف نعود لهذا في قسم الإعدادات. - العميل في باقة Basic لا يحتاج قاعدة بيانات خاصة ولن يدفع ثمنها، والعميل الذي يفرض عليه نظام أو عقد أن تبقى بياناته في المملكة أو منفصلة فعلياً يدفع ثمن ذلك في باقة أعلى.
- الأرقام في الصورة مثال فقط، والذي تحدده أنت هو أي الحدود تهم منتجك.
أين تضع بيانات كل مستأجر Tenant؟
هذا أهم قرار في المقال لأنه أصعب ما يمكن تغييره لاحقاً، وفي PostgreSQL، وهو ما سوف نستخدمه في الأمثلة، توجد ثلاثة خيارات رئيسية، والصورة التالية تبينها جنباً إلى جنب مع مقارنة سريعة:

قاعدة بيانات لكل مستأجر Database per Tenant
هذا أول حل يخطر على البال لأنه لا يحتاج أي تغيير في الكود: كل مستأجر Tenant له قاعدة بيانات باسمه، والتطبيق يختار الاتصال المناسب حسب المستأجر، وبالتالي لا يمكن لاستعلام نسي شرطاً أن يرى بيانات غيره لأن البيانات في مكان آخر أصلاً. ومزاياه حقيقية: العزل قوي، واستعادة مستأجر واحد من نسخة احتياطية تعني استعادة قاعدة واحدة، ونقل عميل إلى سيرفر في منطقة أخرى سهل، ويمكن أن تعطي كل عميل مفتاح تشفير خاصاً به.
ولكن قد يتساءل البعض: إذا كان بهذه القوة فلماذا لا يستخدمه الجميع؟ والإجابة أن ثمنه يظهر مع العدد:
- تضاعف الاتصالات Connection Multiplication: كل قاعدة بيانات تحتاج مجمع اتصالات Connection Pool خاصاً بها، لأن الاتصال في PostgreSQL مرتبط بقاعدة واحدة، فمجمع صغير من عشرة اتصالات في كل نسخة من التطبيق يصبح مع ثلاثمئة مستأجر Tenant وثلاث نسخ تسعة آلاف اتصال محتمل، وقد شرحنا في مقال تجميع الاتصالات لماذا يرفض PostgreSQL الاتصالات بعد حد معين ولماذا لا يحل رفع
max_connectionsالمشكلة. - الترحيل Migrations: كل تغيير في الجداول يجب أن ينفذ على كل القواعد، وإذا فشل على القاعدة رقم 173 فأصبح عندك مستأجرون Tenants على نسخة جداول مختلفة، والتطبيق يجب أن يتعامل مع ذلك.
- التكلفة والتشغيل: المراقبة والنسخ الاحتياطي والتحديث لكل قاعدة، وتنبه إرشادات Azure إلى أن إنشاء قاعدة لكل مستأجر Tenant دون أتمتة يصبح تعقيداً لا يحتمل.
- التقارير الشاملة: سؤال بسيط مثل «كم فاتورة أصدر كل العملاء هذا الشهر؟» يصبح استعلاماً على مئات القواعد ثم تجميع النتائج.
لذلك هذا الخيار مناسب لعدد قليل من العملاء الكبار، أو لعميل واحد يفرض عليه نظام أو عقد فصلاً فعلياً، وغير مناسب إطلاقاً كنقطة بداية لمنتج يتوقع مئات العملاء الصغار.
مخطط لكل مستأجر Schema per Tenant
المخطط Schema في PostgreSQL مجلد داخل قاعدة البيانات يحمل جداوله، فتنشئ مخططاً لكل مستأجر Tenant فيه نفس الجداول، acme.invoices وglobex.invoices، والتطبيق يختار المخطط في بداية كل طلب بتغيير search_path، فيكتب الكود SELECT * FROM invoices دون أن يذكر المستأجر. وهذا هو النمط الذي تطبقه مكتبة django-tenants لإطار Django، وapartment لإطار Rails.
ومزاياه أنه وسط بين الخيارين: قاعدة واحدة واتصالات أقل، ونسخة احتياطية لمستأجر Tenant واحد ممكنة بالأمر pg_dump -n acme، وعزل أوضح من عمود في جدول. وثمنه:
- الترحيل Migrations ما زال يتكرر بعدد المخططات.
- العدد له حد عملي، فوثائق Citus تضع التوزيع بالمخطط Schema-based Sharding في نطاق من مستأجر Tenant واحد إلى عشرة آلاف مستأجر في ال Cluster، مقابل مليون وأكثر للتوزيع بالصفوف Row-based Sharding.
- تغيير
search_pathإعداد على مستوى الجلسة، وإذا كان أمام قاعدة البيانات PgBouncer بوضع transaction فالجلسة لا تبقى لنفس الكلاينت، وقد شرحنا في قسم ما ينكسر في وضع transaction لماذا يتسرب إعدادSETإلى طلب آخر، وهنا معنى التسرب أن طلب globex قد ينفذ على مخطط acme، لذلك استخدمSET LOCALداخل المعاملة.
جداول مشتركة Shared Tables مع عمود tenant_id
الخيار الثالث أن تكون الجداول واحدة للجميع، وكل جدول فيه عمود tenant_id، وكل استعلام يشترط هذا العمود. وهو الخيار الأعلى كثافة والأقل تكلفة، وترحيل الجداول يحدث مرة واحدة، ومجمع الاتصالات واحد، وهو ما تقوم عليه أنظمة كبيرة جداً كما سوف ترى في قسم الأمثلة الواقعية. وثمنه أن العزل أصبح مسؤولية كل سطر في الكود، وهنا ثلاث قواعد تقلل الخطر قبل أن نصل إلى الحارس:
- اجعل
tenant_idجزءاً من المفتاح الأساسي Primary Key والمفاتيح الأجنبية Foreign Keys: مثلPRIMARY KEY (tenant_id, id)، وفاتورة تشير إلى عميل عبرFOREIGN KEY (tenant_id, customer_id)، والسبب أن قاعدة البيانات نفسها تمنع فاتورة acme من أن تشير إلى عميل يخص globex، وهذا أيضاً ما تشترطه وثائق Citus للمفاتيح عندما توزع الجداول حسب المستأجر Tenant، فالقرار يخدمك مرتين. وقد شرحنا المفاتيح الأساسية والأجنبية في مقال تصميم قواعد البيانات. - القيود الفريدة Unique Constraints تكون داخل المستأجر Tenant: البريد الإلكتروني فريد داخل acme وليس في الجدول كله، أي
UNIQUE (tenant_id, email)، وإلا منعت عميلاً من إضافة موظف لأن عميلاً آخر أضافه قبله، وكشفت له بذلك أن هذا البريد موجود عندك. - لا تعتمد على أن المعرف لا يمكن تخمينه: الطلب
GET /invoices/1يجب أن يفحص أن الفاتورة رقم 1 تخص مستأجر Tenant المستخدم، وسوف ترى في التجربة كيف يعيد الاستعلام فاتورة acme لمستخدم من globex عندما يبحث بالرقم وحده، وهذه الثغرة لها اسم مشهور هو الوصول المباشر غير الآمن للكائنات IDOR، وقد شرحنا الفحص الصحيح في مقال الصلاحيات المركزية.
الحارس الأخير: سياسات الصفوف Row-Level Security
القواعد السابقة تعتمد كلها على أن المطور لم ينس، والحل الأنسب أن تضيف حارساً داخل قاعدة البيانات نفسها لا يعتمد على ذاكرة المطور، وهو سياسات الصفوف Row-Level Security واختصاراً RLS في PostgreSQL: تكتب على الجدول سياسة Policy تقول إن الصف يظهر فقط إذا كان tenant_id فيه يساوي المستأجر Tenant الحالي، والتطبيق يخبر قاعدة البيانات بالمستأجر الحالي في بداية كل معاملة، مثل SET LOCAL app.tenant_id = 'acme'، وبعدها حتى الاستعلام الذي نسي الشرط لا يرى إلا صفوف acme. وتذكر وثائق PostgreSQL ثلاث نقاط يجب أن تعرفها من الآن:
- إذا فعلت RLS على جدول ولم توجد سياسة، فالافتراضي هو المنع Default Deny، أي لا يظهر أي صف، وهذا هو السلوك الآمن.
- المستخدمون الخارقون Superusers والأدوار التي لها الخاصية
BYPASSRLSيتجاوزون السياسات دائماً، ومالك الجدول Table Owner يتجاوزها أيضاً إلا إذا فعلتFORCE ROW LEVEL SECURITY، لذلك لا يتصل التطبيق بقاعدة البيانات بالمستخدم الذي أنشأ الجداول. - فحوص سلامة البيانات مثل القيود الفريدة والمفاتيح الأجنبية تتجاوز السياسات دائماً، وكذلك العمليات على الجدول كله مثل
TRUNCATE.
وكن صادقاً مع نفسك في الثمن: إرشادات Azure تذكر أن RLS يحتاج أن تحمل هوية المستخدم والمستأجر Tenant إلى قاعدة البيانات مع كل استعلام، وأن تصميمه واختباره وصيانته معقد، وأن كثيراً من الأنظمة لا تستخدمه لهذا السبب، وإرشاداتها الخاصة بـ PostgreSQL تنبه إلى أثره على الأداء والحاجة إلى فهارس مناسبة. ومع ذلك فهو في رأينا أرخص من حادثة تسرب واحدة، بشرط أن يكون حارساً إضافياً وليس بديلاً عن الشرط في الكود. وسوف ترى في التجربة مثالاً صغيراً عليه، أما التطبيق الكامل مع الأدوار والمجمع والاختبارات فهو موضوع المقال الثاني في السلسلة.
مقارنة الخيارات الثلاثة لعزل البيانات Data Isolation
| الخيار | نقاط القوة | نقاط الضعف | يناسب |
|---|---|---|---|
| قاعدة بيانات لكل مستأجر Database per Tenant | أقوى عزل، واستعادة ونقل وتشفير لكل مستأجر Tenant، ولا يتأثر مستأجر بأقفال غيره | مجمع اتصالات لكل قاعدة، وترحيل بعدد القواعد، وتكلفة أعلى، وتقارير شاملة صعبة | عدد قليل من العملاء الكبار أو الخاضعين لأنظمة تفرض الفصل |
| مخطط لكل مستأجر Schema per Tenant | قاعدة واحدة، ونسخة احتياطية لمستأجر Tenant واحد بالأمر pg_dump -n، والكود لا يذكر المستأجر في الاستعلام | ترحيل بعدد المخططات، وحد عملي للعدد، وsearch_path يحتاج حذراً مع PgBouncer | عشرات إلى آلاف المستأجرين Tenants بجداول متشابهة |
جداول مشتركة مع tenant_id | أعلى كثافة وأقل تكلفة، وترحيل مرة واحدة، ومجمع واحد، وتقارير شاملة سهلة | العزل في كل استعلام، والجار المزعج أقرب، واستعادة مستأجر Tenant واحد تعني استخراج صفوفه من نسخة كاملة | أغلب منتجات SaaS في بدايتها، وآلاف إلى ملايين المستأجرين Tenants |
ولاحظ أن الاستعادة في الخيار الثالث لها حل معروف ذكرته إرشادات Azure: تستعيد النسخة الاحتياطية على سيرفر منفصل ثم تستخرج صفوف المستأجر Tenant المطلوب، وهذا يعمل ولكنه يحتاج أداة جاهزة قبل أن يطلبه العميل، وسوف نبنيها في المقال الثاني. وهناك خيار رابع يذكره نفس المرجع على أنه نمط يجب تجنبه، وهو جدول لكل مستأجر داخل قاعدة واحدة مثل invoices_acme، لأنه يجمع عيوب الخيارات كلها ولا يكبر.
بيانات المستأجر Tenant خارج قاعدة البيانات
البيانات ليست في PostgreSQL وحدها، وكل مخزن آخر يحتاج نفس السؤال: الملفات في التخزين الكائني Object Storage تكون تحت مسار يبدأ بالمستأجر Tenant مثل tenants/acme/invoices/، وروابط التنزيل المؤقتة تولد بعد فحص أن الملف يخص المستأجر، وفهرس البحث Search Index إما مشترك مع حقل tenant_id يدخل في كل بحث كشرط، أو فهرس لكل مستأجر، والطوابير والرسائل تحمل المستأجر داخل الرسالة نفسها. وأخطر هذه المخازن هو ال Cache، وله قسم خاص بعد قليل.
كيف يعرف الطلب مستأجره؟ سياق المستأجر Tenant Context
كل ما سبق يفترض أن التطبيق يعرف المستأجر الحالي، والسؤال الآن من أين يعرفه. الحل البسيط أن يرسل الكلاينت المستأجر في الطلب، مثل حقل tenant_id في جسم الطلب أو ترويسة X-Tenant-ID، ثم يثق التطبيق به، وهذا الحل غير مناسب إطلاقاً، والسبب أن أي مستخدم يستطيع تغيير القيمة في الطلب فيصبح فجأة في مستأجر آخر. والقاعدة الصحيحة كما تصوغها مبادئ AWS أن تربط هوية المستخدم بهوية المستأجر Bind User Identity to Tenant Identity عند تسجيل الدخول، فيحمل كل طلب هوية واحدة تعرف المستخدم ومستأجره، ثم تمر هذه الهوية على كل الطبقات. والصورة التالية تبين الطريق من المتصفح إلى كل طبقة:

النطاق الفرعي Subdomain لكل مستأجر Tenant
أشهر طريقة أن يكون لكل مستأجر Tenant نطاق فرعي، مثل acme.app.example وglobex.app.example، فيعرف التطبيق المستأجر من ترويسة Host، ويعرف المستخدم من عنوان المتصفح أين هو. ولكي لا تضيف سجلاً في DNS مع كل عميل جديد تستخدم سجلاً عاماً *.app.example، وقد شرحنا في قسم السجل العام Wildcard كيف يجيب عن أي اسم تحت النطاق. وكذلك الشهادة: شهادة واحدة شاملة Wildcard Certificate لـ *.app.example، ولاحظ أمرين ذكرناهما في مقال الشهادات: أنها تغطي مستوى واحداً فقط، فلا تغطي a.acme.app.example، وأن Let's Encrypt لا تصدرها إلا بالتحقق عبر ال DNS (DNS-01 Challenge) كما في وثائق أنواع التحقق، لذلك يحتاج Caddy مثلاً وحدة لمزود ال DNS كما شرحنا في دليل Caddy.
والتطبيق لا يثق باسم النطاق وحده، فبعد أن يحول acme إلى رقم المستأجر Tenant من جدول المستأجرين، يفحص أن المستخدم المسجل دخوله عضو في هذا المستأجر، وإلا فمستخدم globex يستطيع أن يكتب acme.app.example في المتصفح ومعه ملف تعريف الارتباط Cookie الخاص به إذا كان مضبوطاً على النطاق الرئيسي كله.
نطاق العميل الخاص Custom Domain
العملاء الكبار يطلبون أن يكون التطبيق على نطاقهم، مثل portal.acme.com، فيضيف العميل سجل CNAME يشير إلى تطبيقك كما في قسم CNAME، ويضيف التطبيق جدولاً يربط كل نطاق بمستأجره Tenant. والمشكلة في الشهادة: لا تستطيع أن تصدر شهادة شاملة لنطاقات لا تملكها، ولا تعرف هذه النطاقات مسبقاً. والحل الذي يقدمه Caddy اسمه On-Demand TLS، وتصفه الوثائق بأنه يحصل على الشهادة أثناء أول مصافحة TLS تحتاجها، بدل أن يحصل عليها عند تحميل الإعداد، وأنه مناسب بالضبط عندما لا تتحكم في أسماء النطاقات لأنها نطاقات العملاء. ولكن انتبه للشرط الذي تكرره الوثائق: يجب أن تقيده بنقطة سؤال ask يسأل Caddy فيها تطبيقك هل هذا النطاق مسجل لعميل حقيقي، والسبب أن أي شخص يستطيع توجيه أي نطاق إلى سيرفرك، ودون هذا الفحص سوف يطلب Caddy شهادات لكل نطاق يصل، فيستنزف حدود جهة الإصدار ويخزن شهادات لا تعرف أصحابها.
المسار Path والترويسة Header والحقل داخل التوكن Claim
وهناك ثلاث طرق أخرى تقابلها كثيراً:
- المسار Path: مثل
/t/acme/invoices، وهو مناسب عندما لا تريد نطاقات فرعية، والقاعدة نفسها: المسار يقول ما يريده المستخدم، والفحص يقرر هل يحق له. - الترويسة Header: مثل
X-Tenant-ID، وهي مقبولة بين خدماتك الداخلية بعد أن تحدد البوابة المستأجر Tenant وتضعها، وغير مقبولة من المتصفح دون فحص العضوية. - حقل داخل التوكن Claim: عندما يصدر مزود الهوية توكناً فيه حقل مثل
org_id، يقرأ التطبيق المستأجر Tenant من التوكن الموقع بدل أن يسأل عنه، وقد شرحنا الحقول القياسية في مقال الجلسات وJWT ومقال OAuth وOpenID Connect. ولاحظ أن المستخدم الذي ينتمي إلى أكثر من مستأجر يحتاج طريقة لاختيار المستأجر النشط، وسوف نعود لهذا في قسم الهوية.
حمل سياق المستأجر Tenant Context عبر التطبيق والسجلات Logs والطوابير Queues
بعد أن تحدد طبقة وسيطة Middleware المستأجر Tenant في بداية الطلب، تضعه في سياق خاص بهذا الطلب Request-scoped Context، مثل contextvars في Python أو AsyncLocalStorage في Node.js، ثم تقرؤه كل طبقة من هناك بدل أن تمرره يدوياً من دالة إلى دالة. والهدف كما تقول مبادئ AWS أن يكون كتابة كود متعدد المستأجرين قريباً من كتابة كود عادي، لأن المطور الذي يجب أن يتذكر المستأجر في كل سطر سوف ينساه في سطر ما، والحل مكتبات مشتركة تخفي التفاصيل. وفيما يلي أين يذهب السياق:
- قاعدة البيانات: طبقة الوصول للبيانات تضيف شرط المستأجر Tenant تلقائياً، وتنفذ
SET LOCAL app.tenant_idفي بداية كل معاملة Transaction لتعمل سياسات RLS، وLOCALمهمة لأن الإعداد ينتهي مع المعاملة فلا يتسرب عبر المجمع إلى طلب آخر. - السجلات Logs: كل سطر يحمل
tenant=acme، والسبب أن أول سؤال في أي بلاغ من عميل هو «ماذا حدث لهذا العميل؟»، ودون هذا الحقل تبحث في سجلات الجميع. - الطوابير Queues: الرسالة تحمل
tenant_idداخلها، والعامل Worker الذي يقرؤها بعد دقائق لا يوجد عنده طلب HTTP يعرف منه المستأجر Tenant، لذلك يعيد ضبط السياق من الرسالة قبل أن يعمل أي شيء، وإلا نفذ المهمة دون مستأجر أو بمستأجر الرسالة السابقة. - الخدمات الأخرى: عندما تستدعي خدمة داخلية ترسل معها التوكن أو الترويسة الداخلية، فلا تحتاج الخدمة أن تخمن.
مفتاح ال Cache يجب أن يحمل المستأجر Tenant
ال Cache هو المكان الذي يتسرب منه المستأجرون Tenants أكثر من غيره، والسبب أن المطور يكتب مفتاحاً مثل stats:unpaid لإحصائية في لوحة التحكم، ويختبرها بمستأجر واحد في بيئة التطوير فتعمل، ثم في الإنتاج Production يحفظ أول طلب نتيجة acme تحت هذا المفتاح، ويقرؤها كل مستأجر بعده حتى تنتهي صلاحيتها. ولاحظ أن RLS في قاعدة البيانات لا يحميك هنا إطلاقاً، لأن الطلب الثاني لم يصل إلى قاعدة البيانات أصلاً، وسوف ترى هذا بعينك في التجربة. والقاعدة: كل مفتاح لبيانات تخص مستأجراً يبدأ بالمستأجر، مثل t:acme:stats:unpaid، وأفضل من القاعدة أن تبني دالة واحدة تصنع المفاتيح من السياق فلا يكتبها أحد يدوياً. ونفس الفكرة في ال CDN وال Reverse Proxy، وقد شرحنا في قسم مفتاح ال Cache وتجربة الصفحة الخاصة التي تتسرب كيف تحفظ نسخة واحدة لصفحة تختلف من مستخدم لآخر، وشرحنا الاتصال بـ Redis وValkey في مقال تجميع الاتصالات.
الهوية Identity والصلاحيات Authorization لكل مستأجر Tenant
في تطبيق لمستأجر Tenant واحد يكفي جدول مستخدمين وصفحة دخول، أما في تطبيق متعدد المستأجرين فتظهر أسئلة جديدة: كيف يعرف التطبيق مستأجر المستخدم قبل أن يدخل؟ وماذا عن مستشار يعمل مع شركتين؟ وماذا لو أرادت acme أن يدخل موظفوها بحساباتهم في مزود الهوية الخاص بها؟ والصورة التالية تبين الصورة الكاملة:

- دخول يعرف المستأجر Tenant-aware Login: التطبيق يعرف المستأجر من النطاق الفرعي، أو يسأل عن البريد أولاً ثم يحدد من نطاق البريد أين يرسل المستخدم. وفي Keycloak توجد لهذا ميزة المنظمات Organizations داخل ال Realm الواحد، فلكل منظمة أعضاؤها ونطاقات بريدها ومزودو الهوية المرتبطون بها، ويوجه المستخدم إلى مزود منظمته حسب نطاق بريده، وأعلن المشروع أن المنظمات مدعومة بالكامل منذ الإصدار 26. والبديل الأقدم Realm لكل مستأجر، وال Realms معزولة عن بعضها كما تقول الوثائق، ولكن عددها يكبر مع عدد العملاء ويصبح كل Realm شيئاً تديره.
- مستخدم في أكثر من مستأجر Tenant: مستشار أو محاسب خارجي يعمل مع acme وglobex بنفس البريد، فلا تجعل المستخدم ينتمي إلى مستأجر واحد في جدول المستخدمين، وإنما اجعل جدول عضويات Memberships فيه المستخدم والمستأجر والدور، وأعطه طريقة لاختيار المستأجر النشط، ويصدر التوكن أو الجلسة لهذا المستأجر وحده.
- الأدوار لكل مستأجر Roles per Tenant: سارة مديرة admin في acme ومشاهدة viewer في globex، لذلك لا يوجد دور «مدير» عام، ويوجد دور في مستأجر.
- الدخول الموحد SSO لكل عميل: العميل الكبير يأتي بمزود هويته عبر SAML أو OIDC، وقد شرحنا الفرق في قسم المقارنة في مقال OAuth، وفي تطبيقك يصبح لكل مستأجر Tenant إعداد مزود هوية خاص به، وغالباً تجعله ميزة في الباقات الأعلى.
- تعيين الحسابات SCIM لكل مستأجر Tenant: عندما يغادر موظف acme يجب أن يتعطل حسابه عندك تلقائياً، وهذا ما يفعله SCIM، ولكل مستأجر توكن SCIM خاص به لا يستطيع إنشاء مستخدم إلا داخل مستأجره.
- المستأجر Tenant في كل فحص صلاحية: السؤال ليس «هل سارة admin؟» وإنما «هل سارة admin في المستأجر الذي تتبع له هذه الفاتورة، وهل هذا هو المستأجر النشط في الطلب؟»، وقد شرحنا في مقال الصلاحيات المركزية كيف تمثل العلاقات بين المستخدم والمنظمة والكائن في محرك مثل OpenFGA، والمنظمة هنا هي المستأجر.
أما authentik فلاحظ الفرق بين مفهومين فيه حتى لا يختلط عليك: العلامات Brands تغير مظهر صفحة الدخول وسلوكها حسب النطاق، والتعدد Tenancy ميزة مختلفة متاحة منذ الإصدار 2024.2 تنشئ مستأجرين مستقلين لكل واحد منهم مخطط PostgreSQL خاص، وتذكر الوثائق أنها ما زالت ألفا Alpha وتحتاج ترخيص Enterprise، وأن ال Brands كانت تسمى سابقاً Tenants.
الجار المزعج Noisy Neighbor والعدل بين المستأجرين Tenants
تعرف AWS SaaS Lens الجار المزعج Noisy Neighbor بأنه مستخدم يضع حملاً على موارد النظام يؤثر سلباً على المستخدمين الآخرين، وتذكر أن المشكلة أوضح في الأنظمة متعددة المستأجرين لأن حمل كل مستأجر Tenant لا يمكن توقعه. ومثال ذلك عميل كبير يصدر تقرير السنة كاملة في التاسعة صباحاً فيأخذ كل العمال وكل اتصالات قاعدة البيانات، فتبطؤ الصفحات عند مئة عميل صغير لا علاقة لهم بالتقرير. والحل الأول الذي يخطر على البال أن تكبر السيرفر، وهذا يؤجل المشكلة فقط، لأن العميل الكبير سوف يأخذ السيرفر الأكبر أيضاً، والحل الأنسب أن تضع حدوداً لكل مستأجر كما في الصورة التالية:

- حد الطلبات لكل مستأجر Rate Limit per Tenant: المفتاح في حد الطلبات هو المستأجر وليس عنوان IP وحده، وفي Nginx مثلاً تقول وثائق limit_req_zone إن المفتاح يمكن أن يكون أي متغير، وإن الطلبات التي قيمة مفتاحها فارغة لا تحسب، وهذا يعني أن الطلب الذي لم يحدد مستأجره لا يخضع للحد إطلاقاً، فتأكد أن البوابة ترفضه قبل ذلك. وقد شرحنا إعداد حد الطلبات في كل أداة في مقال حماية تطبيقات الويب، والرد الصحيح عند تجاوز الحد هو 429 Too Many Requests.
- الحصص Quotas: عدد المستخدمين، ومساحة التخزين، وعدد التقارير الثقيلة في اليوم، وهي حدود يفحصها التطبيق نفسه وتأتي من باقة المستأجر Tenant.
- الطوابير Queues لكل مستأجر Tenant أو لكل أولوية: العمل الثقيل لا يحدث داخل الطلب، وإنما يذهب إلى طابور كما شرحنا في المدخل إلى تصميم الأنظمة، ولكن طابوراً واحداً للجميع يعني أن عشرة آلاف مهمة من مستأجر واحد تقف أمام مهمة واحدة من مستأجر آخر، والحل أن يأخذ العمال من طوابير المستأجرين بالدور، أو أن يكون طابور لكل باقة مع عدد عمال لكل طابور.
- حدود الموارد Resource Limits: في قاعدة البيانات مهلة للاستعلام
statement_timeoutتمنع استعلاماً واحداً من أن يعمل ساعة، وفي Kubernetes حصص Resource Quotas لكل Namespace عندما يكون للمستأجر Tenant حوسبة خاصة. - المراقبة لكل مستأجر Tenant: تطلب مبادئ AWS لوحات تشغيل تعرض حمل كل مستأجر وكل باقة، لأن السؤال الأول عند البطء «من الذي يسببه؟». ولكن احذر في Prometheus، فوثائق التسمية فيه تحذر من أن كل تركيبة قيم للحقول Labels سلسلة زمنية Time Series جديدة، وتمنع استخدام حقول بقيم كثيرة لا حد لها مثل أرقام المستخدمين، فإذا كان عندك عشرون مستأجراً فحقل
tenantمقبول، وإذا كانوا عشرين ألفاً فضع الباقة في المقاييس، والمستأجر في السجلات والتتبع Tracing حيث يمكن البحث عنه. - القياس والفوترة Metering: نفس الأرقام تجيب عن سؤال تطرحه AWS صراحة: هل عملاء الباقة الأساسية يكلفون أكثر من عملاء الباقة العليا؟ ودون قياس لكل مستأجر Tenant قد تكتشف أن أرخص باقة هي أغلى عميل. وإذا كانت الفوترة بالاستهلاك Usage-based Billing فهناك أدوات مفتوحة المصدر لجمع الاستهلاك مثل OpenMeter (Apache-2.0) وLago (AGPL-3.0).
إعدادات كل مستأجر Tenant Configuration دون نسخة خاصة من الكود
يأتي العميل الكبير ويقول: نريد حقلاً إضافياً في الفاتورة، ونريد تعطيل ميزة التصدير لموظفينا، ونريد أن يكون الحد أعلى. والحل البسيط نسخة من المستودع Fork لهذا العميل، أو شرط if tenant == 'acme' في الكود، وهذا يعمل مع عميل واحد، ثم مع العميل العاشر يصبح عندك عشر نسخ لا تعرف أي إصلاح وصل إلى أيها، وهذا عكس السبب الذي من أجله اخترت تعدد المستأجرين Tenants. والقاعدة التي تكتبها AWS أن تحول الطلب الخاص إلى خيار إعداد متاح لكل العملاء Global Customization، وإرشادات Azure تعد النسخ الخاصة وإضافة أعمدة لعميل واحد من الأنماط التي يجب تجنبها. وفيما يلي الطريقة الصحيحة:
- جدول إعدادات المستأجر Tenant: الباقة، والحدود، والمنطقة، ومزود الهوية، والشعار والألوان، كلها صفوف في قاعدة البيانات يقرؤها الكود، وليست شروطاً فيه.
- أعلام الميزات Feature Flags: الميزة موجودة في الكود للجميع، والعلم يقرر لمن تظهر حسب المستأجر Tenant أو الباقة، ومعيار OpenFeature يوحد طريقة سؤال الكود عن العلم، ومن الأدوات مفتوحة المصدر Unleash (AGPL-3.0) وFlagsmith (BSD-3-Clause).
- الحقول المخصصة Custom Fields: بدل عمود جديد لعميل واحد، عمود JSONB للخصائص المرنة أو جدول للحقول المخصصة، وقد شرحنا متى يناسب JSONB في مقال تصميم قواعد البيانات.
دورة حياة المستأجر Tenant Lifecycle من التسجيل إلى الحذف
هنا نعرض الصورة العامة فقط، والتنفيذ خطوة بخطوة في المقال الثاني:
- التسجيل Onboarding: إنشاء المستأجر Tenant يجب أن يكون عملية واحدة مؤتمتة تتكرر بنفس الشكل كما تطلب مبادئ AWS: صف في جدول المستأجرين، والمدير الأول، والمنظمة في مزود الهوية، والإعدادات حسب الباقة، وفي النموذج المعزول قاعدة البيانات أو المخطط أيضاً، والنطاق الفرعي يعمل تلقائياً بفضل السجل العام.
- التحديث Upgrades: إصدار واحد للجميع، وترحيل الجداول يمر على كل قاعدة أو مخطط إذا لم تكن الجداول مشتركة، وإرشادات Azure تنصح بأن يتوافق التطبيق مع النسخة السابقة من الجداول على الأقل، لأن المستأجرين Tenants لن ينتقلوا كلهم في نفس اللحظة، وقد شرحنا تغيير الجداول دون توقف في مقال تصميم قواعد البيانات.
- النقل: عميل كبر فتنقله من القاعدة المشتركة إلى قاعدة خاصة، أو من منطقة إلى أخرى، وهذا ممكن دون تغيير الكود فقط إذا كان كل صف يحمل مستأجره Tenant من البداية.
- المغادرة Offboarding: تصدير بيانات العميل له بصيغة يستطيع قراءتها، ثم حذفها. وفي اللائحة الأوروبية العامة لحماية البيانات GDPR تنص المادة 28 على أن العقد بين المتحكم Controller والمعالج Processor يلزم المعالج بأن يحذف البيانات الشخصية أو يعيدها إلى المتحكم حسب اختياره بعد انتهاء الخدمة، وفي المملكة يعطي نظام حماية البيانات الشخصية في مادته الرابعة صاحب البيانات الحق في الحصول على نسخة منها بصيغة مقروءة وواضحة، والحق في طلب إتلافها عندما تنتهي الحاجة إليها. وهذا ليس استشارة قانونية، فراجع المستشار القانوني لما ينطبق على منتجك، والذي يهمك تقنياً أن يكون التصدير والحذف لكل مستأجر Tenant أداة جاهزة، وأن تعرف أن النسخ الاحتياطية تحتفظ بالبيانات حتى تنتهي مدة الاحتفاظ بها.
كيف فعلتها شركات معروفة؟ أمثلة من Notion وSlack وFigma
فيما يلي ثلاث تجارب منشورة من مدونات الشركات الهندسية نفسها، اخترناها لأن كل واحدة تعلمك شيئاً مختلفاً:
- Notion: التوزيع حسب مساحة العمل: في مقالها في أكتوبر 2021 شرحت Notion أنها وزعت بيانات PostgreSQL حسب رقم مساحة العمل Workspace ID، لأن كل كتلة Block تنتمي إلى مساحة عمل واحدة بالضبط، والمستخدم يستعلم غالباً داخل مساحة عمل واحدة، فلا تحتاج أغلب الاستعلامات إلى أكثر من جزء، وكان التوزيع 480 جزءاً منطقياً Logical Shards على 32 قاعدة بيانات فعلية. ومساحة العمل هنا هي المستأجر Tenant.
- Slack: عندما يكبر المستأجر Tenant أكثر من السيرفر: في مقالها في ديسمبر 2020 شرحت Slack أن كل جزء Shard في قواعدها كان يحمل كل بيانات مساحة عمل كاملة، وأن الجزء الواحد يحمل آلاف المساحات، ثم مع العملاء الكبار وصل الجزء المخصص لبعضهم إلى أكبر عتاد متاح، ولم تستطع توزيع حملهم فظهرت نقاط ساخنة Hot Spots، كما أن ميزة Slack Connect التي تجمع أكثر من شركة في قناة واحدة لا تناسب افتراض أن بيانات الفريق كلها في جزء واحد، فانتقلت إلى Vitess. والدرس أن التوزيع حسب المستأجر ممتاز حتى يصبح مستأجر واحد أكبر من سيرفر، فخطط لهذا اليوم.
- Figma: المستأجر Tenant ليس دائماً مفتاح التوزيع: في مقالها في مارس 2024 ذكرت Figma أنها لم تجد مفتاحاً واحداً مناسباً في نموذج بياناتها، فاختارت عدة مفاتيح هي UserID وFileID وOrgID، وجمعت الجداول التي تتشارك نفس المفتاح في مجموعات سمتها Colos تدعم الربط والمعاملات داخل المفتاح الواحد. والدرس أن رقم المستأجر هو البداية الطبيعية، ولكن بياناتك هي التي تقرر.
وقد رأينا في مقال Stack Overflow تطبيقاً واحداً متعدد المستأجرين Tenants يخدم الشبكة كلها، ورأينا في قسم انتقال Teams إلى Azure كيف استغرق فصل منتج Teams عن بنية المواقع العامة ونقله حوالي سنتين، وهذا وحده سبب كاف لأن تفكر في حدود المستأجرين مبكراً.
مشاريع مفتوحة المصدر Open Source تطبق هذه الأنماط
| المشروع | النمط | الرخصة | ملاحظات |
|---|---|---|---|
| Citus | توزيع PostgreSQL على عدة سيرفرات حسب tenant_id أو حسب المخطط | AGPL-3.0 | يوجه الاستعلام الذي فيه شرط المستأجر Tenant إلى سيرفر واحد، وقد شرحنا متى تحتاجه في مقال PostgreSQL للجداول الضخمة |
| django-tenants | مخطط لكل مستأجر Tenant في Django | MIT | يحدد المستأجر Tenant من اسم النطاق ويغير search_path |
| apartment (ros-apartment) | مخطط أو قاعدة لكل مستأجر Tenant في Rails | MIT | المستودع الأصلي influitive/apartment لم يحدث منذ 2024، والتطوير مستمر في هذا الفرع |
| acts_as_tenant | جداول مشتركة مع عمود المستأجر Tenant في Rails | MIT | يضيف شرط المستأجر Tenant تلقائياً على الاستعلامات |
| Keycloak | الهوية: المنظمات Organizations أو Realm لكل مستأجر Tenant | Apache-2.0 | المنظمات مدعومة بالكامل منذ الإصدار 26 |
| authentik | الهوية: Brands حسب النطاق، وTenancy بمخطط لكل مستأجر Tenant | MIT، ومجلد Enterprise برخصة خاصة | ميزة Tenancy ألفا وتحتاج ترخيص Enterprise |
| Capsule | الحوسبة: تجميع Namespaces لكل مستأجر Tenant في Kubernetes مع سياسات | Apache-2.0 | عزل على مستوى Namespace |
| vCluster | الحوسبة: Cluster افتراضي لكل مستأجر Tenant | Apache-2.0 | لكل مستأجر Tenant API Server خاص داخل Cluster مشترك |
والرخص كما في صفحة كل مستودع وقت كتابة المقال، وقد شرحنا ما تعنيه AGPL إذا عدلت المشروع وقدمته خدمة للآخرين في مقال البرمجيات مفتوحة المصدر وتراخيصها.
أي نموذج عزل Isolation Model تختار؟ دليل قرار
لا يوجد نموذج صحيح لكل الحالات، وهذا أول مبدأ في مبادئ AWS، ولكن يوجد ترتيب معقول للأسئلة، والصورة التالية تلخصه:

- ابدأ بالنموذج المشترك Pool في أغلب الحالات: جداول مشتركة، وعمود
tenant_idفي كل جدول ومفتاح، وسياسات RLS حارساً، ومفاتيح Cache تبدأ بالمستأجر Tenant، والسبب أنه الأرخص والأسهل في التحديث، والأهم أنه يجبرك على حمل السياق من اليوم الأول، وبعدها يصبح نقل مستأجر إلى قاعدة خاصة مسألة مكان وليس إعادة كتابة. - أعط Silo لمن يحتاجه ويدفع ثمنه: العميل الحكومي أو الصحي الذي يفرض عليه نظام أو عقد مكان البيانات أو فصلها أو مفاتيح تشفير خاصة، في الباقة الأعلى، وتحت نفس الكود وطبقة التحكم.
- اختر مخططاً أو قاعدة لكل مستأجر Tenant من البداية فقط إذا كان عملاؤك قليلين وكباراً: مثل نظام لعشرين مستشفى، لكل واحد نسخته الاحتياطية وتعديلاته، بشرط أن تؤتمت الإنشاء والترحيل من اليوم الأول.
- عندما يكبر مستأجر Tenant حتى يزعج الباقين: انقله إلى قاعدة خاصة Bridge، أو وزع المستأجرين على عدة سيرفرات حسب
tenant_id، وتذكر درس Slack: أكبر مستأجر عندك سوف يصل يوماً إلى حدود السيرفر.
وبكل تأكيد أنت لا تحتاج كل هذا في الأسبوع الأول، والذي لا يمكن تأجيله هو عمود tenant_id في كل جدول والسياق في كل طلب، لأن إضافتهما لاحقاً تعني المرور على كل جدول وكل استعلام في المشروع.
تجربة صغيرة: كيف يتسرب مستأجر Tenant إلى آخر؟
التجربة التالية صغيرة ومقصودة لترى بعينك التسرب في مكانين: استعلام نسي شرط المستأجر Tenant، ومفتاح Cache لا يحمل المستأجر، ثم كيف يمنع الحارس الأول ولا يمنع الثاني. ونحتاج PostgreSQL 18 وValkey 9 في Docker، والمنافذ على 127.0.0.1 فقط.
تجهيز قاعدة البيانات Database وال Cache
docker network create lab-mt-net
docker run -d --name lab-mt-pg --network lab-mt-net -p 127.0.0.1:19580:5432 \
-e POSTGRES_PASSWORD=labpass -e POSTGRES_DB=saas postgres:18
docker run -d --name lab-mt-valkey --network lab-mt-net -p 127.0.0.1:19581:6379 valkey/valkey:9-alpine
docker exec lab-mt-pg psql -U postgres -tAc 'select version()'
docker exec lab-mt-valkey valkey-server --versionوالمخرج سوف يكون كما يلي:
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
Valkey server v=9.1.2 sha=00000000:1 malloc=jemalloc-5.3.0 bits=64 build=4784f0215171d89الآن نقوم بإنشاء جدول للمستأجرين Tenants وجدول فواتير مشترك فيه مستأجران، acme شركة تجارية وglobex عيادات:
docker exec -i lab-mt-pg psql -U postgres -d saas <<'SQL'
CREATE TABLE tenants (
id text PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE invoices (
tenant_id text NOT NULL REFERENCES tenants(id),
id bigint GENERATED ALWAYS AS IDENTITY,
customer text NOT NULL,
amount numeric NOT NULL,
paid boolean NOT NULL DEFAULT false,
PRIMARY KEY (tenant_id, id)
);
INSERT INTO tenants VALUES ('acme', 'Acme Trading'), ('globex', 'Globex Clinics');
INSERT INTO invoices (tenant_id, customer, amount, paid) VALUES
('acme', 'Riyadh Office Supplies', 1200, false),
('acme', 'Jeddah Logistics', 800, false),
('acme', 'Dammam Print', 450, true),
('globex', 'Patient 1043', 300, false),
('globex', 'Patient 2210', 950, true);
SQLوالمخرج سوف يكون كما يلي:
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 5في الإعداد أعلاه لاحظ التالي:
- المفتاح الأساسي
PRIMARY KEY (tenant_id, id)يبدأ بالمستأجر Tenant كما ذكرنا في قسم الجداول المشتركة. - الرقم
idيولد من تسلسل واحد للجدول كله، فأرقام فواتير globex تكمل بعد أرقام acme، وهذا تفصيل صغير سوف يكشف لنا ثغرة بعد قليل.
الخطوة 1: الاستعلام الذي نسي المستأجر Tenant
لنفرض أن موظفاً من globex فتح صفحة «الفواتير غير المدفوعة»، والمطور كتب الاستعلام ونسي الشرط، ثم نقارنه بالاستعلام الصحيح:
echo "== forgotten filter"
docker exec lab-mt-pg psql -U postgres -d saas -c "SELECT tenant_id, id, customer, amount FROM invoices WHERE paid = false ORDER BY id;"
echo "== with filter"
docker exec lab-mt-pg psql -U postgres -d saas -c "SELECT tenant_id, id, customer, amount FROM invoices WHERE tenant_id = 'globex' AND paid = false ORDER BY id;"والمخرج سوف يكون كما يلي:
== forgotten filter
tenant_id | id | customer | amount
-----------+----+------------------------+--------
acme | 1 | Riyadh Office Supplies | 1200
acme | 2 | Jeddah Logistics | 800
globex | 4 | Patient 1043 | 300
(3 rows)
== with filter
tenant_id | id | customer | amount
-----------+----+--------------+--------
globex | 4 | Patient 1043 | 300
(1 row)موظف العيادة رأى عملاء الشركة التجارية ومبالغهم. والآن الثغرة الثانية: صفحة تفاصيل الفاتورة /invoices/1 تبحث بالرقم وحده، وموظف globex يرى أن فاتورته رقم 4، فيجرب الرقم 1:
docker exec lab-mt-pg psql -U postgres -d saas -c "SELECT tenant_id, id, customer, amount FROM invoices WHERE id = 1;"والمخرج سوف يكون كما يلي:
tenant_id | id | customer | amount
-----------+----+------------------------+--------
acme | 1 | Riyadh Office Supplies | 1200
(1 row)وهذه هي ثغرة IDOR التي ذكرناها، والأرقام المتسلسلة تجعل تخمينها أسهل، ولكن الحل ليس رقماً يصعب تخمينه، وإنما شرط المستأجر Tenant في كل استعلام، والأفضل منه حارس لا ينسى.
الخطوة 2: الحارس في قاعدة البيانات Row-Level Security
نقوم الآن بإنشاء دور للتطبيق ليس مالكاً للجدول، ونفعل RLS بسياسة واحدة تقرأ المستأجر Tenant من الإعداد app.tenant_id. وهذا مثال للفكرة فقط، أما الإعداد الكامل مع FORCE والأدوار والمجمع والاختبارات فهو في المقال الثاني:
docker exec -i lab-mt-pg psql -U postgres -d saas <<'SQL'
CREATE ROLE app_user LOGIN PASSWORD 'apppass';
GRANT SELECT, INSERT, UPDATE, DELETE ON invoices TO app_user;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id', true));
SQLوالمخرج سوف يكون كما يلي:
CREATE ROLE
GRANT
ALTER TABLE
CREATE POLICYالآن نتصل بدور التطبيق وننفذ نفس الاستعلامين الخاطئين، مرة دون مستأجر Tenant، ومرة بعد أن نحدد أن الطلب يخص globex:
docker exec -i -e PGPASSWORD=apppass lab-mt-pg psql -h 127.0.0.1 -U app_user -d saas <<'SQL'
-- 1) no tenant set yet: the forgotten filter again
SELECT tenant_id, id, customer FROM invoices WHERE paid = false;
-- 2) the request belongs to globex
BEGIN;
SET LOCAL app.tenant_id = 'globex';
SELECT tenant_id, id, customer FROM invoices WHERE paid = false;
SELECT tenant_id, id, customer FROM invoices WHERE id = 1;
COMMIT;
SQL
echo "== as owner"
docker exec lab-mt-pg psql -U postgres -d saas -c "SELECT count(*) FROM invoices;"والمخرج سوف يكون كما يلي:
tenant_id | id | customer
-----------+----+----------
(0 rows)
BEGIN
SET
tenant_id | id | customer
-----------+----+--------------
globex | 4 | Patient 1043
(1 row)
tenant_id | id | customer
-----------+----+----------
(0 rows)
COMMIT
== as owner
count
-------
5
(1 row)في الإعداد أعلاه لاحظ التالي:
- دون مستأجر Tenant محدد لم يظهر أي صف، لأن
current_setting('app.tenant_id', true)يعيد NULL عندما لا يوجد الإعداد بدل أن يرمي خطأ، والمقارنة مع NULL لا تكون صحيحة أبداً، فالخطأ ينتهي بصفحة فارغة وليس بتسرب. - بعد
SET LOCAL app.tenant_id = 'globex'أعاد نفس الاستعلام الذي نسي الشرط فاتورة globex وحدها، والبحث بالرقم 1 أعاد صفراً من الصفوف بدل فاتورة acme. - المستخدم
postgresرأى الصفوف الخمسة، لأنه مستخدم خارق ومالك الجدول، وهذا ما تقوله الوثائق بالضبط، لذلك لا يتصل التطبيق بهذا المستخدم أبداً.
الخطوة 3: مفتاح Cache دون مستأجر Tenant
الآن نبني «التطبيق» في دالة Shell صغيرة تمثل مربع «الفواتير غير المدفوعة» في لوحة التحكم، وتحفظ النتيجة في Valkey لمدة ستين ثانية. احفظ الملف التالي باسم report.sh:
# report.sh: the "unpaid invoices" widget on the dashboard, cached for 60 seconds
report() {
tenant="$1" # the tenant this request belongs to
key="stats:unpaid" # the cache key
cached=$(docker exec lab-mt-valkey valkey-cli GET "$key")
if [ -n "$cached" ]; then
echo "request from $tenant -> HIT key=$key value=$cached"
return
fi
value=$(echo "SELECT json_build_object('tenant', current_setting('app.tenant_id'), 'unpaid', count(*), 'total', coalesce(sum(amount), 0)) FROM invoices WHERE paid = false;" |
docker exec -i -e PGPASSWORD=apppass -e PGOPTIONS="-c app.tenant_id=$tenant" \
lab-mt-pg psql -h 127.0.0.1 -U app_user -d saas -tA)
docker exec lab-mt-valkey valkey-cli SET "$key" "$value" EX 60 > /dev/null
echo "request from $tenant -> MISS key=$key value=$value"
}لاحظ أن الدالة تتصل بدور التطبيق app_user وتحدد المستأجر Tenant في الاتصال، أي أن RLS يعمل، ثم نرسل طلباً من acme وطلبين من globex:
docker exec lab-mt-valkey valkey-cli FLUSHALL
. ./report.sh
report acme
report globex
report globexوالمخرج سوف يكون كما يلي:
OK
request from acme -> MISS key=stats:unpaid value={"tenant" : "acme", "unpaid" : 2, "total" : 2000}
request from globex -> HIT key=stats:unpaid value={"tenant" : "acme", "unpaid" : 2, "total" : 2000}
request from globex -> HIT key=stats:unpaid value={"tenant" : "acme", "unpaid" : 2, "total" : 2000}موظف globex رأى أن عنده فاتورتين غير مدفوعتين بمجموع 2000، وهي أرقام acme، رغم أن سياسة RLS تعمل كما رأيت في الخطوة السابقة، والسبب أن طلبات globex لم تصل إلى قاعدة البيانات أصلاً، فقد وجدت القيمة جاهزة في ال Cache. والإصلاح أن يبدأ المفتاح بالمستأجر Tenant، فغير سطر المفتاح في report.sh إلى السطر التالي:
key="t:$tenant:stats:unpaid" # the cache key now starts with the tenantثم أعد التجربة واعرض المفاتيح الموجودة:
docker exec lab-mt-valkey valkey-cli FLUSHALL
. ./report.sh
report acme
report globex
report globex
docker exec lab-mt-valkey valkey-cli --scan --pattern '*'والمخرج سوف يكون كما يلي:
OK
request from acme -> MISS key=t:acme:stats:unpaid value={"tenant" : "acme", "unpaid" : 2, "total" : 2000}
request from globex -> MISS key=t:globex:stats:unpaid value={"tenant" : "globex", "unpaid" : 1, "total" : 300}
request from globex -> HIT key=t:globex:stats:unpaid value={"tenant" : "globex", "unpaid" : 1, "total" : 300}
t:acme:stats:unpaid
t:globex:stats:unpaidفي الإعداد أعلاه لاحظ التالي:
- أصبح لكل مستأجر Tenant مفتاحه، وأول طلب من globex حسب أرقامه هو، والطلب الثاني قرأ قيمته هو من ال Cache.
- القيمة نفسها تحمل
"tenant"، وهذا يسمح بالفحص الإضافي الذي أضافته OpenAI بعد حادثتها: قبل أن تعيد القيمة تأكد أن المستأجر Tenant فيها هو مستأجر الطلب. - الأمر
FLUSHALLيمسح كل ما في Valkey، وهو مناسب هنا لأن ال Container خاص بالتجربة، ولا تنفذه على Valkey يستخدمه تطبيق آخر.
حذف التجربة
نحذف ال Containers مع ال Volumes التي أنشأتها ال Images لها تلقائياً، ثم الشبكة، ثم الملف:
docker rm -f -v lab-mt-pg lab-mt-valkey
docker network rm lab-mt-net
rm -f report.shولاحظ الخيار -v، فدونه يبقى ال Volume المجهول Anonymous Volume الذي تنشئه Image الخاصة بـ PostgreSQL للبيانات بعد حذف ال Container.
الأخطاء الشائعة
- الثقة بالمستأجر Tenant القادم من الكلاينت: حقل
tenant_idفي جسم الطلب أو ترويسة من المتصفح دون فحص العضوية، فيغير المستخدم القيمة ويصبح في مستأجر آخر. - استعلام جديد دون شرط المستأجر Tenant: تقرير أو بحث أو تصدير كتب على عجل، والحل طبقة وصول للبيانات تضيف الشرط تلقائياً، وRLS حارساً، واختبار آلي يتأكد أن مستخدم مستأجر لا يرى بيانات غيره.
- البحث بالمعرف وحده:
WHERE id = $1في صفحة التفاصيل والتعديل والحذف، والحلWHERE tenant_id = $1 AND id = $2دائماً. - مفتاح Cache دون مستأجر Tenant: كما رأيت في التجربة، وRLS لا ينقذك هنا.
- التطبيق يتصل بمالك الجداول أو بمستخدم خارق: فلا تعمل سياسات RLS عليه أصلاً.
SETبدلSET LOCALخلف مجمع اتصالات: فيبقى المستأجر Tenant أوsearch_pathعلى الاتصال ويصل إلى طلب مستأجر آخر.- العامل Worker دون سياق: رسالة في الطابور لا تحمل المستأجر Tenant، فينفذ العامل المهمة على مستأجر خاطئ أو على الجميع.
- قيود فريدة على الجدول كله:
UNIQUE (email)بدلUNIQUE (tenant_id, email)، فتمنع عميلاً وتكشف له بيانات عميل آخر. - نسخة خاصة لكل عميل كبير: Fork أو شروط باسم العميل في الكود، بدل الإعدادات وأعلام الميزات.
- حقل
tenantفي كل مقياس مع آلاف المستأجرين Tenants: فينفجر عدد السلاسل الزمنية في Prometheus. - On-Demand TLS دون نقطة
ask: فيطلب ال Reverse Proxy شهادات لأي نطاق يوجهه أي شخص إلى سيرفرك.
قائمة تحقق
- هل كل جدول يخص المستأجرين Tenants فيه عمود
tenant_id، وهل يبدأ به المفتاح الأساسي والمفاتيح الأجنبية والقيود الفريدة؟ - هل يحدد المستأجر Tenant مرة واحدة في بداية الطلب من مصدر موثوق، النطاق أو التوكن، مع فحص أن المستخدم عضو فيه؟
- هل يمر السياق تلقائياً إلى قاعدة البيانات وال Cache والسجلات والطوابير والخدمات الأخرى، دون أن يمرره المطور يدوياً؟
- هل يوجد حارس في قاعدة البيانات مثل RLS، والتطبيق يتصل بدور ليس مالكاً للجداول، و
SET LOCALداخل المعاملة؟ - هل يوجد اختبار آلي يسجل الدخول بمستأجرين Tenants ويتأكد أن كل واحد لا يرى بيانات الآخر في كل صفحة وواجهة API؟
- هل كل مفتاح Cache ومسار ملفات وفهرس بحث يحمل المستأجر Tenant؟
- هل عندك حد للطلبات وحصص لكل مستأجر Tenant، وطوابير لا يحتكرها مستأجر واحد؟
- هل تستطيع أن تجيب في دقيقة: كم يستهلك هذا المستأجر Tenant، وكم يكلفنا؟
- هل الفروق بين العملاء في الإعدادات وأعلام الميزات، وليس في الكود؟
- هل التسجيل والتصدير والحذف لكل مستأجر Tenant أدوات مؤتمتة جاهزة قبل أن يطلبها أول عميل؟
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- المستأجر Tenant هو العميل المؤسسة وليس المستخدم، وتعدد المستأجرين يعني تطبيقاً واحداً يخدم كل العملاء، فيوفر التكلفة والتشغيل ويعطي الجميع إصداراً واحداً، وخطره الأكبر أن يرى مستأجر بيانات آخر.
- نماذج العزل الثلاثة Silo وPool وBridge قرار لكل طبقة ولكل باقة، وطبقة التحكم مشتركة دائماً.
- للبيانات ثلاثة خيارات: قاعدة لكل مستأجر Tenant لأقوى عزل وأعلى تكلفة، ومخطط لكل مستأجر وسطاً بينهما، وجداول مشتركة مع
tenant_idلأعلى كثافة، وفي الأخيرة يكونtenant_idجزءاً من المفاتيح وRLS حارساً إضافياً. - السياق يحدد مرة واحدة من النطاق أو التوكن مع فحص العضوية، ثم يمر إلى قاعدة البيانات وال Cache والسجلات والطوابير، وفي التجربة رأيت مفتاح Cache دون مستأجر Tenant يكشف أرقام acme لـ globex رغم أن RLS كان يعمل.
- الهوية في نظام متعدد المستأجرين Tenants تعني عضويات وأدواراً لكل مستأجر، ودخولاً موحداً وSCIM لكل عميل، والمستأجر في كل فحص صلاحية.
- الجار المزعج يعالج بحدود لكل مستأجر Tenant وطوابير عادلة ومراقبة وقياس لكل مستأجر، والقياس نفسه يخبرك بتكلفة كل عميل.
- ابدأ بالنموذج المشترك وحمل السياق من اليوم الأول، وأعط العزل لمن يحتاجه ويدفع ثمنه، وخطط ليوم يكبر فيه مستأجر Tenant واحد أكثر من سيرفر.
وفي المقال الثاني من السلسلة نبني هذا عملياً: RLS كاملاً في PostgreSQL، وإنشاء المستأجر Tenant آلياً، وترحيل الجداول، والنسخ الاحتياطي والتصدير والحذف لكل مستأجر، ونقل مستأجر بين القواعد.
دليلتطبيق SaaS متعدد المستأجرين عملياً مع Row-Level Securityنبني على PostgreSQL تطبيق SaaS يعزل كل مستأجر Tenant عن غيره بسياسات Row-Level Security، ونرى أين تتسرب البيانات رغم ذلك.
دليلمدخل إلى تصميم الأنظمة: من سيرفر واحد إلى ملايين المستخدمينكيف يكبر متجر إلكتروني من سيرفر واحد إلى نظام يخدم ملايين المستخدمين، وما الثمن الذي تدفعه في كل خطوة.
دليلتصميم قواعد البيانات: التطبيع Normalization ومتى تكسرهلماذا يفشل الجدول الواحد الكبير، وكيف تقسمه إلى جداول لا تناقض نفسها، وما الذي يتغير عندما تكبر البيانات.
دليلConnection Pooling: لماذا الاتصال بقاعدة البيانات مكلفلماذا تظهر رسالة too many clients عندما تزيد نسخ تطبيقك، وكيف يحل مجمع الاتصالات وPgBouncer هذه المشكلة.
دليلكيف يتذكرك الموقع بعد تسجيل الدخول؟ الجلسات وJWTبروتوكول HTTP لا يتذكر أحداً، فكيف يبقى المستخدم مسجلاً بين الصفحات، ومتى تختار الجلسة على السيرفر ومتى تختار JWT.
دليلAuthorization: كيف تجمع قرارات الصلاحيات في مكان واحدلماذا تتحول شروط الصلاحيات المتفرقة في الكود إلى ثغرات، وكيف تنقل القرار إلى محرك سياسات واحد.سجل التحديثات
- أكتوبر 2026: كتابة المقال واختبار التجربة على PostgreSQL 18.6 وValkey 9.1.2 مع Docker Engine 29.8.0.