ADR-0007 — نموذج تعدد المستأجرين وفرض العزل
الحالة: مُعتمَد · التاريخ: 2026-07-20 · صاحب القرار: أمير هارون (القائد التقني)
ذو صلة: QAS-SEC-01 (أعلى سيناريو أولوية) · R-05 (خطر حرج) · Flow 6 · الأمن §2
السياق#
حسابات الشركات (ACT-CO) تملك حسابات موظفين فرعية (ACT-EMP). سجلات صاحب عمل عن موظفيه يجب ألّا تكون مرئية لصاحب عمل آخر. QAS-SEC-01 هو أعلى سيناريو جودة أولوية في النظام، وتسريب البيانات عبر المستأجرين حدث وجودي وقابل للإبلاغ بموجب PDPL.
الخاصية الحرجة لنمط الفشل هذا: إنه صامت. لا خطأ، ولا انهيار — فقط الشركة "أ" تقرأ سجلات موظفي الشركة "ب"، وربما لأشهر قبل الاكتشاف.
شكل البيانات يعارض استخدام حزمة multi-tenancy جاهزة#
| البيانات | التصنيف |
|---|---|
| قوائم الـ marketplace، المستشارون، النماذج، محتوى التدريب، الاختبارات | مشتركة / عامة على مستوى المنصة — وهي غالبية البيانات، وجوهر المنتج ذاته |
| علاقات الشركة↔الموظف، وثائق الشركة، الاشتراكات، تقدّم الموظفين ونتائجهم | محصورة بالمستأجر — وهي الأقلية |
القرار#
قاعدة بيانات مشتركة، schema مشترك، عمود تمييز company_id، مفروض أساسًا عبر PostgreSQL Row-Level Security (RLS). دون حزمة multi-tenancy.
لماذا لا حزمة multi-tenancy#
حزم tenancy الشائعة في Laravel مبنية حول فكرة "تحويل سياق التطبيق بالكامل إلى مستأجر واحد لكل طلب". هذا النموذج يقاوم هذا النظام فعليًا:
- المستخدم الذي يتصفح الـ marketplace ليس في أي سياق مستأجر
- الموظف الذي يشاهد تدريب شركته و القوائم العامة في شاشة واحدة يكون في سياقين
- مسؤولو المنصة ليسوا في أي سياق، والمراجعون يعملون روتينيًا عبر مستأجرين كثيرين
تبنّي إحداها يعني إنفاق المشروع في الالتفاف حول تجريدها.
لماذا لا schema أو قاعدة بيانات لكل مستأجر#
عزل أقوى، لكن: هجرات عبر آلاف الـ schemas، واستنزاف connection pool، وتشعّب النسخ الاحتياطي والاستعادة، و — بشكل حاسم — استعلامات الـ marketplace عبر المستأجرين تصبح مستحيلة أو تتطلب مخزنًا ثانيًا غير مُطبَّع. الـ marketplace هو المنتج؛ وجعله صعبًا أمر مُسقِط للخيار.
تكلفة الخطأ في هذا الاتجاه منخفضة. إذا طالب عميل مؤسسي لاحقًا بعزل فيزيائي، استخرج ذلك العميل إلى نشر مخصص — قرار بنية تحتية عند الحاجة، لا معمارية مدفوعة مسبقًا في اليوم الأول.
آلية الفرض — وهي القرار الفعلي#
عمود company_id ليس ضابطًا. ما يلي هو الضابط.
خمس طبقات، مرتّبة بحسب مدى تأخّر كشف كلٍّ منها#
| # | الطبقة | ترفض عند الشك؟ | الدور |
|---|---|---|---|
| 1 | PostgreSQL RLS مع FORCE ROW LEVEL SECURITY |
✅ | خط الدفاع الأخير — يُرجع صفر صفوف بغض النظر عن أخطاء التطبيق |
| 2 | TenantContext يرمي استثناءً عند عدم التعيين |
✅ | لا يُرجع null أبدًا، ولا يفترض "بلا تصفية" |
| 3 | Eloquent global scope | ❌ | راحة واستخدام للفهرس — وليس حدًّا أمنيًا صراحةً |
| 4 | اختبار معماري: كل جدول فيه company_id يستخدم الـ trait |
✅ عند البناء | يلتقط النموذج الذي سيُضاف العام القادم |
| 5 | حزمة اختبارات عدائية في الـ CI | ✅ عند البناء | تختبر السلوك المرصود، لا التفاصيل الداخلية |
FORCE ROW LEVEL SECURITY مهم: بدونه يتجاوز مالك الجدول السياسات، ومالك الجدول عادةً هو دور التطبيق.
الطبقتان 1 و5 تصمدان أمام تغيّر الفريق. أما 2–4 فتجعل الفعل الصحيح سهلًا.
لماذا الطبقة 3 وحدها غير كافية#
الـ Eloquent global scope يُتجاوَز عبر withoutGlobalScopes()، والاستعلامات الخام، وDB::table()، وبعض مسارات العلاقات، ومن قِبل أي مطوّر لا يعلم بوجوده. اعتباره الضابط هو الخلل الجسيم الأكثر شيوعًا في تطبيقات Laravel متعددة المستأجرين.
الطبقة 5 بالتفصيل#
الحزمة التي تحافظ فعليًا على أمان النظام عبر الزمن:
- تهيئة شركتين ببيانات كاملة تبدو متطابقة
- المصادقة كالشركة "أ"
- المرور على كل مسار مُصادَق، وطلب كلٍّ منه بمعرّفات الشركة "ب"
- التحقق من
403/404في كل حالة — و من خلوّ نص الاستجابة من أي معرّف للشركة "ب" - إفشال البناء عند ظهور مسار جديد بلا تغطية
عندها لا يمكن للنقاط الطرفية الجديدة أن تُدخل تسريبًا بصمت.
⚠ نمطا فشل يجب تصميمهما الآن#
كلاهما حالة تكون فيها سياسة RLS مكتوبة بشكل صحيح ومع ذلك تتسرب البيانات.
1. إعادة استخدام الاتصال#
مع connection pooling في وضع transaction — أو مع workers طويلة العمر — يمكن لمتغيّر جلسة عُيّن في طلب أن يبقى إلى الطلب التالي، فيقدّم بيانات مستأجر تحت سياق مستأجر آخر.
التخفيف: عيّن متغيّر المستأجر بنطاق المعاملة داخل transaction صريحة، و أعِد تصفيره في terminating middleware.
هذا هو ناقل التسريب الفعلي. السياسة تعمل بشكل مثالي وتُرجع الصفوف الخطأ رغم ذلك.
2. الوظائف المُجدولة لا تملك طلبًا#
الوظيفة الخلفية تعمل دون طلب HTTP، لذا يبقى متغيّر المستأجر غير مُعيَّن وتتبخّر كل آليات الأمان المرتبطة بالطلب.
التخفيف: ضمّن معرّف المستأجر في حمولة الوظيفة؛ وأنشئ السياق في job middleware؛ والوظيفة التي لا تستطيع إنشاء السياق يجب أن ترمي استثناءً، لا أن تفترض قيمة افتراضية.
النتائج#
إيجابية#
- استعلامات الـ marketplace تمتد عبر المستأجرين بشكل طبيعي — المنتج يعمل
- schema واحد، ومسار هجرة واحد، ونسخ احتياطي واحد
- العزل المفروض على مستوى قاعدة البيانات يُوثَّق أمام المدقّق التنظيمي أفضل بكثير من "لدينا trait يُفترض أن يستخدمه المطورون". "الإخفاق في تطبيق الضمانات التقنية والتنظيمية" من ضمن فئات الإنفاذ المُبلَّغ عنها لدى سدايا.
- لا اعتماد على تجريدات حزمة tenancy أو وتيرة إصداراتها
سلبية / مقبولة#
- RLS يضيف تكلفة تخطيط بسيطة لكل استعلام — تُعوَّض بالإبقاء على الطبقة 3 كي يستمر المخطِّط في استخدام فهرس
company_id - على كل مطوّر أن يفهم لماذا الطبقة 3 ليست الضابط
- إضافة RLS إلى جدول مأهول بالبيانات تحت الحِمل تتطلب نافذة صيانة ← أنشئ السياسات مع أول migration محصورة بالمستأجر، لا لاحقًا
- عزل أضعف من الفصل الفيزيائي — مقبول، مع مسار الاستخراج أعلاه كمخرج طوارئ
أثر على قرار بيئة التشغيل#
هذا الـ ADR سبب رئيسي لتأجيل Laravel Octane. الـ workers طويلة العمر في Octane تجعل نمط الفشل الأول أكثر احتمالًا بشكل ملموس. تبنّيه قبل إثبات عزل المستأجرين وتغطيته بالاختبارات يعني مقايضة أعلى سمة جودة في النظام بأداء لا يحتاجه حجم الإطلاق. أعِد النظر بعد اكتمال الطبقات 1–5 ونجاح الحزمة العدائية.
محور العزل الثاني#
ACT-EMP يُدخل بُعدًا يتجاوز شركة↔شركة: يجب ألّا يقرأ الموظف درجات زميله، ووصول مسؤول الشركة إلى سجلات الموظفين ينبغي أن يكون محصورًا ومُدقَّقًا لا مطلقًا. لا تنمذج تعدد المستأجرين كمحور مسطّح واحد — حصر الشركة ضروري وغير كافٍ. انظر الأمن §2.4.
اعتماد مفتوح#
OQ-07 — عند تعليق شركة أو رفضها، ماذا يحدث لمحاولات الاختبار الجارية لموظفيها وللشهادات الصادرة بالفعل؟ الآلية أعلاه تضمن أن نقطة الانتشار مكان واحد (تحليل سياق المستأجر) لا موزّعة عبر الوحدات، لكن السياسة تتطلب قرارًا من الجهة الراعية. نقطة التحقق العامة من الشهادات تُرجع الحالة الحالية محسوبة لحظيًا، لذا لا بد لها من إجابة.