تخطّى إلى المحتوى الرئيسي
أمان مستوى الصفوف في Postgres لتطبيقات Node.js متعددة المستأجرين: الأدوار والاتصالات المجمّعة والنقاط العمياء — ملاحظات ميدانية

أمان مستوى الصفوف في Postgres لتطبيقات Node.js متعددة المستأجرين: الأدوار والاتصالات المجمّعة والنقاط العمياء — ملاحظات ميدانية

October 29, 2026
هندسة الأنظمة الخلفية
9 دقيقة قراءة

الخلاصة: ميزة أمان مستوى الصفوف (Row-Level Security أو RLS) في PostgreSQL تجعل قاعدة البيانات نفسها ترفض القراءة والكتابة عبر المستأجرين، فإذا نُسي الشرط WHERE tenant_id = $1 في استعلام Node.js يعود الاستعلام بصفر صفوف بدلًا من بيانات عميل آخر. ولا تصمد RLS إلا إذا اتصل التطبيق بدور لا يملك الجداول، وعيّن المستأجر عبر set_config('app.tenant_id', $1, true) داخل كل معاملة، لأن الإعداد على مستوى الجلسة يتسرب بين الاتصالات المجمّعة.

في التطبيقات متعددة المستأجرين ذات المخطط المشترك (shared schema)، يحمل كل جدول عمود tenant_id، ومن المفترض أن يرشّح كل استعلام عليه. شرط واحد منسي في نقطة نهاية جديدة أو تقرير أو تحميل علاقة عبر ORM يعني تسرب بيانات، ومراجعة الكود لا تلتقط السطر الغائب بشكل موثوق. في Devya ننقل عزل المستأجرين إلى PostgreSQL نفسه، فترفض قاعدة البيانات الصفوف العابرة بين المستأجرين حتى عندما يكون كود التطبيق خاطئًا. هذه ملاحظاتنا الميدانية حول السياسة، وإعداد الأدوار، ونمط التعامل مع مجمّع الاتصالات، والنقاط العمياء، على PostgreSQL 15 وما بعده مع node-postgres أو Drizzle ORM أو Prisma.

أهم النقاط

تضيف ميزة Row-Level Security في PostgreSQL شرط السياسة إلى كل استعلام على الجدول، فلا يقرأ الاتصال ولا يكتب إلا الصفوف التي يطابق فيها tenant_id المستأجرَ المعيَّن للمعاملة الحالية.

مالكو الجداول والمستخدمون الخارقون (superusers) والأدوار التي تحمل صفة BYPASSRLS يتجاوزون RLS، لذلك يجب أن يتصل تطبيق Node.js بدور منفصل لا يملك أي جدول، ويغطي الأمر FORCE ROW LEVEL SECURITY المالكَ أيضًا.

عيّن المستأجر عبر SELECT set_config('app.tenant_id', $1, true) داخل معاملة؛ الوسيط true يحصر القيمة في تلك المعاملة، فلا يرثها الطلب التالي على الاتصال المجمّع نفسه.

العروض (Views) ودوال SECURITY DEFINER وفحوص القيود الفريدة والمفاتيح الأجنبية والأمر TRUNCATE هي النقاط العمياء الرئيسية في RLS، ولكل منها علاجه الخاص.

تفرض RLS الحدود بين المستأجرين، لا الصلاحيات داخل المستأجر الواحد؛ قواعد مثل من يحق له من الأعضاء حذف الفواتير تبقى في كود التطبيق.

ما الذي تفرضه ميزة Row-Level Security في PostgreSQL فعليًا؟

ميزة Row-Level Security في PostgreSQL خاصية على مستوى الجدول، متاحة منذ PostgreSQL 9.5، تحدد الصفوف التي يستطيع دور ما في قاعدة البيانات قراءتها وكتابتها عبر سياسات مرتبطة بالجدول. تشغيل ALTER TABLE invoices ENABLE ROW LEVEL SECURITY دون أي سياسة يضع الجدول في وضع الرفض الافتراضي: الأدوار غير المالكة ترى صفر صفوف وتفشل كل عملية كتابة.

