> ## 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.

# ZITADEL: هوية متعددة المستأجرين Multi-tenant لتطبيقك
- URL: https://arabroot.io/articles/zitadel-هوية-متعددة-المستاجرين-لتطبيق-saas/
- Published: 2026-10-26T01:52:52.000Z
- Updated: 2026-10-26T02:52:39.000Z
- Description: نبني لمنصة SaaS صغيرة طبقة هوية بـ ZITADEL، يدير فيها كل عميل مستخدميه، ولا يرى أحد منهم عميلاً آخر.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, الأمن السيبراني, الاستضافة الذاتية, ZITADEL

لنفرض أن لديك شركة ناشئة صغيرة في الرياض، تبيع للشركات تطبيقاً على الويب لإدارة طلبات الصيانة في مبانيها، والعميل الأول «شركة أكمي» وقع العقد الأسبوع الماضي. وفي اليوم الأول كان الحل واضحاً: جدول `users` في قاعدة بيانات التطبيق، وعمود `company_id` يربط كل مستخدم بشركته، وصفحة دخول بكلمة مرور كتبها أحد المطورين في يومين. ثم جاء العميل الثاني «مؤسسة جلوبكس»، وفي الشهر الأول وصلتك منه ثلاثة طلبات: مدير تقنية المعلومات عندهم يريد أن يضيف موظفيه ويعطلهم بنفسه دون أن يرسل لكم بريداً في كل مرة، وفريق الأمن يريد أن يدخل الموظفون بحسابات Microsoft Entra ID الخاصة بالمؤسسة، والمراجع الداخلي يسأل: من دخل إلى بيانات مؤسستنا، ومتى، ومن أين؟ وفي نفس الأسبوع اكتشف أحد المطورين أن صفحة إدارة المستخدمين تعرض للمدير كل المستخدمين إذا حذف المعامل `company_id` من الرابط، أي أن مدير أكمي كان يستطيع أن يرى موظفي جلوبكس.

