backend

Postgres Row-Level Security لتطبيقات الـ Multi-Tenant (2026)

٢٧ يونيو ٢٠٢٦

Postgres Row-Level Security for Multi-Tenant Apps (2026)

تفرض سياسة أمان مستوى الصف (RLS) في Postgres عزل المستأجرين المتعددين (multi-tenant) في قاعدة البيانات: قم بتمكين RLS على كل جدول، واكتب سياسة تقارن tenant_id بمتغير جلسة خاص بكل طلب، وقم بتوصيل node-postgres كدور بأقل الصلاحيات، واضبط المستأجر باستخدام set_config(..., true) داخل معاملة (transaction) حتى لا يتسرب أبدًا عبر الاتصالات المجمعة (pooled connections).

ملخص

تحول سياسة أمان مستوى الصف في Postgres عزل المستأجر إلى ضمانة من قاعدة البيانات بدلاً من قاعدة يجب أن تتذكرها في كل استعلام. ستقوم ببناء جدول documents معزول للمستأجرين على PostgreSQL 18 باستخدام node-postgres (pg) 8.21.0. سياسة tenant_isolation واحدة تجعل قاعدة البيانات — وليس جمل WHERE الخاصة بك — هي الشيء الذي يمنع مستأجرًا من قراءة أو كتابة صفوف مستأجر آخر. يستغرق البناء بالكامل حوالي 30 دقيقة، وقد تم تنفيذ كل جملة SQL هنا مقابل Postgres 18 قبل النشر — جنبًا إلى جنب مع مساعد withTenant ومجموعة الاختبارات — والجزء الأكثر تعقيدًا (تسرب في مجمع الاتصالات الذي يبطل RLS بصمت) يحصل على إصلاح من سطر واحد.

ما ستتعلمه

  • كيف تفرض سياسة أمان مستوى الصف عزل المستأجرين المتعددين في طبقة قاعدة البيانات
  • كيفية تمكين RLS وكتابة سياسة USING مرتبطة بـ tenant_id
  • لماذا تبدو RLS "متجاهلة" لمالكي الجداول والمستخدمين الخارقين (superusers)، والدور ذو الصلاحيات الأقل الذي يصلح ذلك
  • كيفية تمرير المستأجر الحالي من node-postgres باستخدام current_setting و set_config
  • تسرب مجمع الاتصالات الذي يكسر RLS البسيطة، والإصلاح المحلي للمعاملة (transaction-local)
  • كيف يمنع WITH CHECK المستأجر من الكتابة في صفوف مستأجر آخر
  • متى تلجأ إلى سياسات FORCE ROW LEVEL SECURITY و RESTRICTIVE
  • كيفية اختبار عزل المستأجر تلقائيًا حتى لا تؤدي عملية ترحيل (migration) لاحقة إلى كسره بصمت

المتطلبات الأساسية

  • PostgreSQL 18 يعمل محليًا (أي نسخة يمكن الوصول إليها تفي بالغرض). RLS نفسها ليست جديدة — لقد كانت ميزة أساسية منذ PostgreSQL 9.5 (2016)، لذا فإن السياسات أدناه تعمل أيضًا على 9.5+.1
  • Node.js 20.6+ (أي إصدار LTS حالي؛ pg نفسها تحتاج فقط إلى Node 16+، لكن مجموعة الاختبار الاختيارية في النهاية تستخدم node --test --import، التي أضيفت في 20.6) و node-postgres pg 8.21.0.2
  • الإلمام بأساسيات SQL (CREATE TABLE، GRANT) و async/await.

ثبّت إصدار التعريف (driver) ليكون بناؤك قابلاً لإعادة الإنتاج:

npm install pg@8.21.0
npm install -D TypeScript@6.0.3 tsx@4.22.4 @types/pg@8.20.0 @types/node@26.0.1