بعد ذلك تحدد السياسة ما هو مسموح. تعبير USING يرشّح الصفوف الموجودة في SELECT وUPDATE وDELETE، وتعبير WITH CHECK يتحقق من قيم الصفوف الجديدة في INSERT وUPDATE. سياستنا المعيارية tenant_isolation تُطبَّق FOR ALL على الدور app_user وتستخدم التعبير نفسه في البندين: tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid.

مع وجود البندين، يفشل أي INSERT يكتب tenant_id لمستأجر آخر بالخطأ "new row violates row-level security policy for table invoices"، ولا يستطيع UPDATE نقل صف إلى مستأجر آخر.

غلاف nullif يعالج عطلًا حقيقيًا. تُرجع current_setting('app.tenant_id', true) القيمة NULL إذا لم يُعرَّف الإعداد على الاتصال من قبل، لكن بعد أن تعيّنه معاملة سابقة على الاتصال نفسه قد يُقرأ كنص فارغ. وتحويل النص الفارغ إلى uuid يرفع الخطأ "invalid input syntax for type uuid". مع nullif يصبح المستأجر الغائب NULL، فلا تتحقق المقارنة أبدًا ويعود الاستعلام بصفر صفوف. كلتا النتيجتين آمنة عند الفشل، لكن صفر صفوف أسهل في الفهم.

هل أستخدم RLS أم مخططًا لكل مستأجر أم مرشحات على مستوى التطبيق؟

تناسب RLS نموذج تعدد المستأجرين بمخطط مشترك، حيث يتشارك المستأجرون الجداول ويفصل بينهم عمود tenant_id. ولا تغني عن العزل الفعلي عندما يشترط عقد العميل قاعدة بيانات مخصصة.

مرشحات WHERE tenant_id على مستوى التطبيق: كل استعلام في الكود مسؤول عن العزل، والشرط المنسي يسرّب صفوف المستأجرين الآخرين، والتكلفة التشغيلية هي الأدنى.

مخطط مشترك مع RLS: سياسات PostgreSQL تفرض العزل، والشرط المنسي لا يُرجع إلا صفوف المستأجر الحالي، والتكلفة مخطط واحد مع إعداد الأدوار والسياسات.

مخطط لكل مستأجر: قيمة search_path على كل اتصال تفرض العزل، والأمان مرهون بصحة search_path، وكل ترحيل (migration) يُنفَّذ مرة لكل مخطط.

قاعدة بيانات لكل مستأجر: سلاسل اتصال منفصلة تفرض العزل، ولا يستطيع الاستعلام الوصول إلى مستأجر آخر، لكن التجهيز وتجميع الاتصالات والنسخ الاحتياطي تتضاعف لكل مستأجر.

ومع ذلك نُبقي مرشحات tenant_id الصريحة في استعلامات التطبيق. RLS هي شبكة الأمان التي تحوّل الشرط المنسي إلى نتيجة فارغة بدلًا من اختراق.

بأي دور في قاعدة البيانات يجب أن يتصل تطبيق Node.js؟

يجب أن يتصل تطبيق Node.js بدور مخصص لا يملك أي جدول ولا يحمل صفة BYPASSRLS، لأن PostgreSQL يتجاوز سياسات RLS لمالكي الجداول والمستخدمين الخارقين وأدوار BYPASSRLS. وهذا أكثر ما يعطّل RLS بصمت: التطبيق يعيد استخدام دور الترحيلات، وهذا الدور يملك كل الجداول، فتُتجاهل كل سياسة دون أي خطأ.

يعتمد إعدادنا على ثلاثة أدوار. app_owner يملك المخطط ولا تستخدمه إلا الترحيلات. app_user دور دخول بصفة NOBYPASSRLS يُمنح SELECT وINSERT وUPDATE وDELETE على الجداول، مع ALTER DEFAULT PRIVILEGES حتى تحصل الجداول التي تنشئها الترحيلات المستقبلية على الصلاحيات نفسها. ودور ثالث بصفة BYPASSRLS يخدم المهام العابرة للمستأجرين مثل تجميع الفوترة عبر سلسلة اتصال خاصة به، فيصبح الوصول العابر للمستأجرين قرارًا مقصودًا يظهر في مراجعة الكود.

