> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# تطبيق SaaS متعدد المستأجرين عملياً مع Row-Level Security
- URL: https://arabroot.io/articles/بناء-تطبيق-saas-متعدد-المستاجرين-عمليا/
- Published: 2026-10-05T20:45:00.000Z
- Updated: 2026-10-09T09:42:57.000Z
- Description: نبني على PostgreSQL تطبيق SaaS يعزل كل مستأجر Tenant عن غيره بسياسات Row-Level Security، ونرى أين تتسرب البيانات رغم ذلك.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, قواعد البيانات, DevOps وCI/CD, الاستضافة الذاتية

في [مقال تعدد المستأجرين Multi-Tenancy للمبتدئين](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) اخترنا الطريق الذي تبدأ منه أغلب تطبيقات SaaS: جداول مشتركة Shared Tables يحمل كل سطر فيها عمود المستأجر `tenant_id`، وسياسات الصفوف Row-Level Security حارساً أخيراً داخل قاعدة البيانات. وقد يبدو التطبيق من الوهلة الأولى بسيطاً، أمر `ALTER TABLE` وسياسة Policy من سطرين، لكن في الواقع فهو غير كذلك، فمالك الجدول Table Owner يتجاوز السياسة Policy دون أن ينبهك أحد، والإعداد الذي يحمل المستأجر يتسرب من كلاينت إلى آخر خلف PgBouncer، وال View التي أنشأها سكربت الترحيل ترى كل المستأجرين، والجدول الجديد الذي أضافه زميلك أمس ليس عليه سياسة أصلاً. وفي هذا المقال سوف نبني التطبيق خطوة خطوة على PostgreSQL 18، ونكسر كل خطوة عمداً ونرى الخطأ الحقيقي، ثم ننتقل إلى ما بعد العزل: تجهيز المستأجر الجديد Provisioning، وترحيل الجداول على كل المستأجرين، وتصدير مستأجر واحد وحذفه واستعادته ونقله إلى قاعدة بيانات خاصة به.

هذا المقال جزء من سلسلة من مقالين:

- [تعدد المستأجرين Multi-Tenancy في SaaS للمبتدئين](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/): المفاهيم والبنية، ونماذج العزل، وسياق المستأجر Tenant Context، والهوية، والجار المزعج Noisy Neighbor.
- **بناء تطبيق SaaS متعدد المستأجرين عملياً** (الموضوع الحالي).

وسوف نناقش في هذا المقال ما يلي:

- نقطة البداية: المفتاح الأساسي المركب Composite Primary Key الذي يبدأ بعمود المستأجر Tenant، والمفتاح الأجنبي Foreign Key الذي يمنع سطراً من الإشارة إلى مستأجر آخر، والقيود الفريدة داخل المستأجر الواحد.
- سياسات الصفوف Row-Level Security في PostgreSQL خطوة بخطوة: `ENABLE` و`FORCE`، والشرطان `USING` و`WITH CHECK`، والقيمة الفارغة التي تكسر السياسة بعد أول معاملة، والمالك والمستخدم الخارق اللذان يتجاوزانها.
- تمرير المستأجر بالأمر `SET LOCAL` داخل المعاملة، ولماذا يسرب `SET` العادي بيانات مستأجر إلى آخر خلف PgBouncer، مع المخرج الحقيقي.
- الأداء: الفهرس الذي يبدأ بعمود المستأجر، والدوال غير المضمونة LEAKPROOF، وال View ودوال `SECURITY DEFINER` التي تفتح باباً خلفياً.
- جهة التطبيق: Middleware يحدد المستأجر من النطاق الفرعي ويفتح كل معاملة بالمستأجر، والمهام الخلفية، واختبار عزل يكتب نفسه لكل جدول جديد.
- البديل: مخطط لكل مستأجر Schema per Tenant باختصار، وترحيل الجداول على كل المستأجرين مع رقم إصدار لكل مستأجر.
- تجهيز المستأجر بسكربت يمكن تكراره Idempotent، ثم تصدير مستأجر واحد وحذفه واستعادته دون استعادة الجميع، ونقل مستأجر ثقيل إلى قاعدة بيانات خاصة به بالنسخ المنطقي Logical Replication.
- الأخطاء الشائعة، وقائمة تحقق للعزل قبل الإطلاق.

## نقطة البداية: جداول مشتركة Shared Tables وعمود المستأجر Tenant في كل مفتاح