وهذه القصة ليست غريبة، وإنما هي الطريق الطبيعي لكل منتج يبيع للشركات Business-to-Business واختصاراً B2B، حيث يصبح كل عميل مستأجراً 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/) شرحنا نماذج العزل، وفي [التطبيق العملي](https://arabroot.io/articles/%D8%A8%D9%86%D8%A7%D8%A1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-saas-%D9%85%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-%D8%B9%D9%85%D9%84%D9%8A%D8%A7/) بنينا عزل البيانات داخل PostgreSQL بسياسات الصفوف، أما في هذا المقال فسوف نبني طبقة الهوية Identity نفسها بأداة صممت لهذه الحالة بالذات، وهي [ZITADEL](https://zitadel.com/?ref=arabroot.io)، ونتابع فيه قصة الشركة الناشئة خطوة خطوة: من العميل الأول إلى العميل الذي يغادر.

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

- لماذا يفشل جدول المستخدمين داخل التطبيق مع العميل الثاني، وما الخيارات الثلاثة لبناء الهوية في منتج متعدد المستأجرين.
- ما هو ZITADEL، وتاريخ رخصته، وسرعة إصداراته الأمنية، ونموذجه: النسخة Instance والمؤسسة Organization والمشروع Project ومنح المشروع Project Grant.
- كيف يعمل من الداخل: خدمة API بلغة Go، وواجهة دخول منفصلة، وسجل أحداث Event Store بدل الجداول العادية.
- التثبيت مع PostgreSQL وCaddy، والمشكلتان اللتان واجهتانا أثناءه، والحساب الإداري الذي يأتي بكلمة مرور معروفة.
- بناء العميلين بسكربت، والدخول إلى تطبيق تجريبي، وقراءة المؤسسة والأدوار من التوكن سطراً سطراً.
- الدخول المقيد بمؤسسة، والتسجيل الذاتي المفتوح افتراضياً، والإدارة المفوضة لمدير العميل، وتعطيل عميل يغادر.
- سجل التدقيق، والموارد المقيسة، والنسخ الاحتياطي ومفتاح التشفير الرئيسي Masterkey، والتحديث، والتكلفة، وقائمة تحقق قبل الإطلاق.

[![](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/multi-tenancy-practice-cover-cover.webp)دليلتطبيق SaaS متعدد المستأجرين عملياً مع Row-Level Securityنبني على PostgreSQL تطبيق SaaS يعزل كل مستأجر Tenant عن غيره بسياسات Row-Level Security، ونرى أين تتسرب البيانات رغم ذلك.](https://arabroot.io/articles/%D8%A8%D9%86%D8%A7%D8%A1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-saas-%D9%85%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-%D8%B9%D9%85%D9%84%D9%8A%D8%A7/)[![](https://arabroot.io/content/images/2026/10/oauth-oidc-cover-cover.webp)دليلشرح OAuth وOpenID Connect للمبتدئينلماذا لا يجب أن يعرف أي تطبيق كلمة مرورك، وكيف يحل OAuth ذلك بتوكنات محدودة، وكيف يضيف OpenID Connect الدخول الموحد.](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-oauth-%D9%88-openid-connect-%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/keycloak-cover-cover.webp)دليلتثبيت Keycloak مزود الهوية IdP الأشهر في المؤسساتنثبت Keycloak مع PostgreSQL، ونربط به تطبيقاً عبر OIDC، ونفرض المصادقة الثنائية على الجميع، ونقيس ما يحتاجه من ذاكرة.](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-keycloak-%D9%85%D8%B2%D9%88%D8%AF-%D8%A7%D9%84%D9%87%D9%88%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A4%D8%B3%D8%B3%D8%A7%D8%AA/)

## لماذا لا يكفي جدول المستخدمين في التطبيق؟

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

- **الإدارة المفوضة Delegated Administration:** مدير العميل يضيف ويعطل ويعيد تعيين كلمات المرور لموظفيه فقط، وهذا يعني شاشات وصلاحيات واختبارات تمنعه من الوصول إلى غيره، وأي خطأ فيها هو بالضبط خطأ `company_id` الذي في القصة.
- **الدخول عبر مزود هوية العميل:** كل عميل كبير يريد أن يدخل موظفوه بحساباتهم في Entra ID أو Google Workspace أو Okta، أي أنك تبني تكاملاً عبر OIDC أو SAML مع كل عميل على حدة، وتحفظ أسرار كل تكامل.
- **المصادقة الثنائية MFA ومفاتيح المرور Passkeys:** وهي اليوم شرط في أغلب استبيانات الأمن التي ترسلها الشركات قبل التعاقد.
- **سجل التدقيق Audit Trail:** من دخل ومن غير صلاحية من، بشكل لا يستطيع أحد تعديله لاحقاً.
- **دورة حياة العميل:** عميل يغادر يجب أن يتوقف كل مستخدميه في لحظة واحدة، وعميل جديد يجب أن يجهز بأمر واحد.

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

📌

في أغسطس 2021 نشر باحثو Wiz [تفاصيل ثغرة ChaosDB](https://www.wiz.io/blog/chaosdb-how-we-hacked-thousands-of-azure-customers-databases?ref=arabroot.io) في خدمة Azure Cosmos DB، حيث استطاعوا عبر خاصية Jupyter Notebook المدمجة أن يحصلوا على مفاتيح الوصول الأساسية لقواعد بيانات آلاف من عملاء Azure الآخرين، أي أن العزل بين المستأجرين في خدمة سحابية كبيرة فشل في مكان واحد. وقد عطلت Microsoft الخاصية خلال 48 ساعة من البلاغ، وطلبت من أكثر من 30٪ من عملاء Cosmos DB تغيير مفاتيحهم يدوياً. والدرس لمنتجك الصغير أن العزل بين العملاء يجب أن يكون في تصميم الطبقة نفسها، لا في شرط تتذكره في كل استعلام.

## ثلاث طرق لبناء الهوية في منتج متعدد المستأجرين

عندما يقرر الفريق أن يخرج الهوية من التطبيق إلى مزود هوية Identity Provider مستقل، فهناك ثلاثة أشكال معروفة، ولكل منها ثمنه:

| الطريقة                                      | كيف تعمل                                                                                                                                                                                                                                     | نقاط القوة                                                          | نقاط الضعف                                                                           |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| مزود هوية بنطاق واحد وعمود الشركة في التطبيق | كل المستخدمين في مكان واحد، والتطبيق يقرر الشركة من خاصية Attribute في التوكن                                                                                                                                                                | بسيط، وأي مزود هوية يكفي                                            | الإدارة المفوضة والدخول عبر مزود العميل يبقيان عليك، والعزل في كود التطبيق           |
| نطاق Realm لكل عميل                          | مثل [Keycloak](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-keycloak-%D9%85%D8%B2%D9%88%D8%AF-%D8%A7%D9%84%D9%87%D9%88%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A4%D8%B3%D8%B3%D8%A7%D8%AA/): كل عميل نطاق مستقل بمستخدميه وتطبيقاته | عزل قوي، وكل عميل يضبط سياساته                                      | التطبيق يسجل في كل نطاق من جديد، ومع مئات العملاء تصبح الإعدادات والترقيات ثقيلة     |
| مؤسسات Organizations داخل نسخة واحدة         | مثل ZITADEL: تطبيق واحد، وكل عميل مؤسسة لها مستخدموها ومديروها ومزودو هويتها                                                                                                                                                                 | صمم لـ B2B: إدارة مفوضة، ومزود هوية لكل مؤسسة، ومنح المشروع للعملاء | مفاهيم جديدة يجب أن يتعلمها الفريق، ونسخة واحدة يعني أن خطأ الإعداد العام يمس الجميع |

والطريقة الثالثة هي التي نجربها هنا، والسبب أن قصة الشركة الناشئة فيها كل علامات B2B: عملاء يديرون أنفسهم، وكل واحد منهم يريد مزود هويته، والتطبيق نفسه واحد للجميع.

## ما هو ZITADEL؟

ZITADEL مزود هوية مفتوح المصدر يدعم OpenID Connect وOAuth 2.0 وSAML، ومعه واجهة إدارة Console وواجهات برمجية API كاملة، وما يميزه أن المؤسسات Organizations فيه مفهوم أساسي منذ البداية، وليست إضافة. وقد غير المشروع رخصته في [الإصدار 3.0.0](https://github.com/zitadel/zitadel/releases/tag/v3.0.0?ref=arabroot.io) في 2 مايو 2025 من Apache 2.0 إلى AGPL-3.0، وأزال في نفس الإصدار دعم CockroachDB فصار PostgreSQL هو قاعدة البيانات الوحيدة. ثم جاء الإصدار 4 في يوليو 2025 بواجهة الدخول الجديدة Login v2 في خدمة مستقلة مبنية بـ Next.js، وهي التي سوف نستخدمها.

وقد يتساءل البعض: هل رخصة AGPL مشكلة لمنتج تجاري؟ والإجابة أنها تطلب منك نشر كود ZITADEL المعدل إذا عدلته وقدمته للمستخدمين عبر الشبكة، أما تشغيله كما هو بجانب منتجك فلا يطلب منك نشر كود منتجك، لأن تطبيقك يتحدث معه عبر بروتوكولات قياسية ولا يدمجه. ومع ذلك فإذا كانت سياسة شركتك أو عقود عملائك تمنع AGPL فإن ZITADEL تبيع [رخصة تجارية](https://zitadel.com/pricing?ref=arabroot.io) ضمن خطة Enterprise، واسأل المستشار القانوني قبل القرار، فهذه ليست فتوى قانونية.

والمشروع نشط جداً، فآخر إصدار عند كتابة هذا الدليل هو [v4.20.0](https://github.com/zitadel/zitadel/releases/tag/v4.20.0?ref=arabroot.io) في 22 أكتوبر 2026، وصدر في 2026 حتى الآن 45 إصداراً. ولكن الوجه الآخر لذلك أن [صفحة التنبيهات الأمنية](https://github.com/zitadel/zitadel/security/advisories?ref=arabroot.io) فيها 42 تنبيهاً في 2026 وحدها، منها 5 بدرجة Critical و23 بدرجة High، وفي 15 أكتوبر 2026 فقط نشر المشروع ثلاثة تنبيهات بعنوان الاستيلاء على الحساب Account Takeover أصلحها الإصدار 4.19.5، منها واحد في Login v2 نفسها عبر إثبات للتحقق من البريد يمكن تزويره. لذلك فمن يشغل ZITADEL لمنتج فيه عملاء يجب أن يكون لديه من يتابع هذه الصفحة ويحدث خلال أيام، وسوف نعود إلى ذلك في قسم التحديث.

## النموذج: النسخة والمؤسسة والمشروع والمنح

قبل أي أمر يجب أن تعرف خمسة مفاهيم، لأن كل قرار في المقال مبني عليها، والشكل التالي يضعها في قصة الشركة الناشئة:

![نسخة ZITADEL واحدة فيها مؤسسة صاحب التطبيق مع المشروع والأدوار والتطبيق، ومؤسستان للعميلين، والمشروع ممنوح لكل منهما](https://arabroot.io/content/images/2026/10/zitadel-01-tenant-model.webp)

مؤسسة لك فيها المشروع، ومؤسسة لكل عميل، ومنح المشروع يربط بينهما

- **النسخة Instance:** تثبيت ZITADEL كاملاً على عنوان واحد مثل `auth.example.com`، وفيه الإعدادات الافتراضية لكل المؤسسات، مثل سياسة الدخول وسياسة كلمات المرور. ويستطيع تثبيت واحد أن يحمل عدة نسخ، ولكن نسخة واحدة تكفي منتجاً واحداً.
- [**المؤسسة Organization**](https://zitadel.com/docs/concepts/structure/organizations?ref=arabroot.io)**:** حاوية للمستخدمين ومديريهم وسياساتهم ومزودي هويتهم، والمستخدم ينتمي إلى مؤسسة واحدة فقط. وفي قصتنا مؤسسة لك أنت صاحب التطبيق، ومؤسسة لكل عميل.
- [**المشروع Project**](https://zitadel.com/docs/concepts/structure/projects?ref=arabroot.io)**:** يجمع تطبيقات منتجك والأدوار Roles التي يفهمها، مثل `admin` و`member`، ويملكه صاحب التطبيق في مؤسسته.
- **منح المشروع Project Grant:** طريقتك لتقول إن مؤسسة العميل تستطيع استخدام مشروعك، وبأي أدوار، فتمنح أكمي الدورين، وقد تمنح عميلاً على خطة أرخص دور `member` فقط.
- **منح المستخدم Authorization:** داخل مؤسسة العميل يعطي مديرها دوراً من الأدوار الممنوحة لكل موظف، وهذا ما يصل إلى تطبيقك في التوكن.

والفكرة المهمة في هذا النموذج أن تطبيقك مسجل مرة واحدة فقط، في مشروع واحد، ومع ذلك يدخل إليه موظفو كل العملاء، وكل عميل يدير ناسه بنفسه دون أن يلمس إعداد تطبيقك، وهذا الفرق الجوهري بينه وبين نطاق لكل عميل. وتشرح ZITADEL هذا السيناريو في [دليل حالات B2B](https://zitadel.com/docs/guides/solution-scenarios/b2b?ref=arabroot.io).

## كيف يعمل ZITADEL من الداخل؟

في النسخة 4 يتكون التشغيل من خدمتين لـ ZITADEL وقاعدة بيانات، ونضع أمامها Reverse Proxy، والشكل التالي يبين ما شغلناه في المختبر:

![Caddy يوزع الطلبات بين خدمة zitadel-login لواجهة الدخول وخدمة zitadel-api لبقية المسارات، والـ API يكتب في PostgreSQL، وصندوق للمفتاح الرئيسي والنسخة الاحتياطية](https://arabroot.io/content/images/2026/10/zitadel-02-architecture.webp)

واجهة الدخول وخدمة الـ API منفصلتان، وكل الحالة في PostgreSQL

- **zitadel-api:** برنامج واحد بلغة Go يخدم بروتوكولات OIDC وSAML، وواجهة الإدارة Console، والواجهات البرمجية بأشكالها: gRPC وREST، لذلك يتحدث معه الـ Proxy عبر HTTP/2 دون TLS، وهذا ما يسمى h2c.
- **zitadel-login:** تطبيق Next.js يعرض صفحات الدخول والتسجيل وإعادة كلمة المرور، ولا يملك قاعدة بيانات، وإنما يستدعي الـ API بتوكن وصول شخصي Personal Access Token يكتبه الـ API في Volume مشترك عند أول تشغيل. وفصله عن الـ API يسمح لك أن تبني واجهة دخول خاصة بمنتجك فوق نفس الواجهات البرمجية.
- **PostgreSQL:** فيه كل شيء، ولكن بطريقة مختلفة عن أغلب التطبيقات، كما في القسم التالي.

### سجل الأحداث Event Sourcing بدل تعديل الصفوف

أغلب التطبيقات تحفظ المستخدم في صف، وعندما يغير بريده تعدل الصف فيضيع البريد القديم. أما ZITADEL فيبني على [مخزن الأحداث Event Store](https://zitadel.com/docs/concepts/eventstore/overview?ref=arabroot.io)، أي أن كل تغيير يضاف حدثاً جديداً إلى جدول لا يعدل ولا يحذف منه، مثل «أضيف المستخدم» و«تحقق بريده» و«نجح تحقق كلمة مروره»، ثم تقرأ عمليات تسمى الإسقاطات Projections هذه الأحداث بالترتيب وتبني منها جداول جاهزة للقراءة، مثل جدول المستخدمين الذي تستعلم منه الـ Console. والشكل التالي يبين ذلك بالأحداث الحقيقية لمستخدمة من المختبر:

![ثلاثة أحداث حقيقية للمستخدمة sara في سجل الأحداث، والإسقاطات تبني منها جداول القراءة، والنتيجة سجل تدقيق](https://arabroot.io/content/images/2026/10/zitadel-03-events.webp)

كل تغيير حدث جديد، والجداول التي تقرؤها الواجهة مبنية من الأحداث

ولهذا التصميم ثلاث نتائج عملية في قصتنا: الأولى أن سجل التدقيق الذي طلبه مراجع جلوبكس موجود من اليوم الأول لأنه هو البيانات نفسها، والثانية أن قاعدة البيانات تكبر مع كل دخول وكل تغيير ولا تصغر، فقد وصلت في المختبر إلى 1,124 حدثاً بعد ساعة واحدة من التجارب، والثالثة أن بعض التغييرات تظهر في جداول القراءة بعد لحظة من كتابتها، لأن الإسقاط يعمل بعد الحدث، وهذا ما يسمى الاتساق النهائي Eventual Consistency، فإذا كتبت سكربتاً ينشئ مستخدماً ثم يبحث عنه فوراً فقد لا يجده في أول محاولة.

## المتطلبات

- سيرفر Linux بمعالجين 2 vCPU وذاكرة 2 GB يكفي للبداية، فقد استهلكت الخدمات الأربع في الخمول نحو 250 MiB معاً، ووصلت بعد التجارب إلى نحو 400 MiB، والتفصيل في قسم الموارد.
- مساحة قرص بنحو 2 GB، منها 237 MB لـ Image الـ API و332 MB لـ Image واجهة الدخول، وقاعدة البيانات صغيرة في البداية ثم تكبر مع الأحداث.
- نطاق فرعي مثل `auth.example.com` مع TLS، والسبب أن ZITADEL يبني من هذا العنوان كل شيء، ويرفض أي طلب لا يطابقه كما سيأتي.
- PostgreSQL، وفي هذا الدليل نشغله في Container بجانب ZITADEL، وفي بيئة الإنتاج Production قد تفضل قاعدة بيانات مدارة أو سيرفراً خاصاً بها.
- حساب SMTP لرسائل التحقق من البريد وإعادة كلمة المرور، وقد أجلناه في المختبر لأننا ننشئ المستخدمين ببريد محقق.

## التثبيت

نبدأ من [ملف Compose الرسمي](https://zitadel.com/docs/self-hosting/deploy/compose?ref=arabroot.io) للإصدار 4، وفيه Traefik يقرأ إعداده من الـ Docker Socket، وقد استبدلناه بـ Caddy بملف إعداد صغير، والسبب أن تمرير `docker.sock` إلى أي Container يعادل منحه صلاحية root على السيرفر، ولا حاجة لذلك هنا.

### الأسرار في ملف .env

```bash
sudo mkdir -p /opt/zitadel && cd /opt/zitadel
cat > .env <<EOF
ZITADEL_MASTERKEY=$(openssl rand -hex 16)
LOGIN_SESSION_COOKIE_SECRET=$(openssl rand -hex 24)
PG_PASS=$(openssl rand -hex 24)
EOF
chmod 600 .env
```

في الأسرار أعلاه لاحظ التالي:

- `ZITADEL_MASTERKEY` طوله 32 حرفاً بالضبط، وبه يشفر ZITADEL المفاتيح التي يحفظها في قاعدة البيانات، مثل مفاتيح تشفير التوكنات وأسرار مزودي الهوية الخارجيين، وفقدانه يعني أن النسخة لا تعمل، كما سوف نجرب في قسم النسخ الاحتياطي.
- `LOGIN_SESSION_COOKIE_SECRET` توقع به واجهة الدخول كوكي جلستها، ويجب أن يكون 32 حرفاً على الأقل.

### ملف docker-compose.yml

```yaml
services:
  postgres:
    image: postgres:17.6-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: zitadel
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${PG_PASS:?}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d zitadel -U postgres"]
      interval: 10s
      timeout: 30s
      retries: 10
    volumes:
      - postgres-data:/var/lib/postgresql/data

  zitadel-api:
    image: ghcr.io/zitadel/zitadel:v4.20.0
    restart: unless-stopped
    user: "0"
    command: start-from-init --masterkey "${ZITADEL_MASTERKEY}"
    environment:
      ZITADEL_PORT: 8080
      ZITADEL_EXTERNALDOMAIN: localhost
      ZITADEL_EXTERNALPORT: 18820
      ZITADEL_EXTERNALSECURE: "false"
      ZITADEL_TLS_ENABLED: "false"
      ZITADEL_DATABASE_POSTGRES_DSN: postgresql://postgres:${PG_PASS}@postgres:5432/zitadel?sslmode=disable
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false"
      ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel/bootstrap/login-client.pat
      ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client
      ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Automatically Initialized IAM_LOGIN_CLIENT
      ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z"
      ZITADEL_FIRSTINSTANCE_PATPATH: /zitadel/bootstrap/admin.pat
      ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: lab-admin
      ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Lab automation
      ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z"
      ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true"
      ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:18820/ui/v2/login/
      ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:18820/ui/v2/login/login?authRequest=
      ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:18820/ui/v2/login/logout?post_logout_redirect=
      ZITADEL_SAML_DEFAULTLOGINURLV2: http://localhost:18820/ui/v2/login/login?samlRequest=
    healthcheck:
      test: ["CMD", "/app/zitadel", "ready"]
      interval: 10s
      timeout: 30s
      retries: 12
      start_period: 20s
    volumes:
      - zitadel-bootstrap:/zitadel/bootstrap:rw
    depends_on:
      postgres:
        condition: service_healthy

  zitadel-login:
    image: ghcr.io/zitadel/zitadel-login:v4.20.0
    restart: unless-stopped
    user: "0"
    environment:
      ZITADEL_API_URL: http://zitadel-api:8080
      NEXT_PUBLIC_BASE_PATH: /ui/v2/login
      ZITADEL_SERVICE_USER_TOKEN_FILE: /zitadel/bootstrap/login-client.pat
      ZITADEL_SESSION_COOKIE_SECRET: ${LOGIN_SESSION_COOKIE_SECRET:?}
      CUSTOM_REQUEST_HEADERS: Host:localhost,X-Forwarded-Proto:http
    healthcheck:
      test: ["CMD", "/bin/sh", "-c", "node /app/healthcheck.mjs http://localhost:3000/ui/v2/login/healthy"]
      interval: 10s
      timeout: 30s
      retries: 12
      start_period: 20s
    volumes:
      - zitadel-bootstrap:/zitadel/bootstrap:ro
    depends_on:
      zitadel-api:
        condition: service_healthy

  proxy:
    image: caddy:2.10.2-alpine
    restart: unless-stopped
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
    ports:
      - "127.0.0.1:18820:80"
    depends_on:
      zitadel-api:
        condition: service_healthy
      zitadel-login:
        condition: service_healthy

volumes:
  postgres-data:
  zitadel-bootstrap:
```

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

- الثلاثي `EXTERNALDOMAIN` و`EXTERNALPORT` و`EXTERNALSECURE` هو العنوان الذي يراه المستخدم بالضبط، وهو في المختبر `http://localhost:18820`، وفي الإنتاج `auth.example.com` و443 و`true`. وZITADEL يحدد النسخة Instance من اسم النطاق في كل طلب، لذلك أي اختلاف بين هذه القيم والعنوان الحقيقي يعطيك خطأ `Instance not found`، ويقول المشروع في ملف شرح Compose إنه أكثر خطأ يتكرر عند التثبيت، وسوف نراه بأعيننا بعد قليل.
- `start-from-init` ينشئ قاعدة البيانات والجداول عند أول تشغيل، ثم ينفذ خطوات الترقية Setup، ثم يبدأ الخدمة، وهو آمن عند التكرار، لذلك يعمل مع كل إعادة تشغيل. أما [دليل الإنتاج](https://zitadel.com/docs/self-hosting/manage/production?ref=arabroot.io) فيفصل هذه الخطوات الثلاث في Containers منفصلة، وهذا أنسب عندما تشغل أكثر من نسخة من الـ API حتى لا تتسابق على الترحيل Migration.
- متغيرات `FIRSTINSTANCE` لا تعمل إلا في أول تشغيل على قاعدة بيانات فارغة، وأضفنا منها حساباً آلياً Machine User اسمه `lab-admin` يكتب ZITADEL توكنه في `admin.pat`، وبه سوف نبني العملاء من سطر الأوامر، وهذا الحساب يملك صلاحية المدير على النسخة كلها، فعامل ملفه مثل كلمة مرور root.
- `user: "0"` موجودة في الملف الرسمي حتى يكتب الـ API في الـ Volume المشترك، ولاحظ أن الـ Image الخاصة بالـ API لا تضم أوامر مثل `cat` و`ls`، لذلك قرأنا التوكن من Container واجهة الدخول التي تضم `sh`.

### ملف Caddyfile: من يخدم أي مسار؟

```text
:80 {
	handle_path /api/* {
		reverse_proxy h2c://zitadel-api:8080
	}
	handle /ui/v2/login* {
		reverse_proxy zitadel-login:3000
	}
	handle / {
		rewrite * /ui/v2/login/
		reverse_proxy zitadel-login:3000
	}
	handle {
		reverse_proxy h2c://zitadel-api:8080
	}
}

# back channel for containers: same API, with the public Host header that ZITADEL expects
:81 {
	reverse_proxy h2c://zitadel-api:8080 {
		header_up Host localhost:18820
		header_up X-Forwarded-Host localhost:18820
	}
}
```

والقواعد هي نفس قواعد التوجيه في الملف الرسمي لـ Traefik: مسارات واجهة الدخول إلى خدمة Next.js، والعنوان الرئيسي للموقع Root URL يعاد كتابته إلى صفحة الدخول، والبادئة `/api` اسم مختصر للواجهات البرمجية، وكل ما عدا ذلك، ومنه `/.well-known/openid-configuration` و`/oauth/v2/` و`/ui/console`، يذهب إلى الـ API عبر h2c. أما الموقع الثاني على المنفذ 81 فلم يكن في خطتنا، وإنما أضفناه بسبب المشكلة الثانية التي واجهتنا.

### التشغيل الأول

```bash
docker compose up -d --wait
docker compose logs zitadel-api | grep -E "initialization started|setup started|server is listening"
```

```text
time=2026-10-25T22:26:47.800Z level=INFO msg="initialization started" stream=runtime version=v4.20.0
time=2026-10-25T22:26:48.836Z level=INFO msg="setup started" stream=runtime version=v4.20.0
time=2026-10-25T22:27:28.440Z level=INFO msg="server is listening" stream=runtime version=v4.20.0 address=[::]:8080
```

أي أن الإنشاء والترحيل وتجهيز النسخة الأولى استغرقت نحو 41 ثانية، وكان السيرفر في تلك اللحظة مشغولاً بتجارب أخرى، فتوقع أقل من ذلك على سيرفر هادئ. ثم نتحقق من ملف الاكتشاف Discovery:

```bash
curl -s http://localhost:18820/.well-known/openid-configuration | head -c 200
```

```text
{"issuer":"http://localhost:18820","authorization_endpoint":"http://localhost:18820/oauth/v2/authorize","token_endpoint":"http://localhost:18820/oauth/v2/token","introspection_endpoint":"http://localh
```

### المشكلة الأولى: التاريخ الذي حوله YAML إلى شيء آخر

في المحاولة الأولى كتبنا تاريخ انتهاء التوكنات دون علامات تنصيص، فتوقف الـ API وكتب في السجل:

```text
Error: unable to read steps: decoding failed due to the following error(s):

'FirstInstance.Org.Machine.Pat.ExpirationDate' parsing time "2099-01-01 00:00:00 +0000 UTC" as "2006-01-02T15:04:05Z07:00": cannot parse " 00:00:00 +0000 UTC" as "T"
```

والسبب أن YAML يتعرف على القيمة `2099-01-01T00:00:00Z` بلا علامات تنصيص على أنها تاريخ، فيحولها Compose إلى نص بصيغة مختلفة قبل أن يمررها إلى المتغير، ولا يفهم ZITADEL الصيغة الجديدة. وفي الملف الرسمي لا تظهر المشكلة لأن القيمة تأتي من ملف `.env` عبر متغير، لذلك ضع علامات التنصيص على كل قيمة تشبه التاريخ أو الرقم أو true وfalse في متغيرات البيئة. وبعد التصحيح احذف الـ Volumes وابدأ من جديد، لأن متغيرات `FIRSTINSTANCE` لا تقرأ إلا على قاعدة بيانات فارغة.

### المشكلة الثانية: Instance not found من داخل Docker

التطبيق التجريبي يعمل في Container، ويحتاج أن يستبدل الكود بالتوكن عبر القناة الخلفية Back Channel، فوجهناه في البداية إلى `http://proxy:81` مع ترويسة Host صحيحة، فكان الرد 404، وفي سجل الـ API:

```text
level=error msg="unable to set instance" error="unable to get instance by domain: instanceDomain proxy, publicHostname proxy: ID=QUERY-1kIjX Message=Errors.Instance.NotFound"
```

أي أن ZITADEL قرأ اسم النطاق من ترويسة `X-Forwarded-Host` التي أضافها Caddy بالاسم الداخلي `proxy:81`، وليس من ترويسة Host التي عدلناها، ولم يجد نسخة بهذا الاسم. والحل أن نضبط الترويستين معاً كما في الموقع الثاني من Caddyfile، وبعدها صار ملف الاكتشاف يعود من داخل الشبكة بنفس الـ Issuer الذي يراه المتصفح. وفي بيئة الإنتاج لن تحتاج إلى هذه الحيلة غالباً، لأن التطبيق يستطيع أن يصل إلى `https://auth.example.com` نفسه.

## الحساب الإداري الذي يأتي بكلمة مرور معروفة

ينشئ ZITADEL في أول تشغيل مستخدماً بشرياً مديراً اسمه `zitadel-admin@zitadel.localhost`، وكلمة مروره الافتراضية `Password1!` كما يذكر [دليل Compose الرسمي](https://zitadel.com/docs/self-hosting/deploy/compose?ref=arabroot.io)، والملف الرسمي نفسه يضبط `PASSWORDCHANGEREQUIRED` على `false`، أي أن النظام لا يجبرك على تغييرها. وقد جربناها فعلاً فدخلنا إلى واجهة الإدارة وظهرت المؤسسات الثلاث:

![واجهة إدارة ZITADEL تعرض ثلاث مؤسسات: المؤسسة الافتراضية ومؤسستي العميلين](https://arabroot.io/content/images/2026/10/zitadel-05-console-orgs.webp)

الدخول إلى الـ Console بالحساب الافتراضي وكلمة المرور المعروفة نجح

🛑

إذا وصل أحد إلى صفحة الدخول قبل أن تغير كلمة المرور فهو مدير النسخة كلها، أي كل عملائك. لذلك ضع اسم المدير وكلمة مروره قبل أول تشغيل عبر متغيرات `ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME` و`ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD` كما يشرح الدليل، واجعل `PASSWORDCHANGEREQUIRED` قيمته `true`، ولا تنشر النسخة على الإنترنت قبل أن تدخل بنفسك وتسجل للمدير مفتاح مرور Passkey.

ولاحظ في الشكل أعلاه عمود نطاق المؤسسة Organization Domain: أنشأ ZITADEL لكل مؤسسة نطاقاً من اسمها، فصار `شركة-أكمي.localhost` لأننا سمينا المؤسسة بالعربية. وهذا النطاق يستخدم في اكتشاف المؤسسة من بريد المستخدم Domain Discovery وفي أسماء الدخول، لذلك إذا أردت هذه الخاصية فأضف لكل عميل نطاقه الحقيقي مثل `acme.example.com` وتحقق منه، واجعل الاسم العربي للعرض فقط.

## بناء العميلين بسكربت

تستطيع أن تنشئ كل شيء من الـ Console بالنقر، ولكن العميل الجديد في منتج SaaS يجهز من سكربت يعمل عند توقيع العقد، والسبب ما شرحناه في [تجهيز المستأجر Tenant Provisioning](https://arabroot.io/articles/%D8%A8%D9%86%D8%A7%D8%A1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-saas-%D9%85%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-%D8%B9%D9%85%D9%84%D9%8A%D8%A7/#provisioning): خطوة يمكن تكرارها لا يخطئ فيها الموظف. والسكربت التالي بـ Node.js يستخدم توكن `lab-admin` ويبني المشروع والتطبيق والعميلين:

```javascript
// ZITADEL lab: one SaaS project in the vendor org, two customer organisations with their own users and roles.
const Z = 'http://localhost:18820';
const PAT = process.env.PAT;
const call = async (method, path, body, org) => {
  const r = await fetch(Z + path, { method, headers: { Authorization: `Bearer ${PAT}`, 'Content-Type': 'application/json', ...(org ? { 'x-zitadel-orgid': org } : {}) }, body: body ? JSON.stringify(body) : undefined });
  const j = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(`${method} ${path} ${r.status} ${JSON.stringify(j)}`);
  return j;
};
const out = {};
const vendor = (await call('POST', '/admin/v1/orgs/_search', {})).result[0].id;
out.vendorOrg = vendor;
// project with roles, owned by the vendor org
const project = await call('POST', '/management/v1/projects', { name: 'saas-app', projectRoleAssertion: true, projectRoleCheck: true, hasProjectCheck: true }, vendor);
out.projectId = project.id;
for (const [roleKey, displayName] of [['admin', 'مدير الحساب'], ['member', 'عضو']]) await call('POST', `/management/v1/projects/${project.id}/roles`, { roleKey, displayName }, vendor);
const app = await call('POST', `/management/v1/projects/${project.id}/apps/oidc`, {
  name: 'saas-web', redirectUris: ['http://localhost:18821/callback'], postLogoutRedirectUris: ['http://localhost:18821/'],
  responseTypes: ['OIDC_RESPONSE_TYPE_CODE'], grantTypes: ['OIDC_GRANT_TYPE_AUTHORIZATION_CODE'], appType: 'OIDC_APP_TYPE_WEB',
  authMethodType: 'OIDC_AUTH_METHOD_TYPE_BASIC', devMode: true, accessTokenRoleAssertion: true, idTokenRoleAssertion: true, idTokenUserinfoAssertion: true,
}, vendor);
out.clientId = app.clientId; out.clientSecret = app.clientSecret;
// two customers
for (const [key, name, users] of [['acme', 'شركة أكمي', [['sara', 'admin'], ['omar', 'member']]], ['globex', 'مؤسسة جلوبكس', [['lina', 'admin']]]]) {
  const org = await call('POST', '/v2/organizations', { name });
  const orgId = org.organizationId;
  const grant = await call('POST', `/management/v1/projects/${project.id}/grants`, { grantedOrgId: orgId, roleKeys: ['admin', 'member'] }, vendor);
  out[key] = { orgId, grantId: grant.grantId, users: {} };
  for (const [u, role] of users) {
    const user = await call('POST', '/v2/users/human', { organization: { orgId }, username: `${u}@${key}.example.com`,
      profile: { givenName: u, familyName: key }, email: { email: `${u}@${key}.example.com`, isVerified: true },
      password: { password: process.env.UPASS, changeRequired: false } });
    await call('POST', `/management/v1/users/${user.userId}/grants`, { projectId: project.id, projectGrantId: grant.grantId, roleKeys: [role] }, orgId);
    out[key].users[u] = { id: user.userId, role };
  }
}
console.log(JSON.stringify({ ...out, clientSecret: out.clientSecret ? `(${out.clientSecret.length} chars)` : null }, null, 1));
if (process.env.SECRET_OUT) (await import('node:fs')).writeFileSync(process.env.SECRET_OUT, JSON.stringify(out));
```

```bash
PAT=$(docker compose exec -T zitadel-login cat /zitadel/bootstrap/admin.pat) UPASS='كلمة-مرور-قوية' SECRET_OUT=lab.json node setup.mjs
```

```json
{
 "vendorOrg": "396732857583759363",
 "projectId": "396733333200990211",
 "clientId": "396733334459281411",
 "clientSecret": "(64 chars)",
 "acme": {
  "orgId": "396733335113592835",
  "grantId": "396733335650463747",
  "users": {
   "sara": { "id": "396733336069894147", "role": "admin" },
   "omar": { "id": "396733340985618435", "role": "member" }
  }
 },
 "globex": {
  "orgId": "396733345129590787",
  "grantId": "396733345398026243",
  "users": {
   "lina": { "id": "396733345599352835", "role": "admin" }
  }
 }
}
```

في السكربت والمخرج أعلاه لاحظ التالي:

- الترويسة `x-zitadel-orgid` تحدد المؤسسة التي يعمل عليها الطلب في واجهات `/management/v1`، فالمشروع أنشئ في مؤسستك، ومنح المستخدم أنشئ داخل مؤسسة العميل، وإذا نسيت الترويسة نفذ الطلب على مؤسسة الحساب الذي يستدعيه، وهذا مصدر خطأ شائع في السكربتات.
- `projectRoleAssertion` يجعل الأدوار تصل في التوكن، و`projectRoleCheck` يرفض دخول مستخدم ليس له أي دور في المشروع، و`hasProjectCheck` يرفض دخول مستخدم من مؤسسة لم يمنح لها المشروع، وبدون هذين الخيارين يستطيع أي مستخدم في النسخة، ومنهم من سجل نفسه بنفسه، أن يدخل إلى تطبيقك.
- `devMode: true` يسمح بعنوان عودة على HTTP لأن المختبر على localhost، وفي الإنتاج اتركه `false` حتى يرفض ZITADEL أي عنوان عودة غير HTTPS.
- المعرفات أرقام طويلة مرتبة بحسب وقت الإنشاء، وليست أرقاماً متسلسلة، فلا تستخدمها سراً ولا تفترض أنها تبدأ من 1.
- سر التطبيق 64 حرفاً، وطبعنا طوله فقط وحفظناه في ملف لا يدخل Git، ولم نطبعه في السجل.

## موظفو عميلين في تطبيق واحد

نستخدم التطبيق التجريبي الذي كتبناه بلا مكتبات في [دليل Keycloak](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-keycloak-%D9%85%D8%B2%D9%88%D8%AF-%D8%A7%D9%84%D9%87%D9%88%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A4%D8%B3%D8%B3%D8%A7%D8%AA/#demo-app)، مع ثلاثة تعديلات: مسارات ZITADEL `/oauth/v2/authorize` و`/oauth/v2/token` و`/oidc/v1/end_session`، ونطاق Scope إضافي يطلب مؤسسة المستخدم، وعرض المؤسسة والأدوار بدل المجموعات. والسطر المهم في التعديل هو بناء النطاقات:

```javascript
      scope: ['openid profile email urn:zitadel:iam:user:resourceowner', url.searchParams.get('org') ? `urn:zitadel:iam:org:id:${url.searchParams.get('org')}` : ''].join(' ').trim(),
```

والنطاق `urn:zitadel:iam:user:resourceowner` من [النطاقات الخاصة بـ ZITADEL](https://zitadel.com/docs/apis/openidoauth/scopes?ref=arabroot.io)، ويطلب أن يحمل التوكن معرف مؤسسة المستخدم واسمها، أما الجزء الثاني فنشرحه في القسم التالي. وعند فتح التطبيق ظهرت صفحة الدخول الجديدة بالعربية:

![صفحة الدخول Login v2 في ZITADEL بالعربية تطلب اسم الدخول مع رابط تسجيل مستخدم جديد](https://arabroot.io/content/images/2026/10/zitadel-06-login-ar.webp)

واجهة الدخول الجديدة بالعربية، ولاحظ رابط «تسجيل مستخدم جديد»

والترجمة العربية كاملة في الشاشات التي مررنا بها، ولكن الاتجاه بقي من اليسار إلى اليمين LTR، فالحقل والأزرار في أماكنها الإنجليزية، وعلامة التعجب في «مرحباً بعودتك!» تظهر في بداية السطر. وهذا مقبول لأداة داخلية، أما إذا كانت صفحة الدخول جزءاً من منتجك أمام عملاء عرب فخطط لواجهة دخول خاصة فوق الـ API، وهذا من أسباب فصل Login v2 أصلاً. ثم دخلت sara من أكمي، وهذه الحقول التي وصلت إلى التطبيق:

![التطبيق التجريبي يعرض حقول التوكن للمستخدمة sara ومنها اسم المؤسسة ومعرفها والأدوار](https://arabroot.io/content/images/2026/10/zitadel-07-claims-sara.webp)

sara من شركة أكمي، ودورها admin داخل مؤسستها

```json
{
  "iss": "http://localhost:18820",
  "sub": "396733336069894147",
  "preferred_username": "sara@acme.example.com",
  "email": "sara@acme.example.com",
  "org": "شركة أكمي",
  "org_id": "396733335113592835",
  "roles": {
    "admin": {
      "396733335113592835": "شركة-أكمي.localhost"
    }
  },
  "amr": [
    "pwd"
  ]
}
```

والحقول `org` و`org_id` و`roles` أسماء اختصرناها في التطبيق، أما أسماؤها في التوكن فهي كما في الشكل التالي، وفي المخرج أعلاه لاحظ التالي:

![التوكن مع حقل معرف المؤسسة وحقل الأدوار، وبجانبهما شرح لما يقرؤه التطبيق منهما وتحذير من أخذ رقم العميل من المتصفح](https://arabroot.io/content/images/2026/10/zitadel-04-token.webp)

رقم العميل يأتي من التوكن الموقع، وليس من الرابط

- `urn:zitadel:iam:user:resourceowner:id` هو معرف مؤسسة المستخدم، وهو الرقم الذي تضعه في `tenant_id` في قاعدة بيانات تطبيقك، ومنه تبدأ سياسات الصفوف التي بنيناها في [مقال Row-Level Security](https://arabroot.io/articles/%D8%A8%D9%86%D8%A7%D8%A1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-saas-%D9%85%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-%D8%B9%D9%85%D9%84%D9%8A%D8%A7/#rls)، وبهذا يغلق خطأ `company_id` الذي بدأت به القصة، لأن الرقم لم يعد يأتي من الرابط.
- `urn:zitadel:iam:org:project:roles` قاموس، فيه لكل دور قائمة بالمؤسسات التي منحته مع نطاقها، فدور `admin` عند sara صالح داخل أكمي فقط، وإذا كتب التطبيق شرطاً مثل «إذا كان لديه admin فهو مدير» دون أن يقارن المؤسسة، فقد يعطي مدير عميل صلاحيات على كل التطبيق.
- `amr` قيمته `pwd`، أي أن الدخول كان بكلمة مرور فقط، ونستطيع أن نفرض المصادقة الثنائية لكل مؤسسة في سياسة الدخول، أو لكل النسخة.

ودخلت lina من جلوبكس فوصل إلى التطبيق نفس الشكل بمعرف مؤسستها `396733345129590787` واسمها «مؤسسة جلوبكس» ودور `admin` داخلها فقط، أي أن تطبيقاً واحداً مسجلاً مرة واحدة يعرف من التوكن وحده لأي عميل ينتمي كل مستخدم.

## صفحة دخول خاصة بكل عميل

بعد أسابيع طلبت جلوبكس أن يكون لها رابط دخول خاص بها، يحمل شعارها ولا يقبل إلا موظفيها. وهذا ما يفعله النطاق `urn:zitadel:iam:org:id:{id}`، حيث يقيد طلب الدخول بمؤسسة واحدة فتطبق سياساتها وهويتها البصرية Branding ومزودو هويتها، ولذلك أضفنا إلى التطبيق المعامل `?org=`. والتجربة البديهية هنا: ماذا يحدث إذا حاولت sara، وهي من أكمي، أن تدخل من رابط جلوبكس؟

### ماذا حدث فعلاً؟ عرض عليها التسجيل

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

![صفحة التسجيل في ZITADEL بعد إدخال اسم مستخدم غير موجود في المؤسسة، والبريد معبأ مسبقاً](https://arabroot.io/content/images/2026/10/zitadel-08-register-offered.webp)

مستخدم غير معروف في المؤسسة يحول إلى التسجيل الذاتي بدل الرفض

وقرأنا سياسة الدخول الافتراضية للنسخة فوجدنا السبب:

```bash
curl -s -H "Authorization: Bearer $PAT" http://localhost:18820/admin/v1/policies/login
```

```text
{"allowUsernamePassword":true,"allowRegister":true,"allowExternalIdp":true,"passwordlessType":"PASSWORDLESS_TYPE_ALLOWED","allowDomainDiscovery":true}
```

والقيمتان المهمتان، بحسب [صفحة الإعدادات الافتراضية](https://zitadel.com/docs/guides/manage/console/default-settings?ref=arabroot.io)، هما `allowRegister` بقيمة `true`، أي أن أي شخص يستطيع أن ينشئ لنفسه حساباً في أي مؤسسة يصل إلى صفحة دخولها، و`ignoreUnknownUsernames` غائبة أي `false`، أي أن الصفحة تخبر المهاجم إن كان اسم المستخدم موجوداً أم لا، وهذا ما يسمى كشف المستخدمين User Enumeration. وفي منتج B2B يعني الأول أن موظفاً سابقاً أو منافساً يستطيع أن ينشئ حساباً داخل مؤسسة عميلك، ولولا `projectRoleCheck` و`hasProjectCheck` اللذان وضعناهما في المشروع لدخل إلى تطبيقك أيضاً.

### الإصلاح، وسبب تأخره

عدلنا سياسة النسخة فأغلقنا التسجيل الذاتي وأخفينا وجود الأسماء، بطلب `PUT /admin/v1/policies/login` يحمل السياسة كاملة مع القيمتين الجديدتين، فكان الرد:

```text
200 {"details":{"sequence":"109", "changeDate":"2026-10-25T22:45:30.172301Z", "resourceOwner":"396732857583693827"}}
allowRegister undefined ignoreUnknownUsernames true
```

وقيمة `undefined` هنا تعني `false`، لأن الواجهة لا ترسل الحقول المنطقية الخاطئة. ثم كررنا التجربة فظهرت صفحة التسجيل نفسها مرة أخرى! وقرأنا الإعدادات عبر الواجهة البرمجية v2 فوجدناها صحيحة، فالمشكلة إذن في واجهة الدخول، حيث تحتفظ خدمة Next.js بنسخة من الإعدادات، وبعد إعادة تشغيل Container واجهة الدخول تغير السلوك فوراً: ظهرت صفحة كلمة المرور لـ sara كأنها موجودة، ثم بعد إدخال كلمة مرورها الصحيحة:

![صفحة كلمة المرور في ZITADEL مع رسالة فشلت المصادقة بعد إغلاق التسجيل الذاتي](https://arabroot.io/content/images/2026/10/zitadel-09-after-fix.webp)

بعد الإصلاح: لا تسجيل، ولا كشف للأسماء، ورسالة «فشلت المصادقة.»

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

## مدير العميل يدير موظفيه، ولا يرى غيرهم

الطلب الأول لجلوبكس كان أن يضيف مدير تقنية المعلومات موظفيه بنفسه، وأكمي طلبت شيئاً أكبر: نظام الموارد البشرية عندها يضيف الموظف الجديد ويعطل المغادر تلقائياً. وفي ZITADEL يعطى مدير العميل دوراً إدارياً على مؤسسته فقط، مثل `ORG_OWNER` للمدير الكامل، أو `ORG_USER_MANAGER` لمن يدير المستخدمين فقط. وجربنا الحالة الأصعب: حساب آلي لأكمي بدور `ORG_USER_MANAGER`، يحاول أن يصل إلى جلوبكس:

```javascript
// Delegated administration: a service account of the customer Acme with ORG_USER_MANAGER tries to reach Globex.
import { readFileSync } from 'node:fs';
const Z = 'http://localhost:18820';
const lab = JSON.parse(readFileSync(process.env.LAB, 'utf8'));
const req = async (token, method, path, body, org) => {
  const r = await fetch(Z + path, { method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...(org ? { 'x-zitadel-orgid': org } : {}) }, body: body ? JSON.stringify(body) : undefined });
  return { status: r.status, body: await r.json().catch(() => ({})) };
};
const ADMIN = process.env.PAT;
const acme = lab.acme.orgId, globex = lab.globex.orgId;
const m = await req(ADMIN, 'POST', '/management/v1/users/machine', { userName: 'acme-hr-sync', name: 'Acme HR sync', accessTokenType: 'ACCESS_TOKEN_TYPE_BEARER' }, acme);
const pat = await req(ADMIN, 'POST', `/management/v1/users/${m.body.userId}/pats`, {}, acme);
const mem = await req(ADMIN, 'POST', '/management/v1/orgs/me/members', { userId: m.body.userId, roles: ['ORG_USER_MANAGER'] }, acme);
console.log('machine user', m.status, 'pat', pat.status, 'member ORG_USER_MANAGER in acme', mem.status);
const T = pat.body.token;
const list = async (org) => req(T, 'POST', '/management/v1/users/_search', { query: { limit: 20 } }, org);
const a = await list(acme);
console.log('list acme  ->', a.status, (a.body.result || []).map(u => u.userName).join(', '));
const g = await list(globex);
console.log('list globex ->', g.status, g.body.message || (g.body.result || []).map(u => u.userName).join(', '));
const v2 = await req(T, 'POST', '/v2/users', {});
console.log('v2 list all ->', v2.status, (v2.body.result || []).map(u => u.username).join(', ') || v2.body.message);
const add = await req(T, 'POST', '/v2/users/human', { organization: { orgId: globex }, username: 'intruder@globex.example.com', profile: { givenName: 'x', familyName: 'y' }, email: { email: 'intruder@globex.example.com', isVerified: true } });
console.log('create user in globex ->', add.status, add.body.message);
const addOwn = await req(T, 'POST', '/v2/users/human', { organization: { orgId: acme }, username: 'nora@acme.example.com', profile: { givenName: 'nora', familyName: 'acme' }, email: { email: 'nora@acme.example.com', isVerified: true } });
console.log('create user in acme   ->', addOwn.status, addOwn.body.userId ? 'userId ' + addOwn.body.userId : addOwn.body.message);
```

```text
machine user 200 pat 200 member ORG_USER_MANAGER in acme 200
list acme  -> 200 acme-hr-sync, omar@acme.example.com, sara@acme.example.com
list globex -> 404 membership not found (AUTHZ-cdgFk)
v2 list all -> 200 acme-hr-sync, omar@acme.example.com, sara@acme.example.com
create user in globex -> 404 membership not found (AUTHZ-cdgFk)
create user in acme   -> 200 userId 396735526033453059
```

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

- عندما طلب الحساب قائمة مستخدمي جلوبكس صراحة بالترويسة لم يحصل على قائمة فارغة، وإنما على رفض `membership not found`، أي أن ZITADEL يتحقق من عضويته في المؤسسة المطلوبة قبل أي استعلام.
- الواجهة البرمجية v2 التي تبحث في كل النسخة دون ترويسة أعادت مستخدمي أكمي فقط، أي أن نتيجة البحث نفسها مقيدة بصلاحيات من يسأل، وليست قائمة النسخة كلها.
- إنشاء مستخدم داخل جلوبكس رفض، وداخل أكمي نجح، وهذا بالضبط ما يحتاجه تكامل نظام الموارد البشرية عند العميل، وقد تربطه بعد ذلك عبر [SCIM](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-scim-%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%AD%D8%B3%D8%A7%D8%A8%D8%A7%D8%AA-%D8%AA%D9%84%D9%82%D8%A7%D8%A6%D9%8A%D8%A7/) بدل سكربت خاص.

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

## العميل الذي يريد الدخول بحسابات Entra ID

الطلب الثاني لجلوبكس كان أن يدخل موظفوها بحساباتهم في Microsoft Entra ID، وفي ZITADEL يضاف مزود الهوية الخارجي Identity Provider على مستوى المؤسسة، فيظهر زر الدخول عبر Microsoft في صفحة دخول جلوبكس فقط، ولا يراه موظفو أكمي، ويستطيع مدير جلوبكس أن يديره بنفسه إذا أعطيته الصلاحية. ولم نجرب هذا الجزء في المختبر، لأنه يحتاج إلى مستأجر Entra ID حقيقي وتسجيل تطبيق فيه، لذلك نكتفي هنا بالمبدأ، وخطواته في توثيق ZITADEL لمزودي الهوية. وانتبه إلى نقطتين عند تنفيذه: اجعل المستخدم الذي يأتي من Entra ID ينشأ داخل مؤسسة جلوبكس نفسها لا في مؤسستك، وحدد هل يربط بحساب موجود بنفس البريد أم لا، والسبب أن الربط التلقائي بالبريد بين مزودين مختلفين كان مصدراً لثغرات الاستيلاء على الحساب في أكثر من منتج، ومنها أحد تنبيهات ZITADEL نفسه في سبتمبر 2026 عن خلط مزودي SAML.

## العميل الذي يغادر

في نهاية السنة لم تجدد جلوبكس العقد، وهنا تظهر فائدة المؤسسة مرة أخرى: بدل أن تبحث عن كل موظفيها وتعطلهم واحداً واحداً، تعطل المؤسسة نفسها بأمر واحد:

```bash
curl -s -X POST -H "Authorization: Bearer $PAT" -H "x-zitadel-orgid: $GLOBEX" -H 'Content-Type: application/json' \
  http://localhost:18820/management/v1/orgs/me/_deactivate -d '{}'
```

```text
{"details":{"sequence":"5", "changeDate":"2026-10-25T23:23:15.263189Z", "resourceOwner":"396733345129590787"}}
```

ثم حاولت lina أن تدخل بكلمة مرورها الصحيحة، فكانت النتيجة «فشلت المصادقة.»، وعندما أعدنا تفعيل المؤسسة بالأمر `_reactivate` عاد كل شيء كما كان، وهذا مناسب للعميل الذي تأخر في الدفع أسبوعاً. ولاحظ أن تعطيل المؤسسة يمنع الدخول الجديد، أما الجلسات المفتوحة في تطبيقك فتبقى حتى تنتهي صلاحية توكناتها أو يتحقق التطبيق من المستخدم عبر Introspection، لذلك اجعل مدة صلاحية الـ Access Token قصيرة، وأضف في تطبيقك فحصاً لحالة العميل عند الأعمال الحساسة. أما حذف بيانات العميل نهائياً بعد انتهاء مدة الاحتفاظ في العقد فهو خطوة مختلفة تشمل قاعدة بيانات تطبيقك أيضاً، وقد شرحناها في [عمليات المستأجر الواحد](https://arabroot.io/articles/%D8%A8%D9%86%D8%A7%D8%A1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-saas-%D9%85%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-%D8%B9%D9%85%D9%84%D9%8A%D8%A7/#tenant-ops).

## سجل التدقيق: من فعل ماذا، ومتى؟

سؤال المراجع الداخلي لجلوبكس له إجابة جاهزة، لأن الأحداث هي البيانات نفسها، فنقرأ أحداث مستخدم واحد بالواجهة البرمجية:

```bash
curl -s -H "Authorization: Bearer $PAT" -H 'Content-Type: application/json' -X POST \
  http://localhost:18820/admin/v1/events/_search -d '{"limit":30,"asc":true,"aggregateId":"396733336069894147"}'
```

```text
22:31:38 user.human.added | Lab automation | seq 1
22:31:38 user.human.email.verified | Lab automation | seq 2
22:37:35 user.human.password.check.succeeded | Automatically Initialized IAM_LOGIN_CLIENT | seq 3
```

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

```bash
docker compose exec postgres psql -U postgres -d zitadel -tc "select aggregate_type, count(*) from eventstore.events2 group by 1 order by 2 desc"
```

```text
 permission     |   509
 system         |   341
 instance       |   109
 auth_request   |    39
 session        |    32
 user           |    21
 project        |    18
 org            |    15
 oidc_session   |    12
 milestone      |     5
 web_key        |     3
 usergrant      |     3
 feature        |     3
```

ولاحظ أن أغلب الأحداث ليست من عملنا، وإنما من تجهيز النسخة نفسها: 509 حدثاً للصلاحيات الافتراضية و341 للنظام، ثم يأتي كل طلب دخول Auth Request وكل جلسة بأحداثه. وهذا يعني أن الأحداث تزيد مع عدد عمليات الدخول وليس مع عدد المستخدمين فقط، فراقب حجم قاعدة البيانات شهرياً. وإذا كنت تبني سجل تدقيق للتطبيق نفسه بجانب هذا السجل فقد شرحنا ما يجب أن يحفظ فيه في [مقال سجل التدقيق](https://arabroot.io/articles/%D8%B3%D8%AC%D9%84-%D8%A7%D9%84%D8%AA%D8%AF%D9%82%D9%8A%D9%82-audit-trail-%D9%85%D9%86-%D9%81%D8%B9%D9%84-%D9%85%D8%A7%D8%B0%D8%A7-%D9%88%D9%85%D8%AA%D9%89/).

## الموارد: الذاكرة والقرص بالأرقام

| الخدمة        | بعد التشغيل | بعد التجارب | ملاحظة                                     |
| ------------- | ----------- | ----------- | ------------------------------------------ |
| zitadel-api   | 82 MiB      | 151 MiB     | Image بحجم 237 MB                          |
| zitadel-login | 79 MiB      | 116 MiB     | Image بحجم 332 MB                          |
| PostgreSQL    | 79 MiB      | 109 MiB     | قاعدة البيانات 16 MB والـ Volume كله 73 MB |
| Caddy         | 12 MiB      | 20 MiB      |                                            |

وهذه أرقام صغيرة مقارنة بـ Keycloak الذي احتاج في دليله إلى أكثر من 500 MiB وحده، والسبب أن ZITADEL برنامج Go مترجم لا يحتاج إلى آلة Java الافتراضية. وإعادة تشغيل الـ API حتى صار `healthy` استغرقت 66 ثانية، ومعظمها انتظار فترة فحص الصحة كل عشر ثوان وخطوة Setup التي تعمل مع `start-from-init` في كل تشغيل، لذلك إذا أردت تحديثاً بلا توقف فشغل نسختين من الـ API خلف الـ Proxy وافصل خطوة الترحيل كما في دليل الإنتاج.

## النسخ الاحتياطي والمفتاح الرئيسي Masterkey

النسخة الاحتياطية هي قاعدة البيانات مع المفتاح الرئيسي، ولا قيمة لأحدهما دون الآخر:

```bash
docker compose exec -T postgres pg_dump -U postgres -Fc zitadel > zitadel-$(date +%F).dump
```

وكان حجم الملف 386 KB، واستعدناه في قاعدة بيانات جديدة فوجدنا فيها 1,124 حدثاً و8 مستخدمين بين بشري وآلي، وهي نفس الأرقام في الأصل. ثم جربنا السؤال الأهم: ماذا يحدث إذا استعدت قاعدة البيانات ولكنك فقدت المفتاح؟ شغلنا نسخة ثانية من الـ API على نفس قاعدة البيانات بمفتاح عشوائي جديد، فبدأت دون أي خطأ وكتبت `server is listening`، وعرضت المفاتيح العامة في `/oauth/v2/keys` بشكل طبيعي، أي أن كل فحوص الصحة كانت ستقول إنها سليمة. ولكن عندما استدعيناها بتوكن الحساب الآلي نفسه كان الرد:

```text
/oauth/v2/keys 200 {"keys":[{"use":"sig","kty":"RSA","kid":"396732861090131971","alg":"RS256","n":"6nRe8k6MmfnnoOHmO8cbF8ICVwXfRPIwBG69ad7w6ZK0O2Jy8Fg5Y8lCqU2U0EgiSxb_hBZYGObYXLnf
/auth/v1/users/me 401 {"code":16, "message":"Errors.Token.Invalid (AUTH-7fs1e)"
level=warning msg="token verifier repo: decrypt access token" error="ID=CRYPT-ha6Oh Message=malformed encrypted value
```

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

## التحديث Upgrade إلى إصدار أحدث

1. تابع [التنبيهات الأمنية](https://github.com/zitadel/zitadel/security/advisories?ref=arabroot.io) والإصدارات، فقد رأينا أن 2026 حمل 42 تنبيهاً، وخطط لتطبيق الإصدارات التصحيحية خلال أيام.
2. اقرأ ملاحظات الإصدار و[دليل التحديث](https://zitadel.com/docs/self-hosting/manage/updating%5Fscaling?ref=arabroot.io)، وخاصة عند الانتقال بين إصدارات رئيسية، فقد غيرت 3.0 الرخصة وأزالت CockroachDB، وغيرت 4.0 واجهة الدخول.
3. خذ نسخة احتياطية بالأمر السابق، وتأكد أن المفتاح الرئيسي معك.
4. غير الوسم في الخدمتين `zitadel-api` و`zitadel-login` معاً، لأن واجهة الدخول تستدعي واجهات برمجية قد تتغير بين الإصدارات، ثم `docker compose pull && docker compose up -d --wait`.
5. تابع في السجل خطوات `verify migration` و`setup completed`، ثم شغل سكربت الإدارة المفوضة من هذا المقال واختبار دخول من كل نوع، فهما اختبار العزل بعد كل تحديث.

## أين تحفظ بيانات الهوية؟

بيانات الهوية بيانات شخصية: أسماء الموظفين وبريدهم وأوقات دخولهم وعناوين IP، وإذا كان عملاؤك جهات في المملكة فسوف يسألونك أين تحفظ، لأن [لائحة نقل البيانات الشخصية خارج المملكة](https://dgp.sdaia.gov.sa/wps/portal/pdp/knowledgecenter/details/RegulationonPersonalDataTransferOutsidetheKingdom?ref=arabroot.io) تضع شروطاً على نقلها، وقد شرحنا ما يعنيه ذلك للخدمات المستضافة في [مقال PDPL للخدمات المستضافة](https://arabroot.io/articles/%D8%AD%D9%85%D8%A7%D9%8A%D8%A9-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A7%D9%84%D8%B4%D8%AE%D8%B5%D9%8A%D8%A9-pdpl-%D9%84%D9%84%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%B6%D8%A7%D9%81%D8%A9/). وهنا يظهر سبب من أسباب الاستضافة الذاتية: عندما تشغل ZITADEL على سيرفر داخل المملكة تبقى قاعدة البيانات وسجل الأحداث عندك، أما الخدمة السحابية ZITADEL Cloud فتعرض في [صفحة الأسعار](https://zitadel.com/pricing?ref=arabroot.io) مناطق الاتحاد الأوروبي والولايات المتحدة وسويسرا وأستراليا لمكان البيانات Data Residency. وهذا لا يعني أن الخدمة السحابية ممنوعة، وإنما أنه قرار يجب أن تقرأ فيه اللائحة وعقود عملائك قبل أن تختار.

## كم تدفع لو اخترت الخدمة السحابية؟

بحسب صفحة الأسعار بتاريخ 25 أكتوبر 2026 فإن ZITADEL Cloud له ثلاث خطط: Free بـ 0 دولار ومعها 100 مستخدم نشط يومياً Daily Active Users ومؤسسات بلا حد، وPro تبدأ من 100 دولار شهرياً ومعها 25,000 مستخدم نشط يومياً ونطاق خاص وضمان توفر 99.5٪، وEnterprise بسعر يتفق عليه، ومنها الرخصة التجارية وضمان 99.99٪. أما الاستضافة الذاتية فلا تدفع فيها إلا السيرفر، وقد رأينا أن ZITADEL كله يعمل في أقل من نصف جيجابايت من الذاكرة، ولكن تكلفتها الحقيقية هي الشخص الذي يتابع التنبيهات الأمنية ويحدث ويراقب ويختبر الاستعادة، وفي منتج يعتمد عليه عملاؤك للدخول يومياً فهذه ليست مهمة جانبية.

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

- **ترك الحساب الإداري الافتراضي:** `zitadel-admin@zitadel.localhost` بكلمة المرور `Password1!` يعمل حتى تغيره، وقد جربناه.
- **ترك التسجيل الذاتي مفتوحاً:** الافتراضي يسمح لأي شخص بإنشاء حساب في أي مؤسسة يصل إلى صفحة دخولها، ويكشف وجود أسماء المستخدمين.
- **مشروع بلا فحص للأدوار:** دون `projectRoleCheck` و`hasProjectCheck` يدخل إلى تطبيقك كل مستخدم في النسخة.
- **قراءة الدور دون المؤسسة:** دور `admin` في التوكن مربوط بمؤسسة، والتطبيق الذي يتجاهل ذلك يعطي مدير عميل صلاحيات على الجميع.
- **أخذ رقم العميل من الرابط أو الطلب:** المصدر الوحيد هو حقل المؤسسة في التوكن الموقع.
- **عنوان خارجي لا يطابق الحقيقي:** أي اختلاف في النطاق أو المنفذ أو HTTPS يعطي `Instance not found`، ومنه ترويسة `X-Forwarded-Host` من Proxy داخلي.
- **فقدان المفتاح الرئيسي:** النسخة تبدأ وتبدو سليمة، ولكن لا يعمل فيها أي توكن.
- **التأخر في التحديث:** تنبيهات الاستيلاء على الحساب تصدر كل بضعة أسابيع، والإصلاح في إصدار تصحيحي.

## قائمة تحقق قبل أن تربط أول عميل

| البند                                                | كيف تتحقق منه                                                |
| ---------------------------------------------------- | ------------------------------------------------------------ |
| المدير الافتراضي باسم وكلمة مرور خاصين، ومعه Passkey | محاولة الدخول بـ Password1! تفشل                             |
| التسجيل الذاتي مغلق، وأسماء المستخدمين لا تكشف       | اسم غير موجود في رابط مؤسسة ينتهي بـ «فشلت المصادقة.»        |
| المشروع يفحص الأدوار والمؤسسات الممنوحة              | مستخدم بلا دور لا يصل إلى التطبيق                            |
| التطبيق يأخذ المؤسسة من التوكن ويقارنها مع الدور     | اختبار آلي بمستخدم من عميلين                                 |
| الإدارة المفوضة معزولة                               | سكربت الإدارة المفوضة يعطي membership not found للعميل الآخر |
| عنوان عودة HTTPS فقط وdevMode معطل                   | إعداد التطبيق في المشروع                                     |
| المفتاح الرئيسي في خزنة منفصلة                       | استعادة تجريبية مع دخول حقيقي بعدها                          |
| من يتابع التنبيهات الأمنية ويحدث                     | اسم شخص وموعد مراجعة أسبوعي                                  |
| مكان حفظ بيانات الهوية موثق للعملاء                  | فقرة في استبيان الأمن ومرجعها في اللائحة                     |

## الخلاصة

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

- جدول المستخدمين في التطبيق يفشل مع العميل الثاني، لأن كل عميل يريد إدارة مفوضة ودخولاً عبر مزود هويته وسجل تدقيق، وكل خطأ في هذه الطبقة تسريب بين العملاء.
- ZITADEL مبني على المؤسسات: مؤسسة لك فيها المشروع والأدوار، ومؤسسة لكل عميل، ومنح المشروع يربط بينهما، والتطبيق مسجل مرة واحدة.
- التوكن يحمل مؤسسة المستخدم وأدواره مربوطة بالمؤسسة، ومنه وحده يحدد تطبيقك العميل.
- الإعدادات الافتراضية لا تناسب منتج B2B: مدير بكلمة مرور معروفة، وتسجيل ذاتي مفتوح، وكشف للأسماء، فغيرها قبل أول عميل واختبر من متصفح جديد.
- الإدارة المفوضة عزلت العميلين في تجربتنا، وتعطيل المؤسسة يوقف دخول عميل كامل بأمر واحد.
- النسخة الاحتياطية بلا المفتاح الرئيسي لا تعمل، والإصدارات الأمنية كثيرة، فخطط للتحديث من اليوم الأول.

وإذا كان منتجك داخلياً لفريق واحد فقد يناسبك أكثر [authentik](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-authentik-%D9%84%D9%84%D8%AF%D8%AE%D9%88%D9%84-%D8%A7%D9%84%D9%85%D9%88%D8%AD%D8%AF/) أو [Keycloak](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-keycloak-%D9%85%D8%B2%D9%88%D8%AF-%D8%A7%D9%84%D9%87%D9%88%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A4%D8%B3%D8%B3%D8%A7%D8%AA/)، وفي مقال قادم بإذن الله سوف نقارن الأدوات الأربع ونشرح متى تختار كل واحدة منها.

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

- أكتوبر 2026: كتابة الدليل واختباره على ZITADEL v4.20.0 مع Login v2 وPostgreSQL 17.6.