يجعل FORCE ROW LEVEL SECURITY السياسات تنطبق على مالك الجدول أيضًا، ما يحمي من سلسلة اتصال مضبوطة خطأً. ولا شيء يجعلها تنطبق على المستخدمين الخارقين أو أدوار BYPASSRLS.

كيف أمرر معرّف المستأجر بأمان عبر مجمّع الاتصالات؟

شغّل SELECT set_config('app.tenant_id', $1, true) كأول جملة في المعاملة، وأبقِ استعلامات الطلب داخل المعاملة نفسها. الوسيط الثالث is_local = true يحصر القيمة في المعاملة، فيتخلص منها PostgreSQL عند COMMIT أو ROLLBACK.

أما الإعداد على مستوى الجلسة، سواء عبر SET app.tenant_id أو set_config مع false، فيبقى على الاتصال الفعلي بعد أن يستعيده المجمّع، والطلب التالي الذي يستعير ذلك الاتصال يعمل باسم المستأجر السابق.

نستخدم set_config بدلًا من SET LOCAL لأن SET أمر خدمي (utility command) لا يقبل المعاملات المربوطة (bind parameters)؛ فالجملة SET LOCAL app.tenant_id = $1 خطأ في الصياغة، ولا يبقى بديل سوى إدراج القيمة نصيًا داخل SQL. أما set_config فدالة عادية، فينتقل معرّف المستأجر كمعامل في الاستعلام.

مع node-postgres، تستعير الدالة المساعدة withTenant عميلًا من Pool، وتشغّل BEGIN، ثم استدعاء set_config بمعرّف المستأجر كمعامل $1، ثم تنفّذ الدالة الممرَّرة، ثم COMMIT أو ROLLBACK عند الخطأ، وتحرّر العميل دائمًا. ومع Drizzle ORM تكون أول جملة داخل db.transaction هي tx.execute مع قالب sql الذي يرسل معرّف المستأجر كمعامل. ومع Prisma تؤدي المهمة معاملة تفاعلية prisma.$transaction تبدأ بـ tx.$executeRaw يشغّل استدعاء set_config نفسه. ولأن الإعداد يعيش داخل المعاملة، يعمل النمط أيضًا خلف PgBouncer في وضع تجميع المعاملات (transaction pooling).

يجب أن يأتي معرّف المستأجر من الجلسة أو رمز الوصول (access token) الموثّق على الخادم، لا من ترويسة أو جسم طلب يتحكم فيه العميل. في NestJS نستخرجه داخل Guard وننقله إلى طبقة البيانات عبر AsyncLocalStorage من node:async_hooks، فلا يستطيع أي مستودع (repository) تخطي withTenant. الثمن معاملة حول استعلامات كل طلب، مع رحلات ذهاب وإياب إضافية لـ BEGIN وCOMMIT، ونقبله مقابل حدود تفرضها قاعدة البيانات.

أين لا تنطبق Row-Level Security بصمت؟

ترشّح RLS الاستعلامات الموجهة إلى الجدول، لكن عدة خصائص في PostgreSQL تصل إلى بيانات الجدول عبر مسار آخر. نفحص هذه النقاط في كل تطبيق لـ RLS.

العروض (Views) تعمل افتراضيًا بصلاحيات مالك العرض، فالعرض الذي يملكه app_owner يتجاوز سياسة المستأجر. أضاف PostgreSQL 15 الخيار WITH (security_invoker = true) للعروض، فتُطبَّق سياسات المستدعي بدلًا من ذلك.

دوال SECURITY DEFINER تعمل بصلاحيات مالكها، فالدالة المساعدة التي يملكها app_owner تقرأ صفوف كل المستأجرين. SECURITY INVOKER هو الافتراضي في PostgreSQL، وكل دالة SECURITY DEFINER تخضع لمراجعة يدوية.

فحوص القيود الفريدة والمفاتيح الأجنبية تتجاوز RLS عمدًا حفاظًا على سلامة البيانات. قيد UNIQUE (email) عام على جدول خاص بالمستأجرين يُخبر المستأجر A، عبر خطأ انتهاك القيد، أن المستأجر B يستخدم هذا البريد بالفعل. والقيود المركبة مثل UNIQUE (tenant_id, email) تغلق هذه القناة.