شرحنا في المقال الأول [الخيارات الثلاثة لبيانات المستأجر](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#data-isolation) ومتى تختار كل واحد، ونبدأ هنا من الجداول المشتركة لأنها الأرخص في التشغيل والأصعب في العزل، فإذا فهمت عزلها فهمت الباقي. والقاعدة الأولى أن كل جدول يملكه المستأجر Tenant-owned Table يحمل عمود `tenant_id` غير فارغ، أما الجداول العامة التي يتشاركها الجميع مثل قائمة الدول أو الباقات فلا تحمله. والصورة التالية تبين شكل الجداول الثلاثة التي سوف نستخدمها في المقال:

![ثلاثة جداول: tenants فيه سطر لكل مستأجر مثل acme وglobex، وcustomers بمفتاح أساسي (tenant_id, id) وقيد فريد (tenant_id, email) فيظهر نفس البريد في مستأجرين، وorders بمفتاح أساسي (tenant_id, id) وفهرس (tenant_id, created_at) ومفتاح أجنبي (tenant_id, customer_id) يشير إلى customers، وفي الأسفل محاولة إدخال طلب للمستأجر 1 يشير إلى العميل 2 الذي يملكه المستأجر 2 فترفضها قاعدة البيانات](https://arabroot.io/content/images/2026/10/multi-tenancy-practice-01-shared-schema.webp)

عمود المستأجر في أول كل مفتاح، فلا يستطيع سطر أن يشير إلى سطر مستأجر آخر

### المفتاح الأساسي المركب Composite Primary Key ولماذا يبدأ بالمستأجر Tenant

الطريقة المعتادة أن يكون المفتاح الأساسي Primary Key هو `id` وحده، ثم تضيف `tenant_id` عموداً عادياً، وهذا يعمل، ولكنك سوف تحتاج فهرساً Index على `tenant_id` في كل جدول لأن كل استعلام تقريباً يبدأ بالمستأجر، ولن تستطيع أن تبني مفتاحاً أجنبياً يتحقق من المستأجر كما سيأتي. لذلك نجعل المفتاح الأساسي `(tenant_id, id)`، فالفهرس الذي ينشئه PostgreSQL للمفتاح يخدم كل استعلام يبدأ بالمستأجر، والسبب أن فهرس B-tree المركب يستخدم بكفاءة عندما يبدأ الشرط بأول أعمدته، كما شرحنا في [قسم الفهرس المركب في مقال الجداول الضخمة](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/#btree-composite). ونفس الفكرة لباقي الفهارس، فالفهرس الذي يخدم «آخر طلبات هذا المستأجر» هو `(tenant_id, created_at)` وليس `(created_at)`، وسوف ترى الفرق في خطة التنفيذ في [قسم الأداء](#rls-performance). أما نوع المعرف نفسه، رقم متسلسل أو UUID، فقد ناقشناه في [مقال المعرفات الفريدة](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%B1%D9%81%D8%A7%D8%AA-%D8%A7%D9%84%D9%81%D8%B1%D9%8A%D8%AF%D8%A9-uuid-%D9%88-ulid-%D9%88-snowflake/)، ويهمك هنا أن يكون فريداً على مستوى قاعدة البيانات كلها إذا كنت تنوي نقل مستأجر بين قواعد البيانات يوماً ما.

### المفتاح الأجنبي Foreign Key الذي يحمل المستأجر Tenant

شرحنا المفتاح الأجنبي في [مقال تصميم قواعد البيانات](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D8%B9-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D8%B3%D8%B9/#foreign-key)، والمشكلة في نظام متعدد المستأجرين أن المفتاح العادي `customer_id REFERENCES customers (id)` يتحقق فقط من أن العميل موجود، ولا يتحقق من أنه عميل نفس المستأجر، فيستطيع خطأ في الكود أن ينشئ طلباً للمستأجر acme يشير إلى عميل من globex. والحل أن يحمل المفتاح الأجنبي عمود المستأجر أيضاً: `FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id)`، وهذا ممكن لأن [وثائق القيود](https://www.postgresql.org/docs/18/ddl-constraints.html?ref=arabroot.io) تشترط أن يشير المفتاح الأجنبي إلى أعمدة مفتاح أساسي أو قيد فريد، و`(tenant_id, id)` هو المفتاح الأساسي في جدولنا. وبهذا يصبح «طلب مستأجر يشير إلى عميل مستأجر آخر» مستحيلاً في قاعدة البيانات نفسها، وسوف ترى الخطأ الحقيقي في المختبر.

### القيود الفريدة Unique Constraints داخل المستأجر Tenant الواحد

القيد `UNIQUE (email)` على جدول العملاء يعني أن البريد لا يتكرر في قاعدة البيانات كلها، فإذا سجل نفس الشخص عميلاً عند شركتين رفضت الثانية، وهذا خطأ في المنطق وتسرب للمعلومات في نفس الوقت، لأن رسالة الرفض تخبر globex أن هذا البريد موجود عند مستأجر آخر. لذلك القيد الصحيح `UNIQUE (tenant_id, email)`، وكذلك رقم الفاتورة المتسلسل واسم المستخدم وكل ما يجب أن يكون فريداً «عند هذا العميل». ولاحظ أن [وثائق CREATE POLICY](https://www.postgresql.org/docs/18/sql-createpolicy.html?ref=arabroot.io) تنبه إلى أن فحص القيود الفريدة يتجاوز سياسات الصفوف، فالقيد الفريد على مستوى الجدول كله قناة جانبية Side Channel يعرف منها المستأجر أن قيمة ما موجودة عند غيره حتى مع RLS.

## تجهيز المختبر Lab: مئتا مستأجر Tenant ومليون طلب

كل الأوامر في هذا المقال نفذناها على جهاز واحد عليه Docker Engine 29.8.0، والمنافذ مربوطة على العنوان المحلي فقط، وإذا لم يكن Docker مثبتاً لديك فابدأ بدليل [تثبيت Docker على Ubuntu](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-docker-%D8%B9%D9%84%D9%89-ubuntu/). نبدأ بالشبكة وPostgreSQL 18، ونفعل `wal_level=logical` من الآن لأننا سوف نحتاجه في آخر المقال لنقل مستأجر:

```bash
docker network create lab-mtp-net
docker run -d --name lab-mtp-pg --network lab-mtp-net -p 127.0.0.1:19680:5432 \
  -e POSTGRES_PASSWORD=labpass -e POSTGRES_DB=saas \
  postgres:18 -c wal_level=logical
docker exec lab-mtp-pg psql -U postgres -Atc "SELECT version()"
```

```text
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
```

الآن ملف الجداول `01-schema.sql`، ولاحظ أننا ننشئ دورين Roles من البداية، واحداً يملك الجداول ويشغل الترحيل، والآخر هو الذي يتصل به التطبيق، والسبب سوف يتضح في [قسم المالك](#owner-bypass):

```sql
-- roles: one owns the tables and runs migrations, the other is what the app logs in with
CREATE ROLE saas_owner LOGIN PASSWORD 'ownerpass';
CREATE ROLE app_user   LOGIN PASSWORD 'apppass';
GRANT CREATE ON SCHEMA public TO saas_owner;
GRANT USAGE  ON SCHEMA public TO app_user;
SET ROLE saas_owner;

CREATE TABLE tenants (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    slug       text NOT NULL UNIQUE,
    name       text NOT NULL,
    plan       text NOT NULL DEFAULT 'free',
    db_host    text NOT NULL DEFAULT 'lab-mtp-pg',   -- where this tenant's rows live
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE customers (
    tenant_id bigint NOT NULL REFERENCES tenants (id),
    id        bigint GENERATED ALWAYS AS IDENTITY,
    name      text NOT NULL,
    email     text NOT NULL,
    PRIMARY KEY (tenant_id, id),
    UNIQUE (tenant_id, email)                 -- the same email may exist in two tenants
);

CREATE TABLE orders (
    tenant_id   bigint NOT NULL,
    id          bigint GENERATED ALWAYS AS IDENTITY,
    customer_id bigint NOT NULL,
    total       numeric(10,2) NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (tenant_id, id),
    FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id)
);
CREATE INDEX orders_tenant_created ON orders (tenant_id, created_at);

GRANT SELECT ON tenants TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON customers, orders TO app_user;
RESET ROLE;
```

ثم البيانات في `02-seed.sql`: المستأجران acme وglobex ثم 198 مستأجراً آخر، و50 عميلاً لكل مستأجر، و100 طلب لكل عميل، أي مليون طلب:

```sql
SET ROLE saas_owner;
INSERT INTO tenants (slug, name, plan) VALUES ('acme', 'Acme', 'pro'), ('globex', 'Globex', 'free');
INSERT INTO tenants (slug, name) SELECT 'tenant-' || g, 'Tenant ' || g FROM generate_series(3, 200) g;
INSERT INTO customers (tenant_id, name, email)
SELECT t.id, 'Customer ' || c, 'c' || c || '@example.com'
FROM tenants t, generate_series(1, 50) c;
INSERT INTO orders (tenant_id, customer_id, total, created_at)
SELECT c.tenant_id, c.id, (random() * 500)::numeric(10,2), now() - random() * interval '365 days'
FROM customers c, generate_series(1, 100);
ANALYZE tenants, customers, orders;
SELECT (SELECT count(*) FROM tenants) AS tenants, (SELECT count(*) FROM customers) AS customers, (SELECT count(*) FROM orders) AS orders;
```

```bash
docker exec -i lab-mtp-pg psql -U postgres -d saas < 01-schema.sql
docker exec -i lab-mtp-pg psql -U postgres -d saas < 02-seed.sql
```

والمخرج الأخير سوف يكون كما يلي:

```text
INSERT 0 2
INSERT 0 198
INSERT 0 10000
INSERT 0 1000000
ANALYZE
 tenants | customers | orders
---------+-----------+---------
     200 |     10000 | 1000000
(1 row)
```

ولاحظ أن ترتيب الإدخال جعل أرقام الطلبات متداخلة بين المستأجرين، فالطلب 202 مثلاً يملكه globex، وهذا يشبه الواقع حيث الأرقام تأتي من نفس ال Sequence لكل المستأجرين.

## الطريقة البسيطة: شرط المستأجر tenant\_id في كل استعلام Query

أول ما يفعله كل فريق هو أن يضيف `WHERE tenant_id = ?` إلى كل استعلام، وهذا صحيح وضروري، ولكنه يعتمد على أن كل مطور تذكره في كل استعلام وكل تقرير وكل سكربت إلى الأبد. والملف `03-leak.sql` يمثل الصفحة `GET /orders/202` عندما يطلبها مستخدم من acme، مرة دون الشرط ومرة معه، ثم يحاول إنشاء طلب للمستأجر 1 يشير إلى العميل 2 الذي يملكه globex:

```sql
-- GET /orders/202 by a user of tenant 1 (acme), written without the tenant filter
SELECT tenant_id, id, customer_id, total FROM orders WHERE id = 202;
-- the same endpoint with the filter
SELECT tenant_id, id, customer_id, total FROM orders WHERE tenant_id = 1 AND id = 202;
-- an order for tenant 1 that points at customer 2, who belongs to tenant 2
INSERT INTO orders (tenant_id, customer_id, total) VALUES (1, 2, 10.00);
```

```bash
docker exec -i -e PGPASSWORD=apppass lab-mtp-pg sh -c 'psql -h 127.0.0.1 -U app_user -d saas 2>&1' < 03-leak.sql
```

والمخرج سوف يكون كما يلي:

```text
 tenant_id | id  | customer_id | total
-----------+-----+-------------+--------
         2 | 202 |         202 | 204.51
(1 row)

 tenant_id | id | customer_id | total
-----------+----+-------------+-------
(0 rows)

ERROR:  insert or update on table "orders" violates foreign key constraint "orders_tenant_id_customer_id_fkey"
DETAIL:  Key (tenant_id, customer_id)=(1, 2) is not present in table "customers".
```

في المخرج أعلاه لاحظ التالي:

- الاستعلام الأول أعاد طلب globex لمستخدم من acme، وهذا هو التسرب الكلاسيكي: مستخدم يغير الرقم في الرابط فيرى بيانات شركة أخرى، ويسمى هذا النوع من الثغرات IDOR أو Broken Object Level Authorization.
- الاستعلام الثاني بالشرط الصحيح أعاد صفراً، والفرق بينهما كلمتان نسيهما مطور في يوم مزدحم.
- المفتاح الأجنبي المركب رفض الطلب الذي يشير إلى عميل مستأجر آخر، فهذا الجزء محمي في قاعدة البيانات مهما أخطأ الكود.

وقد يتساءل البعض: لماذا لا نكتفي بمراجعة الكود Code Review وبطبقة ORM تضيف الشرط تلقائياً؟ والإجابة أن هذا هو خط الدفاع الأول ويجب أن يبقى، ولكنه يغطي الاستعلامات التي تمر من ال ORM فقط، أما الاستعلام الخام Raw SQL في تقرير، أو سكربت إصلاح بيانات، أو أداة تحليل تتصل بقاعدة البيانات مباشرة، فلا يمر منه. لذلك نضيف خطاً ثانياً داخل قاعدة البيانات نفسها لا يعتمد على ذاكرة أحد، وهو سياسات الصفوف.

## سياسات الصفوف Row-Level Security خطوة بخطوة

شرحنا فكرة سياسات الصفوف Row-Level Security واختصاراً RLS في [المقال الأول](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#rls-guard)، ونطبقها هنا كاملة. وتعمل RLS على مرحلتين، فبعد أن تفعلها على الجدول يضيف PostgreSQL شرط السياسة إلى كل استعلام لكل دور تنطبق عليه، والصورة التالية تبين الشرطين اللذين تكتبهما في السياسة:

![التطبيق بالدور app_user ومعه app.tenant_id يساوي 1 يمر عبر السياسة tenant_isolation، وفيها شرطان: 1 الشرط USING على الأسطر الموجودة في SELECT وUPDATE وDELETE فتظهر أسطر المستأجر 1 ويختفي سطر المستأجر 2 دون خطأ، و2 الشرط WITH CHECK على الأسطر الجديدة في INSERT وUPDATE فيقبل السطر الجديد للمستأجر 1 ويرفض السطر الجديد للمستأجر 2 بالخطأ new row violates row-level security policy](https://arabroot.io/content/images/2026/10/multi-tenancy-practice-02-rls-check.webp)

السطر الذي لا يمر من USING يختفي بصمت، والسطر الجديد الذي لا يمر من WITH CHECK يوقف الأمر كله بخطأ

- **الشرط `USING`:** يطبق على الأسطر الموجودة، أي ما يراه `SELECT` وما يستطيع `UPDATE` و`DELETE` الوصول إليه، والسطر الذي لا يحقق الشرط يختفي بصمت دون خطأ كما في [وثائق CREATE POLICY](https://www.postgresql.org/docs/18/sql-createpolicy.html?ref=arabroot.io).
- **الشرط `WITH CHECK`:** يطبق على الأسطر الجديدة أو بعد تعديلها في `INSERT` و`UPDATE`، والسطر الذي لا يحققه يوقف الأمر بخطأ. وإذا كتبت سياسة لكل الأوامر `FOR ALL` بشرط `USING` وحده، فإن PostgreSQL يستخدمه للحالتين، ولكننا نكتب الاثنين صراحة حتى يقرأ من بعدك ما تقصده.

### تفعيل RLS وكتابة السياسة Policy الأولى

الملف `04-rls.sql` يفعل RLS على الجدولين ويكتب سياسة تقرأ المستأجر الحالي من إعداد اسمه `app.tenant_id`، وهذا إعداد مخصص Custom Option، فتقبل [وثائق الإعدادات المخصصة](https://www.postgresql.org/docs/18/runtime-config-custom.html?ref=arabroot.io) أي اسم من جزأين بينهما نقطة، والدالة `current_setting('app.tenant_id', true)` تعيد قيمته، والوسيط الثاني `true` يجعلها تعيد NULL بدل الخطأ إذا لم يكن الإعداد موجوداً، كما في [وثائق دوال الإدارة](https://www.postgresql.org/docs/18/functions-admin.html?ref=arabroot.io):

```sql
SET ROLE saas_owner;
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders    ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON customers TO app_user
    USING      (tenant_id = current_setting('app.tenant_id', true)::bigint)
    WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::bigint);
CREATE POLICY tenant_isolation ON orders TO app_user
    USING      (tenant_id = current_setting('app.tenant_id', true)::bigint)
    WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::bigint);
```

ثم نختبرها بالدور `app_user` بالملف `05-rls-test.sql`: مرة دون مستأجر، ثم كمستأجر acme داخل معاملة Transaction نعد طلباتنا ونطلب الطلب 202 ونحاول إدخال طلب لغيرنا، ثم نحاول نقل أحد طلباتنا إلى globex، ثم نحاول حذف طلب globex، وفي النهاية نعد مرة أخرى في نفس الجلسة بعد انتهاء المعاملات:

```sql
-- 1) no tenant context at all
SELECT count(*) FROM orders;
-- 2) tenant 1, inside a transaction
BEGIN;
SET LOCAL app.tenant_id = '1';
SELECT count(*) AS my_orders FROM orders;
SELECT tenant_id, id, total FROM orders WHERE id = 202;
INSERT INTO orders (tenant_id, customer_id, total) VALUES (2, 2, 10.00);
ROLLBACK;
-- 3) tries to move one of its own orders to tenant 2
BEGIN;
SET LOCAL app.tenant_id = '1';
UPDATE orders SET tenant_id = 2 WHERE id = 1;
ROLLBACK;
-- 4) delete everything: only its own rows are visible to DELETE
BEGIN;
SET LOCAL app.tenant_id = '1';
DELETE FROM orders WHERE id = 202;
ROLLBACK;
-- 5) the same session after the transaction ended
SELECT count(*) FROM orders;
```

```bash
docker exec -i lab-mtp-pg psql -U postgres -d saas < 04-rls.sql
docker exec -i -e PGPASSWORD=apppass lab-mtp-pg sh -c 'psql -h 127.0.0.1 -U app_user -d saas 2>&1' < 05-rls-test.sql
```

والمخرج سوف يكون كما يلي:

```text
 count
-------
     0
(1 row)

BEGIN
SET
 my_orders
-----------
      5000
(1 row)

 tenant_id | id | total
-----------+----+-------
(0 rows)

ERROR:  new row violates row-level security policy for table "orders"
ROLLBACK
BEGIN
SET
ERROR:  new row violates row-level security policy for table "orders"
ROLLBACK
BEGIN
SET
DELETE 0
ROLLBACK
ERROR:  invalid input syntax for type bigint: ""
```

في المخرج أعلاه لاحظ التالي:

- دون مستأجر لا يظهر أي طلب، لأن `current_setting` أعاد NULL والمقارنة مع NULL ليست صحيحة، وهذا هو السلوك الآمن الذي نريده: نسيان المستأجر يعطي صفحة فارغة وليس بيانات الجميع.
- كمستأجر acme رأينا 5000 طلب، أي طلباتنا وحدها، والطلب 202 الذي تسرب في القسم السابق أصبح صفراً دون خطأ، وهذا عمل `USING`.
- إدخال طلب ل globex، ونقل طلبنا إلى globex بالأمر `UPDATE`، رفضهما `WITH CHECK` بالخطأ `new row violates row-level security policy for table "orders"`.
- الحذف أعاد `DELETE 0`، لأن `DELETE` لا يرى إلا ما يسمح به `USING`.
- السطر الأخير خطأ لم نتوقعه: نفس الاستعلام الذي أعاد صفراً في البداية أصبح يفشل بالخطأ `invalid input syntax for type bigint: ""`، وهذا موضوع القسم التالي.

### القيمة الفارغة Empty String التي تكسر السياسة Policy بعد أول معاملة Transaction

الإعداد المخصص لا يوجد في الجلسة حتى تضعه أول مرة، فتعيد الدالة NULL، ولكن بعد أن تضعه بالأمر `SET LOCAL` وتنتهي المعاملة، يبقى الإعداد معرفاً في الجلسة بقيمة نصية فارغة وليس NULL، وتحويل النص الفارغ إلى `bigint` يفشل. والملف `07-after-tx.sql` يبين ذلك بعد إصلاح السياسة:

```sql
SELECT current_setting('app.tenant_id', true) IS NULL AS is_null;
BEGIN;
SET LOCAL app.tenant_id = '1';
SELECT count(*) FROM orders;
COMMIT;
SELECT current_setting('app.tenant_id', true) IS NULL AS is_null,
       current_setting('app.tenant_id', true) = '' AS is_empty;
SELECT count(*) FROM orders;
```

والإصلاح في `06-fix-policy.sql` أن نحول النص الفارغ إلى NULL بالدالة `NULLIF` قبل التحويل إلى رقم:

```sql
SET ROLE saas_owner;
ALTER POLICY tenant_isolation ON customers
    USING      (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
    WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint);
ALTER POLICY tenant_isolation ON orders
    USING      (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
    WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint);
```

وبعد تنفيذ الإصلاح ثم `07-after-tx.sql` بالدور `app_user` كان المخرج كما يلي:

```text
 is_null
---------
 t
(1 row)

BEGIN
SET
 count
-------
  5000
(1 row)

COMMIT
 is_null | is_empty
---------+----------
 f       | t
(1 row)

 count
-------
     0
(1 row)
```

لاحظ أن القيمة قبل المعاملة كانت NULL وبعدها نص فارغ، ومع `NULLIF` أصبح الاستعلام الأخير يعيد صفراً بدل الخطأ، وعند إعادة `05-rls-test.sql` كاملاً انتهى بنفس النتيجة `0` في السطر الأخير. وهذا الخطأ لا يظهر في الاختبار السريع على اتصال جديد، ويظهر في الإنتاج Production على اتصال من المجمع Pool استخدمه طلب قبلك، لذلك اكتب `NULLIF` في السياسة من اليوم الأول.

### مالك الجدول Table Owner والمستخدم الخارق Superuser يتجاوزان السياسة

تقول [وثائق سياسات الصفوف](https://www.postgresql.org/docs/18/ddl-rowsecurity.html?ref=arabroot.io) إن المستخدم الخارق Superuser والأدوار التي لها الخاصية `BYPASSRLS` تتجاوز السياسات دائماً، وإن مالك الجدول Table Owner يتجاوزها أيضاً إلا إذا فعلت `FORCE ROW LEVEL SECURITY`. وهذه أشهر طريقة يفشل بها RLS دون أن يلاحظ أحد: يتصل التطبيق بنفس الدور الذي أنشأ الجداول، لأن هذا ما في ملف الإعداد من أيام التطوير، فتعمل السياسات في الاختبار اليدوي وتتجاوز في التطبيق. والملف `08-bypass.sql` يبين ذلك:

```sql
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname IN ('postgres', 'saas_owner', 'app_user') ORDER BY 1;
SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname IN ('customers', 'orders') ORDER BY 1;
SET ROLE saas_owner;
SELECT current_user, count(*) FROM orders;
RESET ROLE;
SELECT current_user, count(*) FROM orders;
-- make the owner obey RLS too
ALTER TABLE orders    FORCE ROW LEVEL SECURITY;
ALTER TABLE customers FORCE ROW LEVEL SECURITY;
SET ROLE saas_owner;
SELECT current_user, count(*) FROM orders;
RESET ROLE;
SELECT current_user, count(*) FROM orders;
```

والمخرج سوف يكون كما يلي:

```text
  rolname   | rolsuper | rolbypassrls
------------+----------+--------------
 app_user   | f        | f
 postgres   | t        | t
 saas_owner | f        | f
(3 rows)

  relname  | relrowsecurity | relforcerowsecurity
-----------+----------------+---------------------
 customers | t              | f
 orders    | t              | f
(2 rows)

SET
 current_user |  count
--------------+---------
 saas_owner   | 1000000
(1 row)

RESET
 current_user |  count
--------------+---------
 postgres     | 1000000
(1 row)

ALTER TABLE
ALTER TABLE
SET
 current_user | count
--------------+-------
 saas_owner   |     0
(1 row)

RESET
 current_user |  count
--------------+---------
 postgres     | 1000000
(1 row)
```

في المخرج أعلاه لاحظ التالي:

- المالك `saas_owner` رأى المليون طلب كلها دون أي مستأجر، مع أن RLS مفعل على الجدول.
- بعد `FORCE ROW LEVEL SECURITY` أصبح المالك يرى صفراً، لأن سياستنا مكتوبة `TO app_user` فلا توجد سياسة تنطبق على المالك، وعندها يطبق PostgreSQL المنع الافتراضي Default Deny.
- المستخدم الخارق `postgres` رأى كل شيء قبل `FORCE` وبعده، فلا يوجد شيء يقيده.

لذلك قسم الأدوار هكذا، وكل دور منها ينشأ بالأمر [CREATE ROLE](https://www.postgresql.org/docs/18/sql-createrole.html?ref=arabroot.io):

- **دور التطبيق `app_user`:** ليس مالكاً وليس خارقاً وليس له `BYPASSRLS`، وله صلاحيات `SELECT` و`INSERT` و`UPDATE` و`DELETE` فقط، ولا يستطيع تغيير السياسات أو حذفها.
- **دور المالك `saas_owner`:** يملك الجداول ويشغل الترحيل Migrations، ولا يتصل به التطبيق، ومع `FORCE` لا يرى بيانات المستأجرين، ولاحظ أن الترحيل الذي يملأ عموداً جديداً Backfill يحتاج حينها أن يعمل بدور يرى البيانات.
- **دور العمليات `saas_ops`:** له `BYPASSRLS` للتصدير والحذف والاستعادة على مستوى المستأجر، ويستخدمه سكربت تمت مراجعته فقط، وسوف نستخدمه في [قسم العمليات](#tenant-ops).
- **المستخدم الخارق `postgres`:** للإدارة وحدها، ولا يكتب في ملف إعداد أي تطبيق.

### تمرير المستأجر Tenant بالأمر SET LOCAL داخل المعاملة Transaction

بقي سؤال واحد: كيف تخبر قاعدة البيانات بالمستأجر الحالي؟ والإجابة أن تبدأ كل معاملة بالأمر `SET LOCAL app.tenant_id = '1'`، أو بالدالة `set_config('app.tenant_id', '1', true)` وهي نفس الأمر في شكل دالة تقبل القيمة كوسيط، فتستطيع تمريرها من الكود دون أن تبني نص SQL بنفسك، والوسيط الثالث `true` يعني أن القيمة للمعاملة الحالية فقط كما في [وثائق set\_config](https://www.postgresql.org/docs/18/functions-admin.html?ref=arabroot.io). وتقول [وثائق SET](https://www.postgresql.org/docs/18/sql-set.html?ref=arabroot.io) إن أثر `SET LOCAL` ينتهي مع نهاية المعاملة سواء انتهت ب `COMMIT` أو `ROLLBACK`، وإن تنفيذه خارج معاملة يعطي تحذيراً ولا أثر له. والتحذير بالمخرج الحقيقي عندما ينسى الكود `BEGIN`:

```sql
SET LOCAL app.tenant_id = '1';
SELECT count(*) FROM orders;
```

```text
WARNING:  SET LOCAL can only be used in transaction blocks
SET
 count
-------
     0
(1 row)
```

لاحظ أن النتيجة صفر وليست بيانات أحد، فالخطأ هنا آمن، ولكن ابحث عن هذا التحذير في سجلات قاعدة البيانات، لأنه يعني أن جزءاً من الكود لا يفتح معاملة. وقد يتساءل البعض: لماذا لا نستخدم `SET` العادي مرة واحدة عند فتح الاتصال؟ والإجابة في القسم التالي.

### لماذا يسرب SET العادي المستأجر Tenant خلف PgBouncer؟

شرحنا في [مقال تجميع الاتصالات](https://arabroot.io/articles/%D8%AA%D8%AC%D9%85%D9%8A%D8%B9-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84%D8%A7%D8%AA-connection-pooling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#transaction-mode-breaks) أن PgBouncer في وضع `transaction` يعيد الاتصال الحقيقي إلى المجمع بعد كل معاملة، وأن الأمر `SET` لا يعمل في هذا الوضع كما في [جدول الميزات في وثائق PgBouncer](https://www.pgbouncer.org/features.html?ref=arabroot.io)، ورأينا هناك [إعداداً يضيع من صاحبه ويرثه غيره](https://arabroot.io/articles/%D8%AA%D8%AC%D9%85%D9%8A%D8%B9-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84%D8%A7%D8%AA-connection-pooling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#lab-set-pitfall). والإعداد في ذلك المثال كان مهلة استعلام، أما هنا فهو هوية المستأجر، فالخطأ نفسه يصبح تسرباً لبيانات عميل إلى عميل آخر. والصورة التالية تلخص ما سوف نراه بالمخرج:

![صفان لنفس الاتصال الحقيقي خلف PgBouncer: 1 مع SET العادي يضع A المستأجر 1 ثم B المستأجر 2 فيبقى المستأجر 2 على الاتصال حتى يغلق، فيرى A في استعلامه التالي والكلاينت C الذي لم يحدد مستأجراً طلبات المستأجر 2، و2 مع SET LOCAL كل معاملة تحمل مستأجرها ولا يبقى شيء بعد COMMIT إلا نص فارغ، فيرى A المستأجر 1 فقط ولا يرى C شيئاً](https://arabroot.io/content/images/2026/10/multi-tenancy-practice-04-set-vs-set-local.webp)

الإعداد العادي يبقى على الاتصال الحقيقي، والكلاينت التالي يرث المستأجر مع الاتصال

نبني PgBouncer 1.26 في Docker Image صغيرة فوق `postgres:18` كما فعلنا في مقال تجميع الاتصالات، ونضبط المجمع على اتصالين حقيقيين فقط حتى يتكرر الاتصال بوضوح:

```dockerfile
FROM postgres:18
RUN apt-get update && apt-get install -y --no-install-recommends pgbouncer && rm -rf /var/lib/apt/lists/*
USER postgres
CMD ["pgbouncer", "/etc/pgbouncer/pgbouncer.ini"]
```

```ini
[databases]
saas = host=lab-mtp-pg port=5432 dbname=saas

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
default_pool_size = 2
max_client_conn = 200
```

```bash
docker build -t lab-mtp-pgbouncer:local -f Dockerfile.pgbouncer .
docker exec lab-mtp-pg psql -U postgres -d saas -Atc \
  "SELECT format('\"%s\" \"%s\"', rolname, rolpassword) FROM pg_authid WHERE rolname='app_user';" > userlist.txt
docker run -d --name lab-mtp-bouncer --network lab-mtp-net -p 127.0.0.1:19681:6432 \
  -v "$PWD/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro" \
  -v "$PWD/userlist.txt:/etc/pgbouncer/userlist.txt:ro" \
  lab-mtp-pgbouncer:local
```

والسكربت `set-leak.sh` يمثل ثلاثة طلبات تمر من PgBouncer: الطلب A من acme يضع المستأجر بالأمر `SET` ثم ينتظر ثانية في جهة الكلاينت، وأثناء انتظاره يأتي الطلب B من globex ويضع مستأجره بنفس الطريقة، ثم يسأل A مرة ثانية، وبعد انتهاء الاثنين يتصل كلاينت ثالث C لم يحدد أي مستأجر:

```bash
#!/bin/sh
# set-leak.sh: request A (acme) and request B (globex) each use plain SET through PgBouncer in transaction mode.
P="psql -h lab-mtp-bouncer -p 6432 -U app_user -d saas -Atq"
Q="SELECT 'pid=' || pg_backend_pid() || ' app.tenant_id=' || current_setting('app.tenant_id', true)
          || ' sees tenants=' || coalesce(string_agg(DISTINCT tenant_id::text, ','), 'none') FROM orders"
$P <<SQL &
SET app.tenant_id = '1';
SELECT 'A1 acme    ' || ($Q);
\! sleep 1
SELECT 'A2 acme    ' || ($Q);
SQL
sleep 0.3
$P <<SQL &
SET app.tenant_id = '2';
SELECT 'B1 globex  ' || ($Q);
SQL
wait
$P -c "SELECT 'C  nobody   ' || ($Q);"
```

```bash
docker run --rm --network lab-mtp-net -e PGPASSWORD=apppass \
  -v "$PWD/set-leak.sh:/set-leak.sh:ro" postgres:18 sh /set-leak.sh
```

والمخرج سوف يكون كما يلي:

```text
A1 acme    pid=332 app.tenant_id=1 sees tenants=1
B1 globex  pid=332 app.tenant_id=2 sees tenants=2
A2 acme    pid=332 app.tenant_id=2 sees tenants=2
C  nobody   pid=332 app.tenant_id=2 sees tenants=2
```

في المخرج أعلاه لاحظ التالي:

- الطلبات الأربعة نفذت كلها على نفس العملية 332 في PostgreSQL، لأن PgBouncer أعطى نفس الاتصال الحقيقي لكل من جاء بعد انتهاء معاملة من قبله.
- B غير المستأجر على الاتصال إلى 2، فعندما سأل A مرة ثانية رأى طلبات globex، أي أن مستخدم acme يرى الآن بيانات عميل آخر، وRLS يعمل بشكل صحيح تماماً لأنه يطبق المستأجر الذي وجده على الاتصال.
- C لم يحدد أي مستأجر، ومع ذلك رأى globex، وهذا يكسر الضمان الذي بنينا عليه كل شيء، وهو أن نسيان المستأجر يعطي صفراً.

والسكربت `set-local.sh` هو نفسه، ولكن كل طلب يفتح معاملة ويضع المستأجر فيها بالأمر `SET LOCAL`:

```bash
#!/bin/sh
# set-local.sh: the same two requests, but every transaction starts with SET LOCAL.
P="psql -h lab-mtp-bouncer -p 6432 -U app_user -d saas -Atq"
Q="SELECT 'pid=' || pg_backend_pid() || ' app.tenant_id=' || coalesce(current_setting('app.tenant_id', true), 'null')
          || ' sees tenants=' || coalesce(string_agg(DISTINCT tenant_id::text, ','), 'none') FROM orders"
$P <<SQL &
BEGIN; SET LOCAL app.tenant_id = '1'; SELECT 'A1 acme    ' || ($Q); COMMIT;
\! sleep 1
BEGIN; SET LOCAL app.tenant_id = '1'; SELECT 'A2 acme    ' || ($Q); COMMIT;
SQL
sleep 0.3
$P <<SQL &
BEGIN; SET LOCAL app.tenant_id = '2'; SELECT 'B1 globex  ' || ($Q); COMMIT;
SQL
wait
$P -c "SELECT 'C  nobody   ' || ($Q);"
```

```bash
docker restart lab-mtp-bouncer   # start with fresh server connections
docker run --rm --network lab-mtp-net -e PGPASSWORD=apppass \
  -v "$PWD/set-local.sh:/set-local.sh:ro" postgres:18 sh /set-local.sh
```

```text
A1 acme    pid=334 app.tenant_id=1 sees tenants=1
B1 globex  pid=334 app.tenant_id=2 sees tenants=2
A2 acme    pid=334 app.tenant_id=1 sees tenants=1
C  nobody   pid=334 app.tenant_id= sees tenants=none
```

نفس الاتصال الحقيقي 334 خدم الطلبات الأربعة، ولكن كل معاملة حملت مستأجرها وانتهى معها، وC وجد النص الفارغ الذي رأيناه في [قسم القيمة الفارغة](#empty-setting) فلم ير شيئاً. لذلك القاعدة: لا تقم بوضع المستأجر بالأمر `SET` أو `set_config(..., false)` أبداً، والسبب أن أي مجمع اتصالات، PgBouncer أو المجمع داخل التطبيق نفسه، سوف يعطي الاتصال يوماً ما لطلب آخر، فاستخدم `SET LOCAL` داخل معاملة صريحة في كل مرة، حتى لو لم يكن لديك PgBouncer اليوم.

### الأداء Performance: السياسة شرط إضافي يحتاج فهرساً Index

شرط السياسة يضاف إلى كل استعلام، فإذا لم يجد فهرساً يبدأ بعمود المستأجر قرأت قاعدة البيانات أسطر كل المستأجرين ثم استبعدت أغلبها. والملف `09-explain.sql` يطلب آخر عشرين طلباً للمستأجر 7، ولاحظ أن الاستعلام نفسه لا يحتوي `tenant_id` أصلاً، فالسياسة هي التي تضيفه:

```sql
BEGIN;
SET LOCAL app.tenant_id = '7';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, total, created_at FROM orders ORDER BY created_at DESC LIMIT 20;
COMMIT;
```

مع الفهرس `orders_tenant_created` على `(tenant_id, created_at)` كان المخرج كما يلي:

```text
 Limit (actual rows=20.00 loops=1)
   Buffers: shared hit=23
   ->  Index Scan Backward using orders_tenant_created on orders (actual rows=20.00 loops=1)
         Index Cond: (tenant_id = (NULLIF(current_setting('app.tenant_id'::text, true), ''::text))::bigint)
         Index Searches: 1
         Buffers: shared hit=23
 Planning:
   Buffers: shared hit=134 dirtied=1
```

ثم حذفنا هذا الفهرس ووضعنا مكانه فهرساً على `created_at` وحده، وهو الفهرس الذي يضعه أغلب الناس لهذه الصفحة في تطبيق لعميل واحد:

```sql
SET ROLE saas_owner;
DROP INDEX orders_tenant_created;
CREATE INDEX orders_created ON orders (created_at);
```

```text
 Limit (actual rows=20.00 loops=1)
   Buffers: shared hit=2767 read=11
   ->  Index Scan Backward using orders_created on orders (actual rows=20.00 loops=1)
         Filter: (tenant_id = (NULLIF(current_setting('app.tenant_id'::text, true), ''::text))::bigint)
         Rows Removed by Filter: 2747
         Index Searches: 1
         Buffers: shared hit=2767 read=11
 Planning:
   Buffers: shared hit=131 read=1
```

في المخرج أعلاه لاحظ التالي:

- مع الفهرس المركب أصبح شرط السياسة `Index Cond`، أي أن PostgreSQL ذهب مباشرة إلى أسطر المستأجر 7 في الفهرس، وقرأ 23 صفحة فقط.
- مع فهرس التاريخ وحده أصبح شرط السياسة `Filter`، فقرأ PostgreSQL أحدث الطلبات لكل المستأجرين واستبعد 2747 سطراً حتى وجد عشرين للمستأجر 7، و2778 صفحة بدل 23، أي أكثر بمئة وعشرين مرة. ومع مستأجر صغير لم يطلب شيئاً منذ شهور سوف يقرأ الفهرس كله تقريباً.
- الدالة `current_setting` مستقرة Stable داخل الاستعلام، فيحسبها PostgreSQL مرة ويستخدمها كقيمة في الفهرس، لذلك لا تحتاج أن تغلفها في دالة خاصة.

ثم أعدنا الفهرس المركب ب `DROP INDEX orders_created` و`CREATE INDEX orders_tenant_created ON orders (tenant_id, created_at)`. والقاعدة: كل فهرس على جدول يملكه المستأجر يبدأ بعمود `tenant_id`، إلا إذا كان الاستعلام يعمل عمداً على كل المستأجرين.

### الدوال غير المضمونة Non-Leakproof Functions والفهرس Index الذي لا يستخدم

هناك فخ أقل شهرة، فتقول [وثائق CREATE FUNCTION](https://www.postgresql.org/docs/18/sql-createfunction.html?ref=arabroot.io) إن PostgreSQL يطبق شروط السياسات قبل أي شرط من الاستعلام فيه دالة غير مضمونة Leakproof، والدالة المضمونة هي التي لا تكشف شيئاً عن وسيطها إلا بقيمتها المعادة، فلا ترمي خطأ يحتوي القيمة مثلاً، والسبب أن الدالة التي تنفذ على سطر قبل السياسة قد تكشفه في رسالة خطأ أو سجل. ولا يستطيع وضع الخاصية `LEAKPROOF` إلا المستخدم الخارق. ودوال شائعة مثل `lower()` والمقارنة `LIKE` ليست مضمونة، فلنر ما يحدث للبحث عن عميل بالبريد دون حساسية لحالة الأحرف:

```sql
SET ROLE saas_owner;
CREATE INDEX customers_tenant_lower_email ON customers (tenant_id, lower(email));
-- then as app_user:
BEGIN;
SET LOCAL app.tenant_id = '7';
EXPLAIN (COSTS OFF) SELECT id FROM customers WHERE lower(email) = 'c5@example.com';
COMMIT;
```

```text
 Bitmap Heap Scan on customers
   Recheck Cond: (tenant_id = (NULLIF(current_setting('app.tenant_id'::text, true), ''::text))::bigint)
   Filter: (lower(email) = 'c5@example.com'::text)
   ->  Bitmap Index Scan on customers_tenant_lower_email
         Index Cond: (tenant_id = (NULLIF(current_setting('app.tenant_id'::text, true), ''::text))::bigint)
```

لاحظ أن الفهرس استخدم لعمود المستأجر فقط، أما `lower(email)` فبقي `Filter` مع أنه جزء من الفهرس، والسبب أن الدالة غير مضمونة فلا يجوز تقييمها قبل السياسة. وهذا مقبول لمستأجر فيه خمسون عميلاً، ولكنه مع مستأجر فيه مليون عميل يعني قراءة كل عملائه في كل بحث. والحل ليس أن تضع `LEAKPROOF` على دالة لا تعرف كل سلوكها، وإنما أن تحفظ القيمة المحسوبة في عمود مولد Generated Column وتبحث فيه بالمساواة العادية، وهي مضمونة:

```sql
SET ROLE saas_owner;
DROP INDEX customers_tenant_lower_email;
ALTER TABLE customers ADD COLUMN email_lower text GENERATED ALWAYS AS (lower(email)) STORED;
CREATE INDEX customers_tenant_email_lower ON customers (tenant_id, email_lower);
-- then as app_user:
BEGIN;
SET LOCAL app.tenant_id = '7';
EXPLAIN (COSTS OFF) SELECT id FROM customers WHERE email_lower = 'c5@example.com';
COMMIT;
```

```text
 Index Scan using customers_tenant_email_lower on customers
   Index Cond: ((tenant_id = (NULLIF(current_setting('app.tenant_id'::text, true), ''::text))::bigint) AND (email_lower = 'c5@example.com'::text))
```

أصبح الشرطان معاً `Index Cond`. ولاحظ أننا كتبنا `STORED` صراحة، لأن النوع الافتراضي للعمود المولد في PostgreSQL 18 هو `VIRTUAL` الذي يحسب عند القراءة كما في [وثائق الأعمدة المولدة](https://www.postgresql.org/docs/18/ddl-generated-columns.html?ref=arabroot.io)، ونحن نريد قيمة محفوظة نضع عليها فهرساً. فإذا رأيت `Filter` في خطة استعلام بطيء تحت RLS، فابحث أولاً عن دالة غير مضمونة في الشرط، وتستطيع أن تعرف ذلك من العمود `proleakproof` في `pg_proc`.

### ال View ودوال SECURITY DEFINER: الباب الخلفي Backdoor

تقول [وثائق CREATE VIEW](https://www.postgresql.org/docs/18/sql-createview.html?ref=arabroot.io) إن الوصول إلى الجداول داخل ال View يتم افتراضياً بصلاحيات مالك ال View وليس من يستعلم منها، وإن سياسات الصفوف التي تطبق هي سياسات المالك. فإذا أنشأ سكربت ترحيل يعمل بالمستخدم الخارق View للتقارير، وهذا يحدث كثيراً، فكل من يقرأ منها يتجاوز RLS. والملف `12-view.sql` يمثل هذا الترحيل:

```sql
-- a migration that runs as the postgres superuser creates a reporting view
CREATE VIEW order_totals AS
    SELECT tenant_id, count(*) AS orders, sum(total) AS revenue FROM orders GROUP BY tenant_id;
GRANT SELECT ON order_totals TO app_user;
```

ثم نقرأ منها كمستأجر acme:

```sql
BEGIN;
SET LOCAL app.tenant_id = '1';
SELECT * FROM order_totals ORDER BY tenant_id LIMIT 3;
COMMIT;
```

```text
 tenant_id | orders |  revenue
-----------+--------+------------
         1 |   5001 | 1250733.84
         2 |   5000 | 1262283.35
         3 |   5000 | 1253027.45
(3 rows)
```

مستخدم acme رأى مبيعات كل المستأجرين. والحل الخاصية `security_invoker` الموجودة منذ PostgreSQL 15، فتجعل ال View تعمل بصلاحيات وسياسات من يستعلم منها:

```sql
ALTER VIEW order_totals SET (security_invoker = true);
```

```text
 tenant_id | orders |  revenue
-----------+--------+------------
         1 |   5001 | 1250733.84
(1 row)
```

ونفس المشكلة في الدوال، فالدالة المعرفة `SECURITY DEFINER` تعمل بصلاحيات مالكها كما في [وثائق CREATE FUNCTION](https://www.postgresql.org/docs/18/sql-createfunction.html?ref=arabroot.io)، فإذا كان مالكها هو مالك الجداول أو المستخدم الخارق تجاوزت RLS لكل من ينفذها. لذلك أنشئ كل View ب `security_invoker = true`، ولا تقم بإنشاء دالة `SECURITY DEFINER` تقرأ جداول المستأجرين إلا إذا كانت تأخذ المستأجر من `current_setting` وتضعه في شرطها بنفسها، وراجعها كما تراجع كود المصادقة. وسوف ترى في [قسم الاختبارات](#isolation-tests) أن الاختبار التلقائي يكتشف هذه ال View قبل أن تصل إلى الإنتاج.

## جهة التطبيق: Middleware يحدد المستأجر Tenant ويفتح المعاملة Transaction

قاعدة البيانات أصبحت جاهزة، والآن التطبيق. وشرحنا في المقال الأول [مصادر سياق المستأجر](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#tenant-context): النطاق الفرعي، أو نطاق العميل الخاص، أو حقل داخل التوكن Claim. والمهم هنا أن يحدد المستأجر مرة واحدة في بداية الطلب من مصدر موثوق، ثم يمر إلى كل معاملة في قاعدة البيانات، والصورة التالية تبين المسار:

![المتصفح يطلب acme.saas.example.com بالأمر POST /orders وفي الجسم tenant_id يساوي 2، فيمر إلى Middleware التطبيق: 1 يحدد المستأجر من النطاق الفرعي acme فيصبح tenants.id يساوي 1 أو من حقل في توكن تم التحقق منه، 2 يتجاهل tenant_id في الجسم، 3 يفتح المعاملة BEGIN ثم set_config('app.tenant_id', '1', true)، 4 ينفذ الاستعلامات ثم COMMIT فينتهي الإعداد، ثم PgBouncer في وضع transaction ثم PostgreSQL حيث تقرأ السياسة current_setting، وفي الأسفل 5 المهام الخلفية تحمل tenant_id في رسالة الطابور والعامل يفتح نفس المعاملة](https://arabroot.io/content/images/2026/10/multi-tenancy-practice-03-request-flow.webp)

المستأجر يحدد مرة واحدة من مصدر موثوق، ونفس الدالة تفتح المعاملة للطلب وللمهمة الخلفية

التطبيق التالي `app.py` مكتوب بلغة Python ومكتبة [psycopg 3 ومجمعها](https://www.psycopg.org/psycopg3/docs/api/pool.html?ref=arabroot.io) وخادم [wsgiref](https://docs.python.org/3/library/wsgiref.html?ref=arabroot.io) الموجود مع Python، حتى لا يخفي إطار عمل أي خطوة، ويتصل بقاعدة البيانات عبر PgBouncer:

```python
# app.py: a tiny multi-tenant API. The tenant comes from the subdomain, never from the request body.
import json, os, re
from contextlib import contextmanager
from wsgiref.simple_server import make_server
from psycopg_pool import ConnectionPool

pool = ConnectionPool(os.environ["DATABASE_URL"], min_size=1, max_size=5, open=True)
SUBDOMAIN = re.compile(r"^([a-z0-9-]+)\.saas\.localhost(:\d+)?$")

def resolve_tenant(host):
    m = SUBDOMAIN.match(host or "")
    if not m:
        return None
    with pool.connection() as conn:
        row = conn.execute("SELECT id FROM tenants WHERE slug = %s", (m.group(1),)).fetchone()
    return row[0] if row else None

@contextmanager
def tenant_tx(tenant_id):
    """One transaction per unit of work, opened with SET LOCAL through set_config(..., true)."""
    with pool.connection() as conn:
        with conn.transaction():
            conn.execute("SELECT set_config('app.tenant_id', %s, true)", (str(tenant_id),))
            yield conn

def reply(start, status, body):
    start(status, [("Content-Type", "application/json")])
    return [json.dumps(body, default=str).encode() + b"\n"]

def app(environ, start):
    tenant_id = resolve_tenant(environ.get("HTTP_HOST"))
    if tenant_id is None:
        return reply(start, "404 Not Found", {"error": "unknown tenant"})
    method, path = environ["REQUEST_METHOD"], environ["PATH_INFO"]
    try:
        if method == "GET" and path == "/orders":
            with tenant_tx(tenant_id) as conn:
                rows = conn.execute("SELECT id, tenant_id, total FROM orders ORDER BY created_at DESC LIMIT 3").fetchall()
            return reply(start, "200 OK", {"tenant": tenant_id, "orders": rows})
        m = re.fullmatch(r"/orders/(\d+)", path)
        if method == "GET" and m:
            with tenant_tx(tenant_id) as conn:
                row = conn.execute("SELECT id, tenant_id, total FROM orders WHERE id = %s", (int(m.group(1)),)).fetchone()
            return reply(start, "200 OK", row) if row else reply(start, "404 Not Found", {"error": "no such order"})
        if method == "POST" and path == "/orders":
            body = json.loads(environ["wsgi.input"].read(int(environ.get("CONTENT_LENGTH") or 0)))
            with tenant_tx(tenant_id) as conn:     # body["tenant_id"] is ignored on purpose
                row = conn.execute(
                    "INSERT INTO orders (tenant_id, customer_id, total) VALUES (%s, %s, %s) RETURNING id, tenant_id, total",
                    (tenant_id, body["customer_id"], body["total"])).fetchone()
            return reply(start, "201 Created", row)
    except Exception as e:
        return reply(start, "400 Bad Request", {"error": str(e).splitlines()[0]})
    return reply(start, "404 Not Found", {"error": "not found"})

if __name__ == "__main__":
    make_server("0.0.0.0", 8000, app).serve_forever()
```

```dockerfile
FROM python:3.13-slim
RUN pip install --no-cache-dir "psycopg[binary]==3.*" "psycopg-pool==3.*"
WORKDIR /app
COPY app.py .
CMD ["python", "-u", "app.py"]
```

```bash
docker build -t lab-mtp-app:local -f Dockerfile.app .
docker run -d --name lab-mtp-app --network lab-mtp-net -p 127.0.0.1:19682:8000 \
  -e DATABASE_URL="host=lab-mtp-bouncer port=6432 dbname=saas user=app_user password=apppass" \
  lab-mtp-app:local

curl -s -H "Host: acme.saas.localhost" http://127.0.0.1:19682/orders
curl -s -H "Host: acme.saas.localhost" http://127.0.0.1:19682/orders/202
curl -s -H "Host: globex.saas.localhost" http://127.0.0.1:19682/orders/202
curl -s -H "Host: acme.saas.localhost" -X POST -d '{"tenant_id": 2, "customer_id": 1, "total": 99.50}' http://127.0.0.1:19682/orders
curl -s -H "Host: acme.saas.localhost" -X POST -d '{"customer_id": 2, "total": 99.50}' http://127.0.0.1:19682/orders
curl -s -H "Host: nope.saas.localhost" http://127.0.0.1:19682/orders
```

والمخرج سوف يكون كما يلي، وكان الإصدار في ال Container هو psycopg 3.3.6 وpsycopg-pool 3.3.3:

```text
{"tenant": 1, "orders": [[387601, 1, "212.14"], [569001, 1, "401.99"], [945601, 1, "278.55"]]}
{"error": "no such order"}
[202, 2, "204.51"]
[1000004, 1, "99.50"]
{"error": "insert or update on table \"orders\" violates foreign key constraint \"orders_tenant_id_customer_id_fkey\""}
{"error": "unknown tenant"}
```

في الإعداد أعلاه لاحظ التالي:

- الدالة `tenant_tx` هي الطريق الوحيد إلى جداول المستأجرين، وهي تفتح معاملة بالأمر `conn.transaction()` كما في [وثائق المعاملات في psycopg](https://www.psycopg.org/psycopg3/docs/basic/transactions.html?ref=arabroot.io)، وأول ما تنفذه فيها هو `set_config(..., true)`، ولو عاد الاتصال إلى المجمع وأخذه طلب آخر فلن يجد المستأجر.
- الطلب 202 أعاد «no such order» ل acme وأعاد الطلب ل globex، والكود نفسه لم يكتب `tenant_id` في الاستعلام، فالسياسة هي التي منعت التسرب الذي رأيناه في أول المقال. ومع ذلك أبق الشرط في الكود أيضاً، فهو خط الدفاع الأول وRLS هو الثاني.
- الطلب الذي أرسل `"tenant_id": 2` في الجسم أنشئ للمستأجر 1، لأن الكود يتجاهل هذا الحقل ويأخذ المستأجر من النطاق، ولا تقم أبداً بأخذ المستأجر من جسم الطلب أو من معامل في الرابط يستطيع المستخدم تغييره، والسبب أن هذا يعطي المستخدم اختيار الشركة التي يكتب في بياناتها.
- عندما يأتي المستأجر من توكن JWT، فالمصدر الموثوق هو الحقل Claim في توكن تحققت من توقيعه، كما شرحنا في [مقال الجلسات وJWT](https://arabroot.io/articles/%D8%A7%D9%84%D8%AC%D9%84%D8%B3%D8%A7%D8%AA-%D9%88%D9%85%D9%84%D9%81%D8%A7%D8%AA-%D8%AA%D8%B9%D8%B1%D9%8A%D9%81-%D8%A7%D9%84%D8%A7%D8%B1%D8%AA%D8%A8%D8%A7%D8%B7-%D9%88-jwt-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#jwt-claims)، وإذا كان المستخدم عضواً في أكثر من مستأجر فتحقق أن المستأجر الذي في النطاق من ضمن عضوياته قبل أن تفتح المعاملة.
- رسالة الخطأ في الطلب الخامس تكشف اسم القيد، وهذا مقبول في المختبر فقط، أما في الإنتاج فسجل التفاصيل وأعد للمستخدم رسالة عامة.

### المهام الخلفية Background Jobs والطوابير Queues تحمل المستأجر Tenant أيضاً

المهمة الخلفية لا تملك نطاقاً فرعياً ولا توكن، لذلك يجب أن تحمل رسالة الطابور `tenant_id` صراحة، مثل `{"tenant_id": 1, "job": "invoice"}`، والعامل Worker يفتح كل وحدة عمل بنفس الدالة `tenant_tx(job["tenant_id"])`، ولا يتصل أبداً بدور يتجاوز RLS لأن «المهمة داخلية». والمهام التي تعمل على كل المستأجرين، مثل إرسال الفواتير الشهرية، تقرأ قائمة المستأجرين من جدول `tenants` ثم تعالج كل مستأجر في معاملته الخاصة، فإذا فشل واحد لم يتوقف الباقي ولم تختلط بياناتهم. ونفس الفكرة في مفاتيح ال Cache والسجلات Logs، وقد شرحناها في [قسم حمل سياق المستأجر في المقال الأول](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#propagate).

## اختبارات العزل Isolation Tests: اختبار لكل جدول يكتب نفسه

RLS يحمي الجداول التي فعلته عليها فقط، والجدول الذي يضيفه زميلك الشهر القادم لن يكون عليه سياسة إلا إذا تذكر. لذلك لا يكفي أن تكتب اختباراً للجداول الموجودة اليوم، وإنما تكتب اختباراً يبحث بنفسه عن كل جدول وكل View فيها عمود `tenant_id`، ويتأكد أن المستأجر A لا يقرأ صفوف B ولا يعدلها ولا يحذفها ولا ينقل صفاً إليه. والملف `test_isolation.py` يستخدم مكتبة [unittest](https://docs.python.org/3/library/unittest.html?ref=arabroot.io) الموجودة مع Python:

```python
# test_isolation.py: for every table that has a tenant_id column, tenant A must not read, change,
# delete, or move rows into tenant B; views must not show tenant B. New tables get their test automatically.
import os, unittest
import psycopg

DSN = os.environ["DATABASE_URL"]
A, B = 1, 2

def tenant_relations():
    with psycopg.connect(DSN) as conn:
        return conn.execute(
            """SELECT c.table_name, t.table_type FROM information_schema.columns c
               JOIN information_schema.tables t USING (table_schema, table_name)
               WHERE c.table_schema = 'public' AND c.column_name = 'tenant_id'
               ORDER BY c.table_name""").fetchall()

def make_test(table, kind):
    def test(self):
        conn = psycopg.connect(DSN)
        try:
            conn.execute("SELECT set_config('app.tenant_id', %s, true)", (str(A),))
            seen = conn.execute(f"SELECT count(*) FROM {table} WHERE tenant_id = %s", (B,)).fetchone()[0]
            self.assertEqual(seen, 0, f"tenant {A} reads {seen} rows of tenant {B}")
            if kind == "VIEW":
                return
            n = conn.execute(f"UPDATE {table} SET tenant_id = tenant_id WHERE tenant_id = %s", (B,)).rowcount
            self.assertEqual(n, 0, f"tenant {A} updates {n} rows of tenant {B}")
            n = conn.execute(f"DELETE FROM {table} WHERE tenant_id = %s", (B,)).rowcount
            self.assertEqual(n, 0, f"tenant {A} deletes {n} rows of tenant {B}")
            with self.assertRaises(psycopg.errors.InsufficientPrivilege, msg=f"tenant {A} moves a row to tenant {B}"):
                with conn.transaction():
                    conn.execute(f"UPDATE {table} SET tenant_id = %s WHERE ctid = (SELECT ctid FROM {table} LIMIT 1)", (B,))
        finally:
            conn.rollback()          # never keep anything the test changed
            conn.close()
    return test

class TenantIsolation(unittest.TestCase):
    pass

for name, kind in tenant_relations():
    setattr(TenantIsolation, f"test_{name}", make_test(name, kind))

if __name__ == "__main__":
    unittest.main(verbosity=2)
```

```bash
docker cp test_isolation.py lab-mtp-app:/app/test_isolation.py
docker exec -e DATABASE_URL="host=lab-mtp-pg dbname=saas user=app_user password=apppass" \
  lab-mtp-app python test_isolation.py
```

```text
test_customers (__main__.TenantIsolation.test_customers) ... ok
test_orders (__main__.TenantIsolation.test_orders) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.145s

OK
```

في الإعداد أعلاه لاحظ التالي:

- الاختبار يتصل بنفس دور التطبيق `app_user`، فلو اتصل بالمالك أو بالمستخدم الخارق لنجح دائماً ولم يختبر شيئاً، كما رأيت في [قسم المالك](#owner-bypass).
- كل اختبار ينتهي ب `rollback()` في `finally`، فإذا فشل الاختبار لأن الحذف وصل فعلاً إلى صفوف B فلن يبقى أثر.
- محاولة نقل صف إلى B تنتظر الخطأ `InsufficientPrivilege`، وهو الصنف الذي تعطيه psycopg لخطأ `WITH CHECK`، فإذا نجح النقل فشل الاختبار.
- الاختبار يحتاج أن يكون للمستأجرين 1 و2 صفوف في كل جدول، لذلك تجهز بيانات الاختبار بنفس سكربت التجهيز الذي سوف نكتبه بعد قليل.

الآن نمثل الخطأ الذي يحدث في كل فريق: جدول جديد للفواتير أضيف في ترحيل، ومعه الصلاحيات، ولكن دون RLS. الملف `10-invoices.sql`:

```sql
SET ROLE saas_owner;
CREATE TABLE invoices (
    tenant_id bigint NOT NULL,
    id        bigint GENERATED ALWAYS AS IDENTITY,
    order_id  bigint NOT NULL,
    amount    numeric(10,2) NOT NULL,
    PRIMARY KEY (tenant_id, id),
    FOREIGN KEY (tenant_id, order_id) REFERENCES orders (tenant_id, id)
);
GRANT SELECT, INSERT, UPDATE, DELETE ON invoices TO app_user;
RESET ROLE;
INSERT INTO invoices (tenant_id, order_id, amount)
SELECT tenant_id, id, total FROM orders WHERE tenant_id IN (1, 2) AND id % 7 = 0;
```

وقبل الاختبار نضيف فحصاً سريعاً من فهرس النظام Catalog يمكن تشغيله في خط CI، وهو الملف `18-catalog-check.sql`:

```sql
-- every table with a tenant_id column, and whether RLS protects it
SELECT c.relname AS table_name, c.relrowsecurity AS rls, c.relforcerowsecurity AS forced,
       count(p.polname) AS policies
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = 'public'
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'tenant_id' AND NOT a.attisdropped
LEFT JOIN pg_policy p ON p.polrelid = c.oid
WHERE c.relkind = 'r'
GROUP BY 1, 2, 3
ORDER BY 1;
```

والمخرج للفحص ثم للاختبار سوف يكون كما يلي:

```text
 table_name | rls | forced | policies
------------+-----+--------+----------
 customers  | t   | t      |        1
 invoices   | f   | f      |        0
 orders     | t   | t      |        1
(3 rows)

test_customers (__main__.TenantIsolation.test_customers) ... ok
test_invoices (__main__.TenantIsolation.test_invoices) ... FAIL
test_orders (__main__.TenantIsolation.test_orders) ... ok

======================================================================
FAIL: test_invoices (__main__.TenantIsolation.test_invoices)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/app/test_isolation.py", line 23, in test
    self.assertEqual(seen, 0, f"tenant {A} reads {seen} rows of tenant {B}")
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 714 != 0 : tenant 1 reads 714 rows of tenant 2

----------------------------------------------------------------------
Ran 3 tests in 0.267s

FAILED (failures=1)
```

لم يكتب أحد اختباراً للفواتير، ومع ذلك ظهر `test_invoices` وفشل برسالة واضحة: المستأجر 1 يقرأ 714 فاتورة للمستأجر 2\. والإصلاح في `11-invoices-rls.sql`:

```sql
SET ROLE saas_owner;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices TO app_user
    USING      (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
    WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint);
```

```text
 table_name | rls | forced | policies
------------+-----+--------+----------
 customers  | t   | t      |        1
 invoices   | t   | t      |        1
 orders     | t   | t      |        1
(3 rows)

test_customers (__main__.TenantIsolation.test_customers) ... ok
test_invoices (__main__.TenantIsolation.test_invoices) ... ok
test_orders (__main__.TenantIsolation.test_orders) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.311s

OK
```

وعندما أنشأنا ال View `order_totals` في [قسم ال View](#views-definer) ظهر لها اختبار أيضاً وفشل قبل إضافة `security_invoker`:

```text
test_customers (__main__.TenantIsolation.test_customers) ... ok
test_invoices (__main__.TenantIsolation.test_invoices) ... ok
test_order_totals (__main__.TenantIsolation.test_order_totals) ... FAIL
test_orders (__main__.TenantIsolation.test_orders) ... ok

======================================================================
FAIL: test_order_totals (__main__.TenantIsolation.test_order_totals)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/app/test_isolation.py", line 23, in test
    self.assertEqual(seen, 0, f"tenant {A} reads {seen} rows of tenant {B}")
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 1 != 0 : tenant 1 reads 1 rows of tenant 2

----------------------------------------------------------------------
Ran 4 tests in 0.498s

FAILED (failures=1)
```

وبعد `security_invoker = true` نجحت الاختبارات الأربعة. لذلك اجعل هذا الاختبار والفحص من فهرس النظام خطوة في خط CI بعد تطبيق الترحيل على قاعدة اختبار، فلا يدمج ترحيل يضيف جدولاً أو View دون عزل.

## البديل: مخطط لكل مستأجر Schema per Tenant باختصار

شرحنا في المقال الأول [متى يناسبك مخطط لكل مستأجر](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#schema-per-tenant): عدد مستأجرين قليل نسبياً، وعملاء يطلبون تخصيص الجداول أو نسخة احتياطية منفصلة، أو قانون يطلب فصلاً أوضح من عمود في جدول. والفكرة أن لكل مستأجر Schema مثل `t_acme` فيه نفس الجداول، والتطبيق يضع `SET LOCAL search_path = t_acme` في بداية كل معاملة، فيجد الاستعلام `SELECT * FROM orders` جدول acme، كما في [وثائق مسار البحث search\_path](https://www.postgresql.org/docs/18/ddl-schemas.html?ref=arabroot.io). ونفس تحذير PgBouncer ينطبق هنا تماماً، فالأمر `SET search_path` العادي يتسرب من كلاينت إلى آخر مثل `app.tenant_id`. والفرق أن العزل هنا يعتمد على اسم ال Schema، فالاستعلام الذي يكتب اسم Schema آخر صراحة يصل إليه إذا كانت الصلاحيات تسمح، لذلك امنح كل مستأجر دوراً لا يملك إلا ال Schema الخاصة به إذا كان العزل مهماً لك.

والصعوبة الحقيقية في هذا النموذج هي الترحيل، فكل تغيير في الجداول يطبق على مئات ال Schemas، وقد ينجح عند بعضها ويفشل عند غيرها، وهذا موضوع القسم التالي.

## الترحيل Migrations على كل المستأجرين Tenants

في الجداول المشتركة يبقى الترحيل ترحيلاً واحداً لقاعدة واحدة، والقاعدة التي شرحناها في [مقال تصميم قواعد البيانات](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D8%B9-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D8%B3%D8%B9/#migrations) و[مقال البيئات](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D9%8A%D8%A6%D8%A7%D8%AA-%D9%88%D9%85%D8%B9%D8%A7%D9%8A%D9%86%D8%A9-%D8%A7%D9%84%D9%85%D9%8A%D8%B2%D8%A7%D8%AA-%D9%88-gitops-%D9%84%D9%84%D9%81%D8%B1%D9%82/#migrations) تنطبق كما هي: التوسيع ثم التقليص Expand/Contract، أو [Parallel Change](https://martinfowler.com/bliki/ParallelChange.html?ref=arabroot.io)، حتى يعمل الإصدار القديم والجديد من التطبيق على نفس الجداول أثناء النشر. وأضف عليها ثلاثة أمور خاصة بتعدد المستأجرين:

- **الجدول الجديد يأتي مع سياسته في نفس الترحيل:** `CREATE TABLE` و`ENABLE` و`FORCE` و`CREATE POLICY` في ملف واحد، والاختبار في القسم السابق يرفض الترحيل الذي ينساها.
- **الملء Backfill على دفعات لكل مستأجر:** بدل `UPDATE` واحد على مليون صف، عالج مستأجراً بعد مستأجر، فلا يقفل الترحيل كل العملاء في نفس الوقت، وإذا توقف عرفت أين يكمل.
- **رقم إصدار لكل مستأجر في النماذج المعزولة:** مع Schema أو قاعدة بيانات لكل مستأجر، احفظ إصدار الجداول لكل مستأجر في جدول مركزي، وطبق الترحيل على كل مستأجر في معاملة خاصة به، فإذا فشل واحد بقي على إصداره ولم يوقف الباقين.

ولنر الأمر الأخير عملياً على قاعدة ثانية `saas_schemas` فيها ثلاثة مستأجرين بنموذج Schema لكل مستأجر، الملف `17-schemas.sql`:

```sql
CREATE DATABASE saas_schemas;
\c saas_schemas
CREATE TABLE public.tenant_schemas (slug text PRIMARY KEY, schema_name text NOT NULL UNIQUE, version int NOT NULL DEFAULT 0);
DO $$
DECLARE s text;
BEGIN
  FOREACH s IN ARRAY ARRAY['acme', 'globex', 'initech'] LOOP
    EXECUTE format('CREATE SCHEMA %I', 't_' || s);
    EXECUTE format('CREATE TABLE %I.orders (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, total numeric(10,2) NOT NULL)', 't_' || s);
    INSERT INTO public.tenant_schemas (slug, schema_name, version) VALUES (s, 't_' || s, 1);
  END LOOP;
END $$;
```

والسكربت `migrate-schemas.sh` يطبق الترحيل رقم 2 على كل مستأجر ما زال على الإصدار 1:

```bash
#!/bin/sh
# migrate-schemas.sh: apply migration 2 to every tenant schema that is still on version 1, one transaction per tenant.
P="psql -h lab-mtp-pg -U postgres -d saas_schemas -v ON_ERROR_STOP=1 -Atq"
for s in $($P -c "SELECT schema_name FROM tenant_schemas WHERE version < 2 ORDER BY slug"); do
  if $P <<SQL
BEGIN;
SET LOCAL search_path = $s;
ALTER TABLE orders ADD COLUMN currency text NOT NULL DEFAULT 'SAR';
UPDATE public.tenant_schemas SET version = 2 WHERE schema_name = '$s';
COMMIT;
SQL
  then echo "$s: migrated to 2"; else echo "$s: FAILED, still on 1"; fi
done
$P -c "SELECT schema_name || ' v' || version FROM tenant_schemas ORDER BY slug"
```

وقبل التشغيل كسرنا مستأجراً عمداً، فأضفنا العمود يدوياً إلى `t_globex.orders` كأن أحداً أصلح شيئاً في الإنتاج دون ترحيل، وهذا يحدث أكثر مما تتوقع:

```bash
docker exec lab-mtp-pg psql -U postgres -d saas_schemas -c "ALTER TABLE t_globex.orders ADD COLUMN currency text;"
docker run --rm --network lab-mtp-net -e PGPASSWORD=labpass \
  -v "$PWD/migrate-schemas.sh:/m.sh:ro" postgres:18 sh /m.sh
```

```text
t_acme: migrated to 2
ERROR:  column "currency" of relation "orders" already exists
t_globex: FAILED, still on 1
t_initech: migrated to 2
t_acme v2
t_globex v1
t_initech v2
```

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

```text
t_globex: migrated to 2
t_acme v2
t_globex v2
t_initech v2
```

ونفس الحلقة تعمل مع قاعدة بيانات لكل مستأجر، والفرق أن الحلقة تمر على سلاسل اتصال بدل أسماء Schemas، ويكون جدول الإصدارات في قاعدة مركزية. ومع مئات المستأجرين شغل الترحيل على دفعات، ابدأ بمستأجرين داخليين أو تجريبيين ثم الباقي، كما تفعل مع النشر التدريجي.

## تجهيز المستأجر Tenant Provisioning بسكربت يمكن تكراره Idempotent

عندما يشترك عميل جديد، فالتجهيز ليس سطراً في جدول `tenants` فقط، وإنما إعداداته الافتراضية، وحدود باقته Plan Limits، ودعوة أول مدير له، واسمه في النطاق، وأحياناً قاعدة بيانات كاملة. والطريقة البسيطة أن يفعل ذلك موظف بيده أو صفحة تسجيل تنفذ عشرة أوامر متتالية، فإذا فشل الأمر السادس بقي المستأجر نصف مجهز، ولا أحد يعرف ما الذي نفذ وما الذي لم ينفذ. والطريقة الصحيحة سكربت يمكن تكراره Idempotent: تشغيله مرة أو عشر مرات بنفس المدخلات يعطي نفس النتيجة، فإذا فشل في المنتصف أعدت تشغيله فأكمل ما بقي.

نضيف أولاً جدولين للإعدادات والمستخدمين، بنفس السياسة، ودوراً للتجهيز `saas_provisioner` عضواً في `app_user` فتنطبق عليه نفس السياسات، وله فوقها صلاحية إضافة مستأجر، الملف `14-provisioning-schema.sql`:

```sql
SET ROLE saas_owner;
CREATE TABLE tenant_settings (
    tenant_id  bigint PRIMARY KEY REFERENCES tenants (id),
    timezone   text NOT NULL DEFAULT 'UTC',
    locale     text NOT NULL DEFAULT 'ar',
    max_users  int  NOT NULL
);
CREATE TABLE users (
    tenant_id    bigint NOT NULL REFERENCES tenants (id),
    id           bigint GENERATED ALWAYS AS IDENTITY,
    email        text NOT NULL,
    role         text NOT NULL DEFAULT 'member',
    invite_token text,
    PRIMARY KEY (tenant_id, id),
    UNIQUE (tenant_id, email)
);
ALTER TABLE tenant_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE users FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tenant_settings TO app_user
    USING      (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
    WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint);
CREATE POLICY tenant_isolation ON users TO app_user
    USING      (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
    WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint);
GRANT SELECT, INSERT, UPDATE, DELETE ON tenant_settings, users TO app_user;
RESET ROLE;
-- the provisioning job: a member of app_user (so the same policies apply) that may also add tenants
CREATE ROLE saas_provisioner LOGIN PASSWORD 'provpass' IN ROLE app_user;
GRANT INSERT ON tenants TO saas_provisioner;
```

ثم السكربت `provision.py`، وكل خطوة فيه تستخدم `INSERT ... ON CONFLICT DO NOTHING` من [وثائق INSERT](https://www.postgresql.org/docs/18/sql-insert.html?ref=arabroot.io)، فإذا كان الشيء موجوداً تركه كما هو:

```python
# provision.py: create a tenant and everything it needs. Safe to run again with the same input.
import os, secrets, sys
import psycopg

PLANS = {"free": 3, "pro": 25, "enterprise": 500}      # plan -> max users

def provision(conn, slug, name, plan, admin_email):
    with conn.transaction():
        row = conn.execute(
            "INSERT INTO tenants (slug, name, plan) VALUES (%s, %s, %s) ON CONFLICT (slug) DO NOTHING RETURNING id",
            (slug, name, plan)).fetchone()
        created = row is not None
        tenant_id = row[0] if created else conn.execute("SELECT id FROM tenants WHERE slug = %s", (slug,)).fetchone()[0]
        print(f"tenant   {slug} id={tenant_id} {'created' if created else 'already exists'}")

        # everything below runs as this tenant, through the same RLS policies as the app
        conn.execute("SELECT set_config('app.tenant_id', %s, true)", (str(tenant_id),))
        n = conn.execute(
            "INSERT INTO tenant_settings (tenant_id, max_users) VALUES (%s, %s) ON CONFLICT (tenant_id) DO NOTHING",
            (tenant_id, PLANS[plan])).rowcount
        print(f"settings max_users={PLANS[plan]} {'created' if n else 'already exists'}")
        n = conn.execute(
            """INSERT INTO users (tenant_id, email, role, invite_token) VALUES (%s, %s, 'admin', %s)
               ON CONFLICT (tenant_id, email) DO NOTHING""",
            (tenant_id, admin_email, secrets.token_urlsafe(24))).rowcount
        print(f"admin    {admin_email} {'invited' if n else 'already invited'}")
    print(f"next     https://{slug}.saas.example.com is served by the wildcard record and certificate")

if __name__ == "__main__":
    slug, name, plan, admin_email = sys.argv[1:5]
    with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
        provision(conn, slug, name, plan, admin_email)
```

نشغله مرتين بنفس المدخلات لمستأجر جديد initech، ثم نشغله على acme وglobex الموجودين من قبل حتى يكمل ما ينقصهما:

```bash
docker cp provision.py lab-mtp-app:/app/provision.py
DB="host=lab-mtp-pg dbname=saas user=saas_provisioner password=provpass"
for i in 1 2; do
  echo "--- run $i"
  docker exec -e DATABASE_URL="$DB" lab-mtp-app python provision.py initech Initech pro admin@initech.example
done
docker exec -e DATABASE_URL="$DB" lab-mtp-app python provision.py acme Acme pro admin@acme.example
docker exec -e DATABASE_URL="$DB" lab-mtp-app python provision.py globex Globex free admin@globex.example
```

والمخرج سوف يكون كما يلي:

```text
--- run 1
tenant   initech id=201 created
settings max_users=25 created
admin    admin@initech.example invited
next     https://initech.saas.example.com is served by the wildcard record and certificate
--- run 2
tenant   initech id=201 already exists
settings max_users=25 already exists
admin    admin@initech.example already invited
next     https://initech.saas.example.com is served by the wildcard record and certificate
tenant   acme id=1 already exists
settings max_users=25 created
admin    admin@acme.example invited
next     https://acme.saas.example.com is served by the wildcard record and certificate
tenant   globex id=2 already exists
settings max_users=3 created
admin    admin@globex.example invited
next     https://globex.saas.example.com is served by the wildcard record and certificate
```

في الإعداد أعلاه لاحظ التالي:

- التشغيل الثاني لم ينشئ شيئاً ولم يفشل، والتشغيل على acme وglobex أكمل الإعدادات والمدير الناقصين ولم يلمس المستأجر نفسه، فنفس السكربت يجهز المستأجر الجديد ويصلح المستأجر القديم الذي ينقصه شيء.
- كل شيء في معاملة واحدة، فإذا فشلت دعوة المدير لم يبق مستأجر دون إعدادات.
- الإعدادات والمدير أنشئا بعد `set_config` وعبر نفس السياسات، فلا يستطيع خطأ في السكربت أن يكتب إعدادات مستأجر في مستأجر آخر.
- حدود الباقة `max_users` محفوظة في الإعدادات، والتطبيق يتحقق منها عند دعوة كل مستخدم جديد، أما ربط الباقات بمستوى العزل فشرحناه في [قسم الباقات في المقال الأول](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#tiering).
- إرسال بريد الدعوة لا يحدث داخل المعاملة، وإنما يرسل إلى طابور بعدها، والسبب أن البريد لا يمكن التراجع عنه إذا فشلت المعاملة.

بقيت الخطوات خارج قاعدة البيانات، والسكربت يطبع الأخيرة منها فقط لأنها جاهزة مسبقاً:

- **النطاق الفرعي Subdomain:** مع سجل DNS عام Wildcard مثل `*.saas.example.com` وشهادة شاملة Wildcard Certificate لنفس الاسم، لا يحتاج المستأجر الجديد أي سجل أو شهادة جديدة، والتطبيق يعرفه من اسمه كما فعلنا. وشرحنا السجل العام في [مقال DNS](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-dns-%D9%88%D8%B3%D8%AC%D9%84%D8%A7%D8%AA%D9%87-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#wildcard) والشهادة الشاملة في [مقال الشهادات](https://arabroot.io/articles/%D8%A7%D9%84%D8%B4%D9%87%D8%A7%D8%AF%D8%A7%D8%AA-%D8%A7%D9%84%D8%B1%D9%82%D9%85%D9%8A%D8%A9-tls-%D9%88-x509-%D9%88%D8%B5%D9%8A%D8%BA-pem-%D9%88-der/#certificate-types)، ولاحظ أن Let's Encrypt لا تصدر الشهادة الشاملة إلا بالتحقق عبر DNS كما في [صفحة طرق التحقق](https://letsencrypt.org/docs/challenge-types/?ref=arabroot.io)، وطريقة إعدادها في [دليل Nginx Proxy Manager](https://arabroot.io/articles/nginx-proxy-manager-%D9%86%D8%B7%D8%A7%D9%82%D8%A7%D8%AA-%D9%88%D8%B4%D9%87%D8%A7%D8%AF%D8%A7%D8%AA-tls/#wildcard-certificate).
- **نطاق العميل الخاص Custom Domain:** مثل `billing.acme.com`، وهنا يحتاج كل مستأجر شهادة خاصة باسمه يصدرها ال Reverse Proxy بعد أن يضيف العميل سجل CNAME يشير إليك، وهذه خطوة غير متزامنة لأنها تنتظر العميل، فيحفظ السكربت حالتها في جدول ويعيد المحاولة، وقد شرحنا هذا النموذج في [قسم نطاق العميل في المقال الأول](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#custom-domain).
- **المستأجر المعزول Silo:** إذا كانت باقة العميل تعطيه قاعدة بيانات أو Schema خاصة، فالسكربت يستدعي أداة البنية ككود Infrastructure as Code مثل Terraform أو OpenTofu لإنشاء القاعدة، ثم يشغل كل ملفات الترحيل عليها حتى آخر إصدار، ثم يسجل سلسلة الاتصال في عمود مثل `db_host` في جدول المستأجرين، وبنفس القاعدة: كل خطوة تتحقق إن كان شيئها موجوداً قبل أن تنشئه.

## عمليات على مستأجر Tenant واحد: التصدير والحذف والاستعادة والنقل

شرحنا في المقال الأول [دورة حياة المستأجر](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#lifecycle)، والصورة التالية تبين ما نطبقه منها في هذا المقال:

![خمس مراحل: 1 التجهيز provision بسطر المستأجر والإعدادات ودعوة المدير وحدود الباقة والنطاق الفرعي بسكربت يمكن تكراره، 2 الترحيل migrate مع كل إصدار بالتوسيع ثم التقليص ورقم إصدار لكل مستأجر، 3 التصدير export بالأمر COPY مع شرط المستأجر أو pg_dump لل Schema، 4 النقل move لمستأجر ثقيل إلى قاعدة خاصة بالنسخ المنطقي، 5 الحذف delete بالجداول الفرعية أولاً في معاملة واحدة مع بقاء البيانات في النسخ الاحتياطية حتى تنتهي مدة الاحتفاظ، وتحتها اختبارات العزل بعد كل خطوة](https://arabroot.io/content/images/2026/10/multi-tenancy-practice-05-tenant-lifecycle.webp)

التجهيز والترحيل لكل مستأجر، أما التصدير والنقل فعند الطلب أو عندما يكبر المستأجر

### تصدير Export بيانات مستأجر Tenant واحد

يطلب العميل نسخة من بياناته عند إلغاء الاشتراك، أو يطلبها القانون في بعض البلدان، والطريقة الأسهل في الجداول المشتركة هي `COPY` لكل جدول. وتقول [وثائق COPY](https://www.postgresql.org/docs/18/sql-copy.html?ref=arabroot.io) إن سياسات `SELECT` تطبق على `COPY ... TO`، لذلك نصدر بدور التطبيق داخل معاملة المستأجر، فلا يستطيع خطأ في السكربت أن يضع صفوف مستأجر آخر في الملف. السكربت `export-tenant.sh`:

```bash
#!/bin/sh
# export-tenant.sh <tenant_id> <dir>: one CSV per table, read through the app role and the tenant's RLS context.
set -e
T=$1; D=$2; mkdir -p "$D"
psql -h lab-mtp-pg -U app_user -d saas -v ON_ERROR_STOP=1 -q <<SQL
BEGIN;
SET LOCAL app.tenant_id = '$T';
\copy (SELECT * FROM tenants WHERE id = $T) TO '$D/tenants.csv' CSV HEADER
\copy (SELECT * FROM tenant_settings) TO '$D/tenant_settings.csv' CSV HEADER
\copy (SELECT * FROM users) TO '$D/users.csv' CSV HEADER
\copy (SELECT tenant_id, id, name, email FROM customers) TO '$D/customers.csv' CSV HEADER
\copy (SELECT * FROM orders) TO '$D/orders.csv' CSV HEADER
\copy (SELECT * FROM invoices) TO '$D/invoices.csv' CSV HEADER
COMMIT;
SQL
wc -l "$D"/*.csv
```

```bash
mkdir -p out
docker run --rm --network lab-mtp-net -e PGPASSWORD=apppass \
  -v "$PWD/export-tenant.sh:/export-tenant.sh:ro" -v "$PWD/out:/out" \
  postgres:18 sh /export-tenant.sh 2 /out/globex
```

```text
    51 /out/globex/customers.csv
   715 /out/globex/invoices.csv
  5001 /out/globex/orders.csv
     2 /out/globex/tenant_settings.csv
     2 /out/globex/tenants.csv
     2 /out/globex/users.csv
  5773 total
```

كل ملف فيه سطر العناوين ثم صفوف globex وحدها: 50 عميلاً و5000 طلب و714 فاتورة. ولاحظ أننا لم نكتب `WHERE tenant_id` في أي استعلام، فالسياسة هي التي حددت الصفوف. ومع نموذج Schema لكل مستأجر يكون التصدير أبسط بالأمر `pg_dump -n t_acme` الذي يصدر ال Schema وحدها، ومع قاعدة لكل مستأجر هو `pg_dump` العادي.

وقد يتساءل البعض: هل نستطيع أيضاً أن نستورد بنفس الطريقة؟ والإجابة لا، فتقول نفس الوثائق إن `COPY FROM` لا يعمل على جدول عليه RLS، وهذا ما ظهر لنا عندما نفذناه بدور التطبيق:

```text
BEGIN
SET
ERROR:  COPY FROM not supported with row-level security
HINT:  Use INSERT statements instead.
ROLLBACK
```

لذلك الاستعادة تحتاج دوراً له `BYPASSRLS`، وهو دور العمليات الذي ذكرناه في [قسم الأدوار](#owner-bypass)، الملف `15-ops-role.sql`:

```sql
-- a separate role for tenant operations: it bypasses RLS, so only reviewed scripts use it
CREATE ROLE saas_ops LOGIN PASSWORD 'opspass' BYPASSRLS;
GRANT UPDATE ON tenants TO saas_ops;
GRANT SELECT, INSERT, DELETE ON tenants, tenant_settings, users, customers, orders, invoices TO saas_ops;
```

### حذف مستأجر Tenant بالكامل

عندما يلغي العميل اشتراكه وتنتهي المهلة التي تعطيها له، تحذف كل صفوفه من كل جدول. والطريقة البسيطة `ON DELETE CASCADE` على كل مفتاح أجنبي ثم حذف سطر `tenants`، وهي تعمل، ولكن سطر حذف واحد يمسح عشرات الجداول دون أن ترى ما حدث، ويمسح بنفس الطريقة لو حذف أحد سطر المستأجر بالخطأ. والأوضح سكربت يحذف الجداول الفرعية أولاً بترتيب المفاتيح الأجنبية، في معاملة واحدة، ويطبع عدد الصفوف من كل جدول حتى يكون في سجل العملية. السكربت `delete-tenant.sh`، وقبله أخذنا نسخة احتياطية كاملة سوف نحتاجها في القسم التالي:

```bash
#!/bin/sh
# delete-tenant.sh <tenant_id>: remove every row of one tenant in one transaction, children first.
set -e
T=$1
psql -h lab-mtp-pg -U saas_ops -d saas -v ON_ERROR_STOP=1 <<SQL
BEGIN;
DELETE FROM invoices        WHERE tenant_id = $T;
DELETE FROM orders          WHERE tenant_id = $T;
DELETE FROM customers       WHERE tenant_id = $T;
DELETE FROM users           WHERE tenant_id = $T;
DELETE FROM tenant_settings WHERE tenant_id = $T;
DELETE FROM tenants         WHERE id = $T;
COMMIT;
SQL
```

```bash
docker exec lab-mtp-pg sh -c 'pg_dump -U postgres -d saas -Fc -f /tmp/saas-before-delete.dump && ls -lh /tmp/saas-before-delete.dump'
docker cp lab-mtp-pg:/tmp/saas-before-delete.dump out/
docker run --rm --network lab-mtp-net -e PGPASSWORD=opspass \
  -v "$PWD/delete-tenant.sh:/delete-tenant.sh:ro" postgres:18 sh /delete-tenant.sh 2
docker exec lab-mtp-pg psql -U postgres -d saas -c "SELECT (SELECT count(*) FROM tenants WHERE id = 2) AS tenant_row, (SELECT count(*) FROM orders WHERE tenant_id = 2) AS its_orders, (SELECT count(*) FROM orders) AS all_orders;"
curl -s -H "Host: globex.saas.localhost" http://127.0.0.1:19682/orders
```

```text
-rw-r--r-- 1 root root 19M Oct  6 08:24 /tmp/saas-before-delete.dump
BEGIN
DELETE 714
DELETE 5000
DELETE 50
DELETE 1
DELETE 1
DELETE 1
COMMIT
 tenant_row | its_orders | all_orders
------------+------------+------------
          0 |          0 |     995001
(1 row)

{"error": "unknown tenant"}
```

حذفت صفوف globex كلها وبقي 995001 طلب للباقين، أي المليون ناقص 5000 زائد الطلب الذي أنشأناه عبر التطبيق، وأصبح نطاقه يعطي «unknown tenant». ولاحظ أن `pg_dump` نفذ بالمستخدم الخارق، لأن [وثائق pg\_dump](https://www.postgresql.org/docs/18/app-pgdump.html?ref=arabroot.io) تقول إنه يضع `row_security` على `off` افتراضياً ويرمي خطأ إذا لم يستطع الدور تجاوز السياسات، حتى لا تخرج نسخة احتياطية ناقصة دون أن تعلم، وهذا سلوك صحيح ولا تحاول الالتفاف عليه.

⚠️

الحذف من قاعدة البيانات لا يحذف البيانات من النسخ الاحتياطية Backups ولا من أرشيف ال WAL ولا من النسخ التي استعدتها للاختبار، فهي تبقى فيها حتى تنتهي مدة الاحتفاظ Retention. لذلك اكتب في شروط الخدمة أن البيانات تحذف من النظام فوراً ومن النسخ الاحتياطية بعد مدة الاحتفاظ، وسجل تاريخ الحذف لكل مستأجر حتى لا تستعيده بالخطأ مع نسخة قديمة، وإذا كان القانون يطلب حذفاً فورياً من كل مكان فهذا سبب حقيقي لقاعدة بيانات لكل مستأجر أو لتشفير بياناته بمفتاح خاص تحذفه.

### استعادة Restore مستأجر Tenant واحد دون استعادة الجميع

لنفرض أن globex عاد بعد يومين يطلب بياناته، أو أن سكربتاً خاطئاً حذف طلبات مستأجر واحد. استعادة النسخة الاحتياطية كلها فوق قاعدة الإنتاج تعني أن تعيد كل المستأجرين الآخرين إلى حالتهم قبل يومين، وهذا غير مناسب إطلاقاً. والطريقة الصحيحة أن تستعيد النسخة كاملة في قاعدة مؤقتة جانبية، ثم تنسخ منها صفوف المستأجر وحده إلى بيئة الإنتاج Production، والجداول الأب قبل الأبناء. السكربت `restore-tenant.sh`:

```bash
#!/bin/sh
# restore-tenant.sh <tenant_id> <dump>: restore the full dump into a scratch database,
# then copy back the rows of one tenant only, parents first.
set -e
T=$1; DUMP=$2
createdb -h lab-mtp-pg -U postgres saas_restore
pg_restore -h lab-mtp-pg -U postgres -d saas_restore --no-owner --no-privileges "$DUMP"
copy() {   # copy <table> <columns> <where>
  psql -h lab-mtp-pg -U postgres -d saas_restore -Atq -c "\copy (SELECT $2 FROM $1 WHERE $3) TO STDOUT" |
  psql -h lab-mtp-pg -U saas_ops -d saas -q -c "\copy $1 ($2) FROM STDIN" -c "SELECT '$1: ' || count(*) FROM $1 WHERE $3" -At
}
copy tenants         "id, slug, name, plan, db_host, created_at"      "id = $T"
copy tenant_settings "tenant_id, timezone, locale, max_users"         "tenant_id = $T"
copy users           "tenant_id, id, email, role, invite_token"       "tenant_id = $T"
copy customers       "tenant_id, id, name, email"                     "tenant_id = $T"
copy orders          "tenant_id, id, customer_id, total, created_at"  "tenant_id = $T"
copy invoices        "tenant_id, id, order_id, amount"                "tenant_id = $T"
dropdb -h lab-mtp-pg -U postgres saas_restore
```

ونشغله بملف كلمات مرور `pgpass` فيه سطر للمستخدم `postgres` وسطر للدور `saas_ops`، ثم نطلب الطلب 202 من نطاق globex:

```bash
docker run --rm --network lab-mtp-net -v "$PWD/restore-tenant.sh:/restore-tenant.sh:ro" \
  -v "$PWD/out:/out" -v "$PWD/pgpass:/pgpass.src:ro" postgres:18 \
  sh -c 'cp /pgpass.src /tmp/pgpass; chmod 600 /tmp/pgpass; PGPASSFILE=/tmp/pgpass sh /restore-tenant.sh 2 /out/saas-before-delete.dump'
curl -s -H "Host: globex.saas.localhost" http://127.0.0.1:19682/orders/202
```

```text
tenants: 1
tenant_settings: 1
users: 1
customers: 50
orders: 5000
invoices: 714
[202, 2, "204.51"]
```

عاد globex بنفس أرقام صفوفه، والطلب 202 بنفس قيمته، ولم يلمس أحد بيانات المستأجرين الآخرين. في الإعداد أعلاه لاحظ التالي:

- الأعمدة مكتوبة صراحة، لأن العمود المولد `email_lower` لا يقبل قيمة من `COPY`، ويحسبه PostgreSQL من جديد.
- الصفوف عادت بأرقامها الأصلية لأن المفاتيح الأجنبية في الجداول الأخرى تشير إليها، ولهذا يجب أن تكون أرقامك فريدة على مستوى القاعدة كلها، فلو أعطت القاعدة هذه الأرقام لمستأجر آخر بعد الحذف لفشلت الاستعادة.
- مع نموذج Schema لكل مستأجر تكون الاستعادة `pg_restore -n t_globex` من النسخة، ومع قاعدة لكل مستأجر تستعيد قاعدته وحدها، وهذا من أقوى أسباب اختيار النماذج المعزولة للعملاء الكبار.

### نقل مستأجر Tenant ثقيل إلى قاعدة بيانات خاصة به

بعد سنة يكبر أحد المستأجرين حتى يصبح نصف حمل القاعدة، وهذا هو [الجار المزعج Noisy Neighbor](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#noisy-neighbor)، أو يشتري باقة تعطيه قاعدة خاصة. والطريقة البسيطة أن توقف المستأجر ساعات وتصدره وتستورده، والأفضل أن تنسخ صفوفه وحده إلى السيرفر الجديد وهو يعمل، ثم تحوله في دقائق. والأداة لذلك في PostgreSQL هي النسخ المنطقي Logical Replication مع مرشح الصفوف Row Filter الموجود منذ PostgreSQL 15، فتقبل [وثائق مرشح الصفوف](https://www.postgresql.org/docs/18/logical-replication-row-filter.html?ref=arabroot.io) شرط `WHERE` بعد كل جدول في [CREATE PUBLICATION](https://www.postgresql.org/docs/18/sql-createpublication.html?ref=arabroot.io)، وتنسخ الصفوف الموجودة التي تحقق الشرط أولاً ثم كل تغيير جديد عليها. والصورة التالية تبين الخطوات:

![السيرفر المشترك lab-mtp-pg فيه المستأجرون من 1 إلى 200 ومنهم المستأجر 7 الثقيل، ومنه PUBLICATION باسم move_tenant_7 بشرط WHERE (tenant_id = 7) لكل جدول، فتنسخ 1 الصفوف الموجودة ثم 2 كل تغيير جديد إلى السيرفر lab-mtp-pg2 الذي لا يحمل إلا المستأجر 7 عبر SUBSCRIPTION مع run_as_owner = true، وفوقهما التطبيق ودليل المستأجرين يشير قبل النقل إلى السيرفر القديم وبعده إلى الجديد، وفي الأسفل 3 خطوات التحويل: إيقاف الكتابة للمستأجر 7 ثم انتظار تأخر 0 بايت ثم setval للعدادات ثم توجيه الدليل إلى السيرفر الجديد ثم حذف الاشتراك والنشر ثم حذف صفوف المستأجر 7 من القاعدة المشتركة](https://arabroot.io/content/images/2026/10/multi-tenancy-practice-06-move-tenant.webp)

نسخ صفوف مستأجر واحد إلى سيرفر جديد وهو يعمل، ثم تحويله في دقائق

نشغل سيرفراً ثانياً، ثم ننشئ النشر Publication على السيرفر المشترك بشرط لكل جدول، الملف `16-publication.sql`:

```sql
CREATE PUBLICATION move_tenant_7 FOR TABLE
    tenants         WHERE (id = 7),
    tenant_settings WHERE (tenant_id = 7),
    users           WHERE (tenant_id = 7),
    customers       WHERE (tenant_id = 7),
    orders          WHERE (tenant_id = 7),
    invoices        WHERE (tenant_id = 7);
```

ولاحظ أن الوثائق تشترط لنشر `UPDATE` و`DELETE` أن تكون أعمدة الشرط ضمن هوية النسخ Replica Identity، وهي المفتاح الأساسي افتراضياً، ومفاتيحنا تبدأ ب `tenant_id`، وهذه فائدة أخرى للمفتاح المركب. وتقول [وثائق قيود النسخ المنطقي](https://www.postgresql.org/docs/18/logical-replication-restrictions.html?ref=arabroot.io) إن الجداول نفسها لا تنسخ، فننسخ الأدوار والجداول والسياسات أولاً بالسكربت `move-schema.sh`:

```bash
#!/bin/sh
# move-schema.sh: copy roles and the schema (tables, keys, policies) from lab-mtp-pg to lab-mtp-pg2.
set -e
pg_dumpall -h lab-mtp-pg -U postgres --roles-only | grep -v -E "ROLE postgres" | psql -h lab-mtp-pg2 -U postgres -q
pg_dump -h lab-mtp-pg -U postgres -d saas --schema-only --no-publications --no-subscriptions | psql -h lab-mtp-pg2 -U postgres -d saas -q
psql -h lab-mtp-pg2 -U postgres -d saas -c "\dt"
```

```bash
docker run -d --name lab-mtp-pg2 --network lab-mtp-net -p 127.0.0.1:19683:5432 \
  -e POSTGRES_PASSWORD=labpass -e POSTGRES_DB=saas postgres:18
docker exec -i lab-mtp-pg psql -U postgres -d saas < 16-publication.sql
docker run --rm --network lab-mtp-net -e PGPASSWORD=labpass \
  -v "$PWD/move-schema.sh:/move-schema.sh:ro" postgres:18 sh /move-schema.sh
```

وآخر المخرج:

```text
                List of tables
 Schema |      Name       | Type  |   Owner
--------+-----------------+-------+------------
 public | customers       | table | saas_owner
 public | invoices        | table | saas_owner
 public | orders          | table | saas_owner
 public | tenant_settings | table | saas_owner
 public | tenants         | table | saas_owner
 public | users           | table | saas_owner
(6 rows)
```

ثم الاشتراك Subscription على السيرفر الجديد بالأمر [CREATE SUBSCRIPTION](https://www.postgresql.org/docs/18/sql-createsubscription.html?ref=arabroot.io)، الملف `16-subscription.sql`، وبعد ثوان نعد الصفوف ونقرأ حالة كل جدول من `pg_subscription_rel`:

```sql
CREATE SUBSCRIPTION move_tenant_7
    CONNECTION 'host=lab-mtp-pg dbname=saas user=postgres password=labpass'
    PUBLICATION move_tenant_7;
```

```sql
SELECT (SELECT count(*) FROM tenants) AS tenants, (SELECT count(*) FROM customers) AS customers,
       (SELECT count(*) FROM orders) AS orders, (SELECT string_agg(DISTINCT tenant_id::text, ',') FROM orders) AS tenant_ids;
SELECT srrelid::regclass AS table_name, srsubstate AS state FROM pg_subscription_rel ORDER BY 1;
```

```text
NOTICE:  created replication slot "move_tenant_7" on publisher
CREATE SUBSCRIPTION
 tenants | customers | orders | tenant_ids
---------+-----------+--------+------------
       1 |         0 |      0 |
(1 row)

   table_name    | state
-----------------+-------
 customers       | d
 invoices        | d
 orders          | d
 tenant_settings | d
 tenants         | r
 users           | d
(6 rows)
```

جدول `tenants` وحده وصل، والباقي عالق في الحالة `d` أي نسخ البيانات، وسجل السيرفر الجديد يقول السبب:

```text
ERROR:  user "saas_owner" cannot replicate into relation with row-level security enabled: "tenant_settings"
ERROR:  user "saas_owner" cannot replicate into relation with row-level security enabled: "users"
ERROR:  user "saas_owner" cannot replicate into relation with row-level security enabled: "invoices"
```

وهذا خطأ لن تراه إلا مع RLS: تقول [وثائق أمان النسخ المنطقي](https://www.postgresql.org/docs/18/logical-replication-security.html?ref=arabroot.io) إن عملية تطبيق التغييرات تنتقل إلى دور مالك الجدول عند كل إدخال أو تعديل، ومالك جداولنا `saas_owner` عليه `FORCE ROW LEVEL SECURITY`، فرفض PostgreSQL أن ينسخ إليه، وجدول `tenants` مر لأنه ليس عليه RLS. والحل الخيار `run_as_owner = true` الذي يجعل كل العمليات بصلاحيات مالك الاشتراك وهو هنا المستخدم الخارق، وتنبه نفس الوثائق إلى أن هذا الخيار يعني أن تثق بمالكي الجداول، لأن أي Trigger على جداولهم سوف يعمل بصلاحيات مالك الاشتراك، وهذا مقبول هنا لأن الجداول جداولنا:

```bash
docker exec lab-mtp-pg2 psql -U postgres -d saas -c "ALTER SUBSCRIPTION move_tenant_7 SET (run_as_owner = true);"
```

```text
 tenants | customers | orders | tenant_ids
---------+-----------+--------+------------
       1 |        50 |   5000 | 7
(1 row)

   table_name    | state
-----------------+-------
 customers       | r
 invoices        | r
 orders          | r
 tenant_settings | r
 tenants         | r
 users           | r
(6 rows)
```

كل الجداول في الحالة `r` أي جاهزة، والسيرفر الجديد فيه المستأجر 7 وحده. الآن نتأكد أن التغييرات الجديدة تصل، فنضيف طلباً للمستأجر 7 وطلباً للمستأجر 8 على السيرفر القديم، ثم نقرأ التأخر من [pg\_replication\_slots](https://www.postgresql.org/docs/18/view-pg-replication-slots.html?ref=arabroot.io):

```bash
docker exec lab-mtp-pg psql -U postgres -d saas -c "INSERT INTO orders (tenant_id, customer_id, total) VALUES (7, 7, 42.00), (8, 8, 42.00) RETURNING tenant_id, id;"
docker exec lab-mtp-pg2 psql -U postgres -d saas -c "SELECT tenant_id, count(*), max(id) FROM orders GROUP BY tenant_id;"
docker exec lab-mtp-pg psql -U postgres -d saas -c "SELECT slot_name, active, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS lag FROM pg_replication_slots;"
```

```text
 tenant_id |   id
-----------+---------
         7 | 1000006
         8 | 1000007
(2 rows)

INSERT 0 2
 tenant_id | count |   max
-----------+-------+---------
         7 |  5001 | 1000006
(1 row)

   slot_name   | active |   lag
---------------+--------+---------
 move_tenant_7 | t      | 0 bytes
(1 row)
```

وصل طلب المستأجر 7 ولم يصل طلب المستأجر 8، والتأخر 0 بايت. وبقي التحويل Cutover، وفي الإنتاج يسبقه إيقاف الكتابة لهذا المستأجر وحده، مثل وضع «للقراءة فقط» يتحقق منه التطبيق لدقيقة، ثم انتظار التأخر حتى يصبح صفراً. وبعدها السكربت `cutover.sh`:

```bash
#!/bin/sh
# cutover.sh: run after tenant 7 stopped writing and the lag is 0 bytes.
set -e
# 1) sequences are not replicated: move them past the last used value
for t in tenants users customers orders invoices; do
  seq=$(psql -h lab-mtp-pg -U postgres -d saas -Atc "SELECT pg_get_serial_sequence('$t', 'id')")
  v=$(psql -h lab-mtp-pg -U postgres -d saas -Atc "SELECT last_value FROM $seq")
  psql -h lab-mtp-pg2 -U postgres -d saas -Atc "SELECT '$t ' || setval(pg_get_serial_sequence('$t', 'id'), $v)"
done
# 2) stop replication (this also drops the slot on the old server)
psql -h lab-mtp-pg2 -U postgres -d saas -c "DROP SUBSCRIPTION move_tenant_7"
psql -h lab-mtp-pg  -U postgres -d saas -c "DROP PUBLICATION move_tenant_7"
# 3) point the directory at the new server, then remove the old rows
PGPASSWORD=opspass psql -h lab-mtp-pg -U saas_ops -d saas -v ON_ERROR_STOP=1 <<'SQL'
BEGIN;
UPDATE tenants SET db_host = 'lab-mtp-pg2' WHERE id = 7;
DELETE FROM invoices        WHERE tenant_id = 7;
DELETE FROM orders          WHERE tenant_id = 7;
DELETE FROM customers       WHERE tenant_id = 7;
DELETE FROM users           WHERE tenant_id = 7;
DELETE FROM tenant_settings WHERE tenant_id = 7;
COMMIT;
SQL
```

```bash
docker run --rm --network lab-mtp-net -e PGPASSWORD=labpass \
  -v "$PWD/cutover.sh:/cutover.sh:ro" postgres:18 sh /cutover.sh
docker exec lab-mtp-pg psql -U postgres -d saas \
  -c "SELECT id, slug, db_host FROM tenants WHERE id IN (6, 7, 8) ORDER BY id;" \
  -c "SELECT count(*) AS tenant_7_orders_left FROM orders WHERE tenant_id = 7;"
```

```text
tenants 204
users 4
customers 10000
orders 1000007
invoices 1428
NOTICE:  dropped replication slot "move_tenant_7" on publisher
DROP SUBSCRIPTION
DROP PUBLICATION
BEGIN
UPDATE 1
DELETE 0
DELETE 5001
DELETE 50
DELETE 0
DELETE 0
COMMIT
 id |   slug   |   db_host
----+----------+-------------
  6 | tenant-6 | lab-mtp-pg
  7 | tenant-7 | lab-mtp-pg2
  8 | tenant-8 | lab-mtp-pg
(3 rows)

 tenant_7_orders_left
----------------------
                    0
(1 row)
```

في الإعداد أعلاه لاحظ التالي:

- العدادات Sequences لا تنسخ بالنسخ المنطقي كما في وثائق القيود، فالسيرفر الجديد كان سيعطي أول طلب جديد الرقم 1، لذلك نقلناها بالدالة `setval` من [دوال العدادات](https://www.postgresql.org/docs/18/functions-sequence.html?ref=arabroot.io) إلى آخر قيمة استخدمها السيرفر القديم.
- `DROP SUBSCRIPTION` حذف فتحة النسخ Replication Slot على السيرفر القديم، ولا تقم بترك فتحة دون مشترك، والسبب أنها تمنع حذف ملفات ال WAL حتى يمتلئ القرص.
- سطر المستأجر 7 بقي في جدول `tenants` على السيرفر القديم، لأنه أصبح دليل المستأجرين Tenant Directory الذي يقرأ منه التطبيق في `resolve_tenant` أين يتصل لكل مستأجر، وهذا هو النموذج الهجين Bridge الذي شرحناه في [قسم نماذج العزل](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#isolation-models). أما تطبيق المختبر فيتصل بقاعدة واحدة، وفي تطبيقك سوف تحتاج مجمعاً لكل سيرفر وأن تختار المجمع من `db_host`.
- الحذف من السيرفر القديم آخر خطوة، وبعد أن يعمل المستأجر على السيرفر الجديد فترة كافية، فإذا ظهرت مشكلة في التحويل رجعت إلى القديم بتغيير `db_host` فقط.

وإذا كانت بياناتك موزعة أصلاً على عدة سيرفرات بأداة [Citus](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/#citus) حسب `tenant_id`، فهي توفر نفس الفكرة بدالة واحدة `isolate_tenant_to_new_shard` التي تنشئ Shard جديداً لمستأجر واحد، ثم `citus_move_shard_placement` التي تنقله إلى سيرفر آخر دون إيقاف الكتابة باستخدام النسخ المنطقي، كما في [مرجع دوال Citus](https://docs.citusdata.com/en/stable/develop/api%5Fudf.html?ref=arabroot.io)، وهما متاحتان في النسخة المفتوحة منذ الإصدار 11\. ولم نشغل Citus في هذا المختبر، فخذ هذا الجزء كطريقة موثقة وليس كنتيجة قسناها. أما الفرق بين التوزيع والنسخ للقراءة والتوفر العالي فشرحناه في [مقال عناقيد قواعد البيانات](https://arabroot.io/articles/%D8%B9%D9%86%D8%A7%D9%82%D9%8A%D8%AF-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#sharding).

## حذف المختبر Lab

بعد أن تنتهي احذف ال Containers والشبكة وال Images التي بنيتها بأسمائها، والخيار `-v` يحذف ال Volume المجهول الذي ينشئه PostgreSQL مع كل Container:

```bash
docker rm -f -v lab-mtp-app lab-mtp-bouncer lab-mtp-pg lab-mtp-pg2
docker network rm lab-mtp-net
docker rmi lab-mtp-pgbouncer:local lab-mtp-app:local
```

## الأخطاء الشائعة

- **التطبيق يتصل بمالك الجداول:** RLS مفعل والسياسات صحيحة، والتطبيق يرى كل شيء لأنه المالك، فاستخدم دوراً منفصلاً للتطبيق وفعل `FORCE ROW LEVEL SECURITY`، واجعل الاختبار يتصل بنفس دور التطبيق.
- **المستأجر بالأمر SET العادي:** يعمل في التطوير على اتصال واحد، ويسرب بيانات مستأجر إلى آخر خلف PgBouncer أو أي مجمع كما رأيت بالمخرج، والحل `SET LOCAL` أو `set_config(..., true)` داخل معاملة صريحة.
- **تحويل الإعداد إلى رقم دون NULLIF:** يعمل أول مرة ثم يفشل بالخطأ `invalid input syntax for type bigint: ""` على كل اتصال استخدم من قبل.
- **المستأجر من جسم الطلب أو الرابط:** أي حقل يرسله المستخدم يستطيع تغييره، فخذ المستأجر من النطاق أو من توكن تم التحقق من توقيعه، وتحقق من عضوية المستخدم فيه.
- **جدول جديد دون سياسة:** لن يتذكره أحد في المراجعة، والاختبار الذي يبحث بنفسه عن كل جدول فيه `tenant_id` يكتشفه قبل الدمج.
- **View أو دالة SECURITY DEFINER يملكها المستخدم الخارق:** تتجاوز RLS لكل من يقرأ منها، فاستخدم `security_invoker = true` لكل View.
- **فهارس لا تبدأ بالمستأجر، أو بحث بدالة غير مضمونة:** يتحول شرط السياسة إلى `Filter` ويقرأ PostgreSQL صفوف كل المستأجرين، فابدأ الفهارس ب `tenant_id` واحفظ القيم المحسوبة في عمود مولد.
- **قيود فريدة على الجدول كله:** مثل `UNIQUE (email)`، فتمنع نفس البريد عند عميلين وتخبر الثاني أن البريد موجود عند غيره.
- **مفتاح أجنبي لا يحمل المستأجر:** يسمح لطلب acme أن يشير إلى عميل globex، والمفتاح المركب يمنعه في قاعدة البيانات نفسها.
- **مهمة خلفية بدور يتجاوز RLS:** لأنها «داخلية»، ثم يأتيها خطأ في المعرف فتكتب في المستأجر الخطأ، والحل أن تحمل الرسالة `tenant_id` وأن يفتح العامل نفس المعاملة.
- **حذف المستأجر ثم نسيان النسخ الاحتياطية:** البيانات باقية فيها حتى تنتهي مدة الاحتفاظ، فاكتب ذلك في شروط الخدمة وسجل تاريخ الحذف.
- **ترك فتحة النسخ بعد نقل مستأجر:** فتحة دون مشترك تبقي ملفات ال WAL على القرص حتى يمتلئ.

## قائمة تحقق العزل Isolation Checklist قبل الإطلاق

1. هل كل جدول يملكه المستأجر فيه `tenant_id NOT NULL`، ومفتاح أساسي وفهارس تبدأ به، ومفاتيح أجنبية مركبة تحمله، وقيود فريدة داخل المستأجر؟
2. هل الفحص من فهرس النظام يعطي `rls = t` و`forced = t` وسياسة واحدة على الأقل لكل جدول فيه `tenant_id`؟
3. هل كل سياسة فيها `USING` و`WITH CHECK` معاً، وتستخدم `NULLIF(current_setting('app.tenant_id', true), '')`؟
4. هل يتصل التطبيق والعمال بدور ليس مالكاً وليس خارقاً وليس له `BYPASSRLS`، وهل الدور الذي له `BYPASSRLS` مستخدم في سكربتات مراجعة فقط؟
5. هل كل وصول إلى جداول المستأجرين يمر من دالة واحدة تفتح معاملة وتضع المستأجر ب `SET LOCAL`، وهل بحثت في الكود عن `SET app.` و`set_config(..., false)` فلم تجد شيئاً؟
6. هل يحدد المستأجر من النطاق أو من توكن تم التحقق منه، ولا يقرأ من جسم الطلب أو من الرابط، وهل تتحقق من عضوية المستخدم في المستأجر؟
7. هل كل رسالة في الطابور وكل مفتاح Cache وكل سطر في السجلات يحمل المستأجر؟
8. هل كل View عليها `security_invoker = true`، وهل راجعت كل دالة `SECURITY DEFINER`؟
9. هل اختبار العزل الذي يبحث بنفسه عن الجداول وال Views يعمل في خط CI بعد كل ترحيل، ويتصل بدور التطبيق؟
10. هل خطط التنفيذ لأهم الاستعلامات تبين شرط السياسة `Index Cond` وليس `Filter`؟
11. هل سكربت التجهيز يمكن تكراره، وجربت تشغيله مرتين؟
12. هل جربت تصدير مستأجر، وحذفه، واستعادته وحده من النسخة الاحتياطية، قبل أن يطلبها عميل حقيقي؟

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- ابدأ من الجداول نفسها: `tenant_id` في أول كل مفتاح وفهرس، ومفاتيح أجنبية مركبة تمنع الإشارة إلى مستأجر آخر، وقيود فريدة داخل المستأجر.
- الشرط في الكود هو خط الدفاع الأول، وسياسات الصفوف Row-Level Security هي الثاني الذي لا يعتمد على ذاكرة أحد: `USING` يخفي صفوف غيرك بصمت، و`WITH CHECK` يرفض الكتابة لغيرك بخطأ.
- RLS يفشل بصمت في ثلاثة أماكن رأيناها بالمخرج: المالك والمستخدم الخارق يتجاوزانه، و`SET` العادي يسرب المستأجر خلف PgBouncer، وال View التي يملكها المستخدم الخارق ترى الجميع. والحلول: دور منفصل للتطبيق مع `FORCE`، و`SET LOCAL` داخل المعاملة، و`security_invoker`.
- اكتب `NULLIF` في السياسة من اليوم الأول، فالإعداد يصبح نصاً فارغاً بعد أول معاملة على الاتصال.
- السياسة شرط يحتاج فهرساً يبدأ بالمستأجر، وفي المختبر قرأ الاستعلام 23 صفحة مع الفهرس المركب مقابل 2778 دونه، والدوال غير المضمونة مثل `lower()` لا تستخدم الفهرس تحت RLS، فاحفظ القيمة في عمود مولد.
- اختبار العزل الذي يبحث بنفسه عن كل جدول وView فيها `tenant_id` اكتشف الجدول الجديد وال View قبل أن يكتب أحد اختباراً لهما.
- التجهيز سكربت يمكن تكراره في معاملة واحدة، والترحيل في النماذج المعزولة معاملة لكل مستأجر مع رقم إصدار لكل مستأجر.
- التصدير يمر من السياسات، والاستعادة تكون في قاعدة جانبية ثم تنسخ صفوف المستأجر وحده، والحذف لا يصل إلى النسخ الاحتياطية، ونقل مستأجر ثقيل يتم وهو يعمل بالنسخ المنطقي مع مرشح الصفوف، مع `run_as_owner` بسبب RLS ونقل العدادات يدوياً.

[![](https://arabroot.io/content/images/2026/10/multi-tenancy-cover-cover.webp)دليلتعدد المستأجرين Multi-Tenancy في SaaS للمبتدئينكيف يخدم تطبيق SaaS واحد مئات العملاء دون أن يرى أحدهم بيانات غيره، وأي نموذج عزل يناسب تطبيقك.](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/connection-pooling-cover-cover.webp)دليلConnection Pooling: لماذا الاتصال بقاعدة البيانات مكلفلماذا تظهر رسالة too many clients عندما تزيد نسخ تطبيقك، وكيف يحل مجمع الاتصالات وPgBouncer هذه المشكلة.](https://arabroot.io/articles/%D8%AA%D8%AC%D9%85%D9%8A%D8%B9-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84%D8%A7%D8%AA-connection-pooling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/db-design-cover-cover.webp)دليلتصميم قواعد البيانات: التطبيع Normalization ومتى تكسرهلماذا يفشل الجدول الواحد الكبير، وكيف تقسمه إلى جداول لا تناقض نفسها، وما الذي يتغير عندما تكبر البيانات.](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D8%B9-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D8%B3%D8%B9/)[![](https://arabroot.io/content/images/2026/10/postgres-large-tables-cover-cover.webp)دليلPostgreSQL للجداول الضخمة: عشرات الملايين من الصفوفماذا تفعل عندما يصل جدول الطلبات إلى عشرات الملايين من الصفوف وتبطئ لوحة التحكم ويمتلئ القرص، وكيف تقيس قبل أن تصلح.](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/)[![](https://arabroot.io/content/images/2026/10/db-clustering-cover-cover.webp)دليلعناقيد قواعد البيانات Database Clustering ومتى تحتاجهاماذا تعني كلمة Cluster في كل قاعدة بيانات، ومتى تحتاج نسخاً أو انتقالاً تلقائياً، ومتى يكفيك سيرفر واحد مع نسخ احتياطية جيدة.](https://arabroot.io/articles/%D8%B9%D9%86%D8%A7%D9%82%D9%8A%D8%AF-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/environments-gitops-cover-cover.webp)دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D9%8A%D8%A6%D8%A7%D8%AA-%D9%88%D9%85%D8%B9%D8%A7%D9%8A%D9%86%D8%A9-%D8%A7%D9%84%D9%85%D9%8A%D8%B2%D8%A7%D8%AA-%D9%88-gitops-%D9%84%D9%84%D9%81%D8%B1%D9%82/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال واختباره على PostgreSQL 18.6 وPgBouncer 1.26.0 من مستودع PGDG وPython 3.13 مع psycopg 3.3.6 وDocker Engine 29.8.0.