أمان مستوى الصف هو نصف جانب قاعدة البيانات في تعدد المستأجرين. إذا كنت تقوم أيضًا بتخزين بيانات المستأجر مؤقتًا في تطبيقك، فقم بدمج ذلك مع مفاتيح تخزين مؤقت لكل مستأجر حتى لا يتم التراجع عن خطأ عزل في طبقة واحدة بواسطة الطبقة الأخرى — راجع الدليل المصاحب حول مفاتيح التخزين المؤقت متعددة المستأجرين في Next.js.

الخطوة 1 — نمذجة المستأجرين باستخدام عمود tenant_id

عادةً ما تختار التطبيقات متعددة المستأجرين أحد نماذج العزل الثلاثة: قاعدة بيانات منفصلة لكل مستأجر (أقوى عزل، وأصعب في التشغيل)، أو مخطط (schema) لكل مستأجر (حل وسط يصبح صعبًا بعد بضع مئات من المستأجرين)، أو مخطط مشترك حيث تعيش صفوف كل مستأجر في نفس الجداول المميزة بـ tenant_id. يتوسع نموذج المخطط المشترك ليشمل آلاف المستأجرين في قاعدة بيانات واحدة وهو الأكثر شيوعًا — ولكنه عادةً ما يضع عبء العزل بالكامل على ضبط فلتر tenant_id بشكل صحيح في كل استعلام. يزيل أمان مستوى الصف هذا العبء عن طريق نقل الفلتر إلى قاعدة البيانات نفسها، حيث لا يمكن لجملة WHERE منسية أن تكشف بيانات مستأجر آخر.

ابدأ بالمخطط. كل جدول مخصص للمستأجرين يحمل عمود tenant_id. قم بتشغيل هذا كدور ترحيل متميز (مالك الجدول):

CREATE TABLE documents (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  tenant_id   bigint      NOT NULL,
  title       text        NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);

INSERT INTO documents (tenant_id, title) VALUES
  (1, 'Acme roadmap'),
  (1, 'Acme invoices'),
  (2, 'Globex memo');

استخدام GENERATED ALWAYS AS IDENTITY بدلاً من serial مهم لاحقًا: أعمدة الهوية مملوكة للجدول، لذا لا يحتاج دور تطبيقك إلى منح تسلسل منفصل للإدراج.

أضف فهرسًا يبدأ بـ tenant_id. نظرًا لأن كل استعلام تمت تصفيته بواسطة RLS يكتسب فعليًا مسند tenant_id = <current tenant>، فإن الفهرس الذي يبدأ بـ tenant_id يحافظ على سرعة تلك الاستعلامات:

CREATE INDEX documents_tenant_id_idx ON documents (tenant_id, created_at DESC);

الخطوة 2 — قم بتشغيل أمان مستوى الصف واكتب سياستك الأولى

يؤدي تمكين RLS إلى تحويل الجدول من "أي شخص لديه امتيازات الجدول يرى كل صف" إلى الرفض الافتراضي: بمجرد تمكين أمان الصف، إذا لم تمنح أي سياسة حق الوصول، فلن تكون أي صفوف مرئية أو قابلة للتعديل على الإطلاق.3

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON documents
  FOR ALL
  USING      (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
  WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint);

تؤدي تعبيرتان وظيفتين. تقوم USING بتصفية الصفوف الموجودة التي تكون مرئية لعمليات القراءة والتحديث والحذف؛ الصفوف التي تعيد فيها قيمة false أو null يتم تخطيها بصمت — دون خطأ.4 تقوم WITH CHECK بالتحقق من صحة الصفوف الجديدة عند INSERT/UPDATE؛ عندما تعيد false أو null يتم إلغاء الأمر بالكامل مع ظهور خطأ.4 سنعتمد على كليهما.

يقرأ استدعاء current_setting('app.tenant_id', true) متغيرًا خاصًا بكل جلسة يضبطه تطبيقك في كل طلب — وهو الجسر بين طبقة المصادقة وقاعدة البيانات. الوسيط الثاني، true (missing_ok)، يعني "إرجاع NULL بدلاً من إظهار خطأ إذا لم يتم ضبط المتغير أبدًا."5 غلاف NULLIF(..., '') ليس للزينة؛ توضح الخطوة 5 العطل الدقيق الذي يمنعه.