يتجاهل الأمر TRUNCATE ميزة RLS لأنه يعمل على الجدول كاملًا، لذلك لا يحصل دور التطبيق أبدًا على صلاحية TRUNCATE.

كل سلسلة اتصال تستخدم دورًا بصفة BYPASSRLS مسار بلا RLS، لذلك يبقى ذلك الدور خارج الكود الذي يعالج الطلبات تمامًا.

هل تبطئ Row-Level Security الاستعلامات؟

تكلفة RLS تقارب تكلفة الترشيح على tenant_id، وهي رخيصة عندما تبدأ الفهارس بعمود tenant_id. نبني الفهارس المركبة بحيث يأتي المستأجر أولًا، مثل (tenant_id, created_at) لقائمة فواتير مقسّمة إلى صفحات.

افحص الاستعلامات الكثيفة بـ EXPLAIN وأنت متصل كـ app_user مع تعيين مستأجر؛ فالخطة المأخوذة بدور المالك لا تتضمن السياسة إطلاقًا. كما يقيّم PostgreSQL شرط السياسة قبل أي دالة غير معلَّمة LEAKPROOF في شرط WHERE الخاص بك، وهذا الترتيب قد يغيّر الفهرس الذي يختاره المخطِّط.

كيف أختبر أن عزل المستأجرين صامد فعلًا؟

اختبر RLS باختبارات تكامل تتصل كـ app_user بنسخة PostgreSQL حقيقية، لأن قاعدة البيانات المحاكاة (mock) لا تستطيع تقييم السياسات. مجموعة اختباراتنا تزرع مستأجرَين وتتحقق من أربعة أمور.

أولًا، مع تعيين المستأجر A، يُرجع اختيار فاتورة المستأجر B بمفتاحها الأساسي صفر صفوف. ثانيًا، مع تعيين المستأجر A، يفشل إدراج صف يحمل tenant_id الخاص بالمستأجر B بالخطأ "new row violates row-level security policy". ثالثًا، دون تعيين أي مستأجر، يُرجع كل جدول خاص بالمستأجرين صفر صفوف.

رابعًا، استعلام على كتالوج pg_class يسرد كل جدول في المخطط public تكون فيه relrowsecurity أو relforcerowsecurity بقيمة false. يعمل هذا الفحص في CI بعد الترحيلات ويلتقط أرجح تراجع: جدول جديد أُضيف دون تفعيل RLS وفرضها. أما الجداول العامة عمدًا، مثل كتالوج الخطط، فتوضع في قائمة سماح صريحة.

الأسئلة الشائعة

س: هل تعمل RLS في PostgreSQL مع PgBouncer في وضع تجميع المعاملات؟

ج: نعم، عندما يُعيَّن المستأجر عبر set_config('app.tenant_id', $1, true) داخل المعاملة نفسها التي تضم الاستعلامات. يتخلص PostgreSQL من الإعدادات المحصورة في المعاملة عند COMMIT أو ROLLBACK، فيبدأ العميل التالي على اتصال الخادم ذاته دون أي مستأجر.

س: لماذا لا يكون لسياسة RLS أي تأثير؟

ج: غالبًا لأن التطبيق يتصل كمالك الجدول أو كمستخدم خارق أو بدور يحمل صفة BYPASSRLS، وكلها تتجاوز السياسات. اتصل بدور غير مالك وأضف ALTER TABLE ... FORCE ROW LEVEL SECURITY ليشمل المالكَ أيضًا.

س: هل ما زلت أحتاج مرشحات WHERE tenant_id إذا كانت RLS مفعّلة؟

ج: RLS وحدها تمنع الشرط المنسي من تسريب البيانات. نُبقي المرشحات الصريحة لأنها تجعل الاستعلامات مقروءة دون معرفة السياسات، وتحافظ على صحة النتائج إذا نُفِّذ استعلام يومًا بدور متجاوز.

س: هل يجب أن تتولى RLS الصلاحيات داخل المستأجر، مثل المشرف مقابل العضو؟

ج: تستطيع ذلك، لكننا نُبقي التفويض داخل المستأجر في كود التطبيق، حيث يسهل اختباره لكل ميزة. قيمة RLS الكبرى في حدود المستأجرين، حيث يكشف خطأ واحد بيانات عميل آخر.