كيف تقوم السياسة بإعادة كتابة استعلاماتك

لا تقوم RLS بتشغيل فحص أذونات منفصل بعد عودة استعلامك — بل تعيد كتابة الاستعلام قبل تشغيله. مع تمكين أمان الصف، يضيف Postgres تعبير USING الخاص بالسياسة كمسند ضمني ويقوم بتقييمه لكل صف قبل أي شروط تأتي من استعلامك الخاص.3 استعلام SELECT * FROM documents عادي صادر عن app_user يتصرف كما لو كنت قد كتبت SELECT * FROM documents WHERE tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint. هذا هو الهدف بالكامل — وهو أيضًا السبب في أن تعبير السياسة يجب أن يظل رخيصًا وصديقًا للفهرس، لأنه يعمل على كل صف من كل استعلام مقابل الجدول.

الخطوة 3 — لماذا تبدو سياستك متجاهلة (المالكون والمستخدمون الخارقون يتجاوزون RLS)

إليك مفاجأة شائعة "RLS لا تعمل". اتصل كمستخدم خارق (أو كمالك للجدول) واستعلم عن الجدول:

-- متصل كمستخدم خارق / مالك الجدول
SELECT count(*) FROM documents;   -- يعيد 3، وليس 0

هذا سلوك صحيح، وليس خطأ. المستخدمون الخارقون والأدوار التي تمتلك سمة BYPASSRLS يتجاوزون دائمًا أمان الصف، ومالك الجدول يتجاوزه أيضًا ما لم تفرضه صراحةً.3 عادةً ما يكون دور الترحيل وجلسة psql الخاصة بك متميزين، لذا فهم يرون كل شيء — وهذا هو بالضبط السبب في وجوب ممارسة RLS بواسطة دور غير متميز.

قم بإنشاء دور تطبيق مخصص ليس مستخدمًا خارقًا ولا مالكًا للجدول، وامنحه فقط امتيازات البيانات التي يحتاجها:

CREATE ROLE app_user NOSUPERUSER NOINHERIT LOGIN PASSWORD 'change-me';
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;

-- لضمان تغطية الجداول المستقبلية تلقائياً
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;

يتصل تطبيقك كـ app_user. ولأن هذا الدور لا يملك documents وليس superuser، فإن سياسة tenant_isolation تنطبق عليه بالفعل. قم بتشغيل تطبيقك تحت دور بأقل صلاحيات ممكنة في كل بيئة — فقوة RLS تعتمد كلياً على الدور الذي تُنفذ به استعلاماتك فعلياً.

الخطوة 4 — ربط الطلب بقاعدة البيانات باستخدام متغير جلسة

الآن قم بربط node-postgres بالسياسة. يتصل التطبيق ببيانات اعتماد app_user، ومع كل طلب، يخبر Postgres أي مستأجر (tenant) هو النشط عن طريق تعيين متغير app.tenant_id الذي تقرأه السياسة.

// db.ts
import { Pool } from 'pg';

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL, // postgres://app_user:change-me@localhost:5432/app
  max: 10,
});

لا تقم بتشغيل SET app.tenant_id = ... مباشرة على اتصال مجمع (pooled connection) (الخطوة 5 تشرح السبب). بدلاً من ذلك، قم بتغليف كل وحدة عمل داخل عملية (transaction) واستخدم set_config(name, value, true). الوسيط الثالث، is_local = true، يجعل نطاق الإعداد مقتصرًا على العملية الحالية — وهو الشكل الوظيفي لـ SET LOCAL، ويتم إعادة ضبطه تلقائياً عند COMMIT أو ROLLBACK.5

// with-tenant.ts
import type { Pool, PoolClient } from 'pg';

export async function withTenant<T>(
  pool: Pool,
  tenantId: number,
  fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    // محلي للعملية: لا يمكن أن يتسرب للطلب التالي على هذا الاتصال
    await client.query('SELECT set_config($1, $2, true)', ['app.tenant_id', String(tenantId)]);
    const result = await fn(client);
    await client.query('COMMIT');
    return result;
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

كل استعلام بنطاق المستأجر يمر الآن عبر withTenant، وتقوم قاعدة البيانات بتصفية الصفوف نيابة عنك:

// معالج المسار (بأسلوب Express)
app.get('/documents', async (req, res) => {
  const tenantId = req.tenant.id; // يتم استخراجه من JWT / الجلسة الخاصة بك، وليس من جسم طلب العميل أبداً
  const docs = await withTenant(pool, tenantId, (client) =>
    client.query('SELECT id, title, created_at FROM documents ORDER BY id'),
  );
  res.json(docs.rows);
});

لاحظ ما هو مفقود: لا يوجد WHERE tenant_id = $1. السياسة تضيفه لكل استعلام ضد documents، لذا فإن نسيان عامل تصفية لم يعد يعني اختراقاً للبيانات بين المستأجرين. تعيين المستأجر من سياق المصادقة الموثوق به — وليس أبداً من بارامتر الطلب — هو ما يجعل ذلك آمناً.

الخطوة 5 — فخ مجمع الاتصالات (والحل في سطر واحد)

هذا هو الخطأ الذي يحول RLS إلى شعور زائف بالأمان، وتتجاهله العديد من الأدلة. تقوم مجمعات الاتصالات (Connection pools) بإعادة استخدام الاتصالات المادية عبر الطلبات. استخدام SET العادي (بدون LOCAL) يغير الإعداد لـ الجلسة بأكملها، لذا فإنه يظل موجوداً بعد client.release() ويتسرب إلى من يستخدم هذا الاتصال بعد ذلك.

إليك التسريب، تمت محاكاته على Postgres 18 مع مجمع اتصال واحد لفرض إعادة الاستخدام:

// نمط سيء — لا تفعل هذا
const a = await pool.connect();
await a.query("SET app.tenant_id = '2'");                 // نطاق الجلسة
await a.query('SELECT count(*) FROM documents');          // يرى صفوف المستأجر 2
a.release();                                               // تمت إعادته بدون إعادة ضبط

const b = await pool.connect();                            // نفس الاتصال المادي
await b.query('SELECT count(*) FROM documents');          // لا يزال يرى المستأجر 2 — تسريب

الطلب الثاني لم يقم بتعيين مستأجر أبداً، ومع ذلك لا يزال current_setting('app.tenant_id') يقرأ 2، لذا فإنه يرى بيانات مستأجر آخر. المساعد withTenant من الخطوة 4 يحل هذه المشكلة تلقائياً: set_config(..., true) محلي للعملية، لذا تختفي القيمة بمجرد انتهاء العملية ولا يمكن للاتصال المسرب حملها للأمام.

هناك تفصيل دقيق في عملية التراجع هذه. بعد تراجع الإعداد المحلي للعملية، لا يصبح المتغير NULL — في الاتصال الذي سبق تعيينه فيه، يعيد current_setting('app.tenant_id', true) سلسلة فارغة:

// نفس الاتصال المادي، مباشرة بعد إتمام عملية withTenant()
const { rows } = await client.query("SELECT current_setting('app.tenant_id', true) AS raw");
console.log(JSON.stringify(rows[0].raw)); // ""  (سلسلة فارغة، وليس null)

إذا كان تحويل النوع في سياستك هو البسيط current_setting('app.tenant_id', true)::bigint، فإن تلك السلسلة الفارغة ستؤدي لخطأ في المرة القادمة التي يتم فيها استخدام الاتصال خارج سياق المستأجر:

error: invalid input syntax for type bigint: ""

هذا هو بالضبط السبب في أن السياسة في الخطوة 2 تغلف القيمة بـ NULLIF(current_setting('app.tenant_id', true), '')::bigint. يقوم NULLIF بتحويل كل من "لم يتم تعيينه أبداً" (NULL) و "تم تعيينه ثم تراجع" (سلسلة فارغة) إلى NULL، و tenant_id = NULL لا يطابق أي صفوف — لذا فإن السياق غير المعين يفشل بأمان إلى صفر صفوف بدلاً من إلقاء خطأ. هذا الاحتراز الوحيد هو الفرق بين سياسة قوية وأخرى تنهار بشكل متقطع تحت الضغط.

الخطوة 6 — منع الكتابة عبر المستأجرين باستخدام WITH CHECK

تحمي USING القراءات. بدون WITH CHECK، لا يزال بإمكان المستأجر القيام بـ INSERT لصف مختوم بـ tenant_id الخاص بشخص آخر. ولأن سياستنا هي FOR ALL مع WITH CHECK صريح، يتم رفض تلك الكتابة. نجاح المستأجر 1 في إدراج صفه الخاص؛ وفشل المستأجر 1 في إدراج صف للمستأجر 2:

// داخل withTenant(pool, 1, ...)
await client.query("INSERT INTO documents (tenant_id, title) VALUES (1, 'Acme Q3 plan')"); // ناجح

await client.query("INSERT INTO documents (tenant_id, title) VALUES (2, 'sneaky')");
// يلقي خطأ: new row violates row-level security policy for table "documents"

يمكنك جعل الوقوع في هذا الخطأ مستحيلاً عن طريق إعطاء tenant_id قيمة افتراضية مستمدة من نفس متغير الجلسة، بحيث لا يقوم كود التطبيق بتعيينها يدوياً أبداً:

ALTER TABLE documents
  ALTER COLUMN tenant_id
  SET DEFAULT NULLIF(current_setting('app.tenant_id', true), '')::bigint;

الآن، أي عملية إدراج تغفل tenant_id يتم ختمها بالمستأجر الحالي تلقائياً:

// داخل withTenant(pool, 2, ...)
const { rows } = await client.query(
  "INSERT INTO documents (title) VALUES ('Globex plan') RETURNING id, tenant_id",
);
console.log(rows[0]); // مثلاً { id: '6', tenant_id: '2' } — تم ملء tenant_id تلقائياً من GUC

تنطبق نفس الحماية على UPDATE و DELETE: عامل تصفية USING الخاص بهما يعني أن المستأجر ببساطة لا يمكنه رؤية صفوف مستأجر آخر لتعديلها، لذا فإن عملية UPDATE عبر المستأجرين ستؤثر على صفر صفوف بدلاً من إثارة خطأ.

الخطوة 7 — الدفاع المتعمق: سياسات FORCE و RESTRICTIVE

أداتان إضافيتان لتقوية الإعداد.

FORCE ROW LEVEL SECURITY تجعل السياسة تنطبق حتى على مالك الجدول:3

ALTER TABLE documents FORCE ROW LEVEL SECURITY;

هذا يهم فقط إذا كان الدور الذي يستخدمه تطبيقك هو أيضاً مالك الجداول — وهو إعداد يجب تجنبه، ولكن FORCE هي شبكة الأمان عندما لا تستطيع ذلك. تنبيه واحد: FORCE لا تؤثر على الـ superusers أو الأدوار التي تملك صلاحية BYPASSRLS. لا شيء سوى سحب تلك الامتيازات سيخضعهم لـ RLS، وهذا هو السبب الحقيقي لإبقاء دور التطبيق بدون امتيازات.

السياسات التقييدية (RESTRICTIVE) تجتمع بشكل مختلف عن الافتراضي. السياسات السماحية (permissive) المتعددة يتم دمجها باستخدام OR (أي واحدة يمكنها منح الوصول)؛ أما السياسات التقييدية فيتم دمجها باستخدام AND، ويجب أن تنجح كل واحدة منها.4 وهذا يجعل السياسة التقييدية مثالية كحاجز حماية غير قابل للتفاوض — على سبيل المثال، "رفض كل الوصول ما لم يتم تعيين مستأجر بالفعل":

CREATE POLICY require_tenant ON documents
  AS RESTRICTIVE
  USING (NULLIF(current_setting('app.tenant_id', true), '') IS NOT NULL);

مع وجود هذا، فإن أي استعلام نسي تعيين app.tenant_id سيتم حظره بواسطة السياسة التقييدية بالإضافة إلى عدم مطابقة أي صفوف من خلال السياسة السماحية. تذكر قاعدة الترتيب: يجب أن تكون هناك سياسة سماحية واحدة على الأقل تمنح الوصول قبل أن تتمكن السياسات التقييدية من تضييقه بشكل مفيد؛ السياسات التقييدية وحدها تمنع كل شيء.4

التحقق من العملية بالكامل

أضمن طريقة للوثوق بقاعدة عزل هي مراقبتها وهي تعمل من دور غير متميز. في psql، انتقل إلى app_user وقم بتعيين مستأجر لكل عملية. (قوائم الصفوف أدناه تفترض بيانات البداية من الخطوة 1؛ إذا قمت أيضاً بتشغيل عمليات الإدراج في الخطوة 6، فسيكون لكل مستأجر صف إضافي واحد.)

-- كدور متميز أولاً، امنح دور الاختبار سياق المستأجر
BEGIN;
SET LOCAL ROLE app_user;
SELECT set_config('app.tenant_id', '1', true);
SELECT tenant_id, title FROM documents ORDER BY id;
--  tenant_id |     title
-- -----------+---------------
--          1 | Acme roadmap
--          1 | Acme invoices
COMMIT;

BEGIN;
SET LOCAL ROLE app_user;
SELECT set_config('app.tenant_id', '2', true);
SELECT tenant_id, title FROM documents ORDER BY id;
--  tenant_id |    title
-- -----------+-------------
--          2 | Globex memo
COMMIT;

يرى المستأجر 1 صفوف Acme فقط؛ ويرى المستأجر 2 صفوف Globex فقط. جرب كتابة عبر المستأجرين وشاهد كيف ترفضها WITH CHECK:

BEGIN;
SET LOCAL ROLE app_user;
SELECT set_config('app.tenant_id', '1', true);
INSERT INTO documents (tenant_id, title) VALUES (2, 'sneaky');
-- ERROR:  new row violates row-level security policy for table "documents"
ROLLBACK;

من Node، يتم تشغيل نفس الفحوصات عبر withTenant. تعيين tenant_id إلى 1 ثم 2 يعيد مجموعات صفوف منفصلة، وعملية الإدراج عبر المستأجرين تثير خطأ — وهذا هو العقد الكامل للميزة، تم التحقق منه.

اختبار عزل المستأجر في CI

العزل هو خاصية تريد لاختبار أن يدافع عنها، لأن أي هجرة مستقبلية — جدول جديد بدون تفعيل RLS، أو سياسة تم حذفها أثناء إعادة الهيكلة — يمكن أن تفتح الباب بهدوء مرة أخرى. يغطي اختباران صغيران باستخدام مشغل الاختبارات المدمج في Node هذا العقد. يتصلان كـ app_user من خلال نفس المساعد withTenant الذي يستخدمه تطبيقك، لذا فهما يختبران السياسة الحقيقية، وليس نموذجاً (mock):

يؤكد الاختبار الأول أن كل صف يقرأه المستأجر ينتمي إلى ذلك المستأجر؛ بينما يؤكد الثاني أن الكتابة عبر المستأجرين يتم رفضها بواسطة WITH CHECK. قم بربط node --test بمسار العمل الخاص بك، وستتحول السياسة المفقودة إلى فشل في البناء (red build) بدلاً من اختراق صامت. تفصيل واحد يستحق الاستيعاب: تعيد مكتبة node-postgres أعمدة bigint كسلاسل نصية (strings) لتجنب فقدان الدقة، وهذا هو سبب مقارنة التأكيد بـ '1' بدلاً من الرقم 1.

استكشاف الأخطاء وإصلاحها

"RLS لا يقوم بتصفية أي شيء." أنت متصل كمستخدم خارق (superuser) أو مالك الجدول؛ كلاهما يتجاوز أمان الصفوف.3 اتصل باستخدام دور app_user غير المتميز (الخطوة 3)، أو قم بتطبيق FORCE ROW LEVEL SECURITY إذا كان دور التطبيق يمتلك الجدول.

"كل استعلام يعيد صفر صفوف." إما أن RLS مفعل بدون سياسة مطبقة (الرفض الافتراضي4)، أو أنك نسيت تعيين app.tenant_id، لذا فإن NULLIF(...) يكون NULL ولا يتطابق شيء. تأكد من المتغير داخل نفس المعاملة (transaction): SELECT current_setting('app.tenant_id', true).

invalid input syntax for type bigint: "". اتصال مجمع (pooled connection) معاد استخدامه يحمل قيمة سلسلة فارغة من إعداد محلي لمعاملة سابقة. قم بالتحويل عبر NULLIF(current_setting('app.tenant_id', true), '')::bigint كما هو موضح في الخطوة 5.

"بيانات المستأجر تتسرب بين الطلبات." لقد استخدمت SET على مستوى الجلسة بدلاً من set_config(..., true) / SET LOCAL المحلي للمعاملة. قم بلف كل طلب في معاملة وقم بتعيين المتغير هناك (الخطوة 4).

خطأ في المفتاح الفريد أو المفتاح الخارجي يكشف عن صف مخفي. تتجاوز فحوصات السلامة المرجعية (Referential-integrity) نظام RLS حسب التصميم، لذا فإن فشل إدراج فريد يمكن أن يكشف عن وجود قيمة في صف لا يمكنك رؤيته.3 حيثما يهم ذلك، استخدم مفاتيح بديلة غامضة بدلاً من مفاتيح ذات معنى خارجي — على سبيل المثال، مفاتيح UUIDv7 الأساسية.

الخطوات التالية

لديك الآن أمان مستوى الصف في Postgres يفرض تعدد المستأجرين من خلال node-postgres: نموذج tenant_id، وسياسة tenant_isolation، ودور بأقل الامتيازات، وجسر مستأجر محلي للمعاملة، و WITH CHECK في عمليات الكتابة — وكلها تم التحقق منها مقابل Postgres 18.

من هنا، هناك أمران يستحقان الاهتمام. أولاً، التجميع (pooling): النمط المحلي للمعاملة هو بالضبط ما يجعل RLS آمناً خلف PgBouncer أو Supavisor في وضع المعاملة، وهو ما تمت تغطيته في دليل تجميع اتصالات Postgres للإنتاج. ثانياً، الأداء: اجعل tenant_id هو العمود الرائد في فهارسك النشطة (الخطوة 1) وتأكد باستخدام EXPLAIN من أن شرط السياسة يستخدمها، وهو نفس انضباط مجموعة المفاتيح المستخدم في التنقل بين الصفحات بالمؤشر (cursor pagination) مع node-postgres.

Footnotes

  1. PostgreSQL 9.5 release announcement — "UPSERT, Row Level Security, and Big Data" (released 2016-01-07). https://www.postgresql.org/about/news/postgresql-95-upsert-row-level-security-and-big-data-1636/

  2. node-postgres (pg) — npm package. https://www.npmjs.com/package/pg

  3. PostgreSQL 18 Documentation — "5.9. Row Security Policies." https://www.postgresql.org/docs/18/ddl-rowsecurity.html 2 3 4 5 6

  4. PostgreSQL 18 Documentation — "CREATE POLICY." https://www.postgresql.org/docs/18/sql-createpolicy.html 2 3 4 5

  5. PostgreSQL 18 Documentation — "9.28.1. Configuration Settings Functions" (current_setting, set_config). https://www.postgresql.org/docs/18/functions-admin.html 2