الأمن السيبراني الأدلة التقنية

ZITADEL: هوية متعددة المستأجرين Multi-tenant لتطبيقك

نبني لمنصة SaaS صغيرة طبقة هوية بـ ZITADEL، يدير فيها كل عميل مستخدميه، ولا يرى أحد منهم عميلاً آخر.

ZITADEL: هوية متعددة المستأجرين Multi-tenant لتطبيقك

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

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

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

  • لماذا يفشل جدول المستخدمين داخل التطبيق مع العميل الثاني، وما الخيارات الثلاثة لبناء الهوية في منتج متعدد المستأجرين.
  • ما هو ZITADEL، وتاريخ رخصته، وسرعة إصداراته الأمنية، ونموذجه: النسخة Instance والمؤسسة Organization والمشروع Project ومنح المشروع Project Grant.
  • كيف يعمل من الداخل: خدمة API بلغة Go، وواجهة دخول منفصلة، وسجل أحداث Event Store بدل الجداول العادية.
  • التثبيت مع PostgreSQL وCaddy، والمشكلتان اللتان واجهتانا أثناءه، والحساب الإداري الذي يأتي بكلمة مرور معروفة.
  • بناء العميلين بسكربت، والدخول إلى تطبيق تجريبي، وقراءة المؤسسة والأدوار من التوكن سطراً سطراً.
  • الدخول المقيد بمؤسسة، والتسجيل الذاتي المفتوح افتراضياً، والإدارة المفوضة لمدير العميل، وتعطيل عميل يغادر.
  • سجل التدقيق، والموارد المقيسة، والنسخ الاحتياطي ومفتاح التشفير الرئيسي Masterkey، والتحديث، والتكلفة، وقائمة تحقق قبل الإطلاق.
دليلتعدد المستأجرين Multi-Tenancy في SaaS للمبتدئينكيف يخدم تطبيق SaaS واحد مئات العملاء دون أن يرى أحدهم بيانات غيره، وأي نموذج عزل يناسب تطبيقك.دليلتطبيق SaaS متعدد المستأجرين عملياً مع Row-Level Securityنبني على PostgreSQL تطبيق SaaS يعزل كل مستأجر Tenant عن غيره بسياسات Row-Level Security، ونرى أين تتسرب البيانات رغم ذلك.دليلشرح OAuth وOpenID Connect للمبتدئينلماذا لا يجب أن يعرف أي تطبيق كلمة مرورك، وكيف يحل OAuth ذلك بتوكنات محدودة، وكيف يضيف OpenID Connect الدخول الموحد.دليلتثبيت Keycloak مزود الهوية IdP الأشهر في المؤسساتنثبت Keycloak مع PostgreSQL، ونربط به تطبيقاً عبر OIDC، ونفرض المصادقة الثنائية على الجميع، ونقيس ما يحتاجه من ذاكرة.

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

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

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

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

📌
في أغسطس 2021 نشر باحثو Wiz تفاصيل ثغرة ChaosDB في خدمة Azure Cosmos DB، حيث استطاعوا عبر خاصية Jupyter Notebook المدمجة أن يحصلوا على مفاتيح الوصول الأساسية لقواعد بيانات آلاف من عملاء Azure الآخرين، أي أن العزل بين المستأجرين في خدمة سحابية كبيرة فشل في مكان واحد. وقد عطلت Microsoft الخاصية خلال 48 ساعة من البلاغ، وطلبت من أكثر من 30٪ من عملاء Cosmos DB تغيير مفاتيحهم يدوياً. والدرس لمنتجك الصغير أن العزل بين العملاء يجب أن يكون في تصميم الطبقة نفسها، لا في شرط تتذكره في كل استعلام.

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

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

الطريقةكيف تعملنقاط القوةنقاط الضعف
مزود هوية بنطاق واحد وعمود الشركة في التطبيقكل المستخدمين في مكان واحد، والتطبيق يقرر الشركة من خاصية Attribute في التوكنبسيط، وأي مزود هوية يكفيالإدارة المفوضة والدخول عبر مزود العميل يبقيان عليك، والعزل في كود التطبيق
نطاق Realm لكل عميلمثل Keycloak: كل عميل نطاق مستقل بمستخدميه وتطبيقاتهعزل قوي، وكل عميل يضبط سياساتهالتطبيق يسجل في كل نطاق من جديد، ومع مئات العملاء تصبح الإعدادات والترقيات ثقيلة
مؤسسات Organizations داخل نسخة واحدةمثل ZITADEL: تطبيق واحد، وكل عميل مؤسسة لها مستخدموها ومديروها ومزودو هويتهاصمم لـ B2B: إدارة مفوضة، ومزود هوية لكل مؤسسة، ومنح المشروع للعملاءمفاهيم جديدة يجب أن يتعلمها الفريق، ونسخة واحدة يعني أن خطأ الإعداد العام يمس الجميع

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

ما هو ZITADEL؟

ZITADEL مزود هوية مفتوح المصدر يدعم OpenID Connect وOAuth 2.0 وSAML، ومعه واجهة إدارة Console وواجهات برمجية API كاملة، وما يميزه أن المؤسسات Organizations فيه مفهوم أساسي منذ البداية، وليست إضافة. وقد غير المشروع رخصته في الإصدار 3.0.0 في 2 مايو 2025 من Apache 2.0 إلى AGPL-3.0، وأزال في نفس الإصدار دعم CockroachDB فصار PostgreSQL هو قاعدة البيانات الوحيدة. ثم جاء الإصدار 4 في يوليو 2025 بواجهة الدخول الجديدة Login v2 في خدمة مستقلة مبنية بـ Next.js، وهي التي سوف نستخدمها.

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

والمشروع نشط جداً، فآخر إصدار عند كتابة هذا الدليل هو v4.20.0 في 22 أكتوبر 2026، وصدر في 2026 حتى الآن 45 إصداراً. ولكن الوجه الآخر لذلك أن صفحة التنبيهات الأمنية فيها 42 تنبيهاً في 2026 وحدها، منها 5 بدرجة Critical و23 بدرجة High، وفي 15 أكتوبر 2026 فقط نشر المشروع ثلاثة تنبيهات بعنوان الاستيلاء على الحساب Account Takeover أصلحها الإصدار 4.19.5، منها واحد في Login v2 نفسها عبر إثبات للتحقق من البريد يمكن تزويره. لذلك فمن يشغل ZITADEL لمنتج فيه عملاء يجب أن يكون لديه من يتابع هذه الصفحة ويحدث خلال أيام، وسوف نعود إلى ذلك في قسم التحديث.

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

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

نسخة ZITADEL واحدة فيها مؤسسة صاحب التطبيق مع المشروع والأدوار والتطبيق، ومؤسستان للعميلين، والمشروع ممنوح لكل منهما
مؤسسة لك فيها المشروع، ومؤسسة لكل عميل، ومنح المشروع يربط بينهما
  • النسخة Instance: تثبيت ZITADEL كاملاً على عنوان واحد مثل auth.example.com، وفيه الإعدادات الافتراضية لكل المؤسسات، مثل سياسة الدخول وسياسة كلمات المرور. ويستطيع تثبيت واحد أن يحمل عدة نسخ، ولكن نسخة واحدة تكفي منتجاً واحداً.
  • المؤسسة Organization: حاوية للمستخدمين ومديريهم وسياساتهم ومزودي هويتهم، والمستخدم ينتمي إلى مؤسسة واحدة فقط. وفي قصتنا مؤسسة لك أنت صاحب التطبيق، ومؤسسة لكل عميل.
  • المشروع Project: يجمع تطبيقات منتجك والأدوار Roles التي يفهمها، مثل admin وmember، ويملكه صاحب التطبيق في مؤسسته.
  • منح المشروع Project Grant: طريقتك لتقول إن مؤسسة العميل تستطيع استخدام مشروعك، وبأي أدوار، فتمنح أكمي الدورين، وقد تمنح عميلاً على خطة أرخص دور member فقط.
  • منح المستخدم Authorization: داخل مؤسسة العميل يعطي مديرها دوراً من الأدوار الممنوحة لكل موظف، وهذا ما يصل إلى تطبيقك في التوكن.

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

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

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

Caddy يوزع الطلبات بين خدمة zitadel-login لواجهة الدخول وخدمة zitadel-api لبقية المسارات، والـ API يكتب في PostgreSQL، وصندوق للمفتاح الرئيسي والنسخة الاحتياطية
واجهة الدخول وخدمة الـ 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، أي أن كل تغيير يضاف حدثاً جديداً إلى جدول لا يعدل ولا يحذف منه، مثل «أضيف المستخدم» و«تحقق بريده» و«نجح تحقق كلمة مروره»، ثم تقرأ عمليات تسمى الإسقاطات Projections هذه الأحداث بالترتيب وتبني منها جداول جاهزة للقراءة، مثل جدول المستخدمين الذي تستعلم منه الـ Console. والشكل التالي يبين ذلك بالأحداث الحقيقية لمستخدمة من المختبر:

ثلاثة أحداث حقيقية للمستخدمة sara في سجل الأحداث، والإسقاطات تبني منها جداول القراءة، والنتيجة سجل تدقيق
كل تغيير حدث جديد، والجداول التي تقرؤها الواجهة مبنية من الأحداث

ولهذا التصميم ثلاث نتائج عملية في قصتنا: الأولى أن سجل التدقيق الذي طلبه مراجع جلوبكس موجود من اليوم الأول لأنه هو البيانات نفسها، والثانية أن قاعدة البيانات تكبر مع كل دخول وكل تغيير ولا تصغر، فقد وصلت في المختبر إلى 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 الرسمي للإصدار 4، وفيه Traefik يقرأ إعداده من الـ Docker Socket، وقد استبدلناه بـ Caddy بملف إعداد صغير، والسبب أن تمرير docker.sock إلى أي Container يعادل منحه صلاحية root على السيرفر، ولا حاجة لذلك هنا.

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

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

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، ثم يبدأ الخدمة، وهو آمن عند التكرار، لذلك يعمل مع كل إعادة تشغيل. أما دليل الإنتاج فيفصل هذه الخطوات الثلاث في Containers منفصلة، وهذا أنسب عندما تشغل أكثر من نسخة من الـ API حتى لا تتسابق على الترحيل Migration.
  • متغيرات FIRSTINSTANCE لا تعمل إلا في أول تشغيل على قاعدة بيانات فارغة، وأضفنا منها حساباً آلياً Machine User اسمه lab-admin يكتب ZITADEL توكنه في admin.pat، وبه سوف نبني العملاء من سطر الأوامر، وهذا الحساب يملك صلاحية المدير على النسخة كلها، فعامل ملفه مثل كلمة مرور root.
  • user: "0" موجودة في الملف الرسمي حتى يكتب الـ API في الـ Volume المشترك، ولاحظ أن الـ Image الخاصة بالـ API لا تضم أوامر مثل cat وls، لذلك قرأنا التوكن من Container واجهة الدخول التي تضم sh.

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

: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 فلم يكن في خطتنا، وإنما أضفناه بسبب المشكلة الثانية التي واجهتنا.

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

docker compose up -d --wait
docker compose logs zitadel-api | grep -E "initialization started|setup started|server is listening"
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:

curl -s http://localhost:18820/.well-known/openid-configuration | head -c 200
{"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 وكتب في السجل:

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:

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 في أول تشغيل مستخدماً بشرياً مديراً اسمه [email protected]، وكلمة مروره الافتراضية Password1! كما يذكر دليل Compose الرسمي، والملف الرسمي نفسه يضبط PASSWORDCHANGEREQUIRED على false، أي أن النظام لا يجبرك على تغييرها. وقد جربناها فعلاً فدخلنا إلى واجهة الإدارة وظهرت المؤسسات الثلاث:

واجهة إدارة ZITADEL تعرض ثلاث مؤسسات: المؤسسة الافتراضية ومؤسستي العميلين
الدخول إلى الـ 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: خطوة يمكن تكرارها لا يخطئ فيها الموظف. والسكربت التالي بـ Node.js يستخدم توكن lab-admin ويبني المشروع والتطبيق والعميلين:

// 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));
PAT=$(docker compose exec -T zitadel-login cat /zitadel/bootstrap/admin.pat) UPASS='كلمة-مرور-قوية' SECRET_OUT=lab.json node setup.mjs
{
 "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، مع ثلاثة تعديلات: مسارات ZITADEL /oauth/v2/authorize و/oauth/v2/token و/oidc/v1/end_session، ونطاق Scope إضافي يطلب مؤسسة المستخدم، وعرض المؤسسة والأدوار بدل المجموعات. والسطر المهم في التعديل هو بناء النطاقات:

      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، ويطلب أن يحمل التوكن معرف مؤسسة المستخدم واسمها، أما الجزء الثاني فنشرحه في القسم التالي. وعند فتح التطبيق ظهرت صفحة الدخول الجديدة بالعربية:

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

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

التطبيق التجريبي يعرض حقول التوكن للمستخدمة sara ومنها اسم المؤسسة ومعرفها والأدوار
sara من شركة أكمي، ودورها admin داخل مؤسستها
{
  "iss": "http://localhost:18820",
  "sub": "396733336069894147",
  "preferred_username": "[email protected]",
  "email": "[email protected]",
  "org": "شركة أكمي",
  "org_id": "396733335113592835",
  "roles": {
    "admin": {
      "396733335113592835": "شركة-أكمي.localhost"
    }
  },
  "amr": [
    "pwd"
  ]
}

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

التوكن مع حقل معرف المؤسسة وحقل الأدوار، وبجانبهما شرح لما يقرؤه التطبيق منهما وتحذير من أخذ رقم العميل من المتصفح
رقم العميل يأتي من التوكن الموقع، وليس من الرابط
  • urn:zitadel:iam:user:resourceowner:id هو معرف مؤسسة المستخدم، وهو الرقم الذي تضعه في tenant_id في قاعدة بيانات تطبيقك، ومنه تبدأ سياسات الصفوف التي بنيناها في مقال Row-Level Security، وبهذا يغلق خطأ 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 بعد إدخال اسم مستخدم غير موجود في المؤسسة، والبريد معبأ مسبقاً
مستخدم غير معروف في المؤسسة يحول إلى التسجيل الذاتي بدل الرفض

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

curl -s -H "Authorization: Bearer $PAT" http://localhost:18820/admin/v1/policies/login
{"allowUsernamePassword":true,"allowRegister":true,"allowExternalIdp":true,"passwordlessType":"PASSWORDLESS_TYPE_ALLOWED","allowDomainDiscovery":true}

والقيمتان المهمتان، بحسب صفحة الإعدادات الافتراضية، هما allowRegister بقيمة true، أي أن أي شخص يستطيع أن ينشئ لنفسه حساباً في أي مؤسسة يصل إلى صفحة دخولها، وignoreUnknownUsernames غائبة أي false، أي أن الصفحة تخبر المهاجم إن كان اسم المستخدم موجوداً أم لا، وهذا ما يسمى كشف المستخدمين User Enumeration. وفي منتج B2B يعني الأول أن موظفاً سابقاً أو منافساً يستطيع أن ينشئ حساباً داخل مؤسسة عميلك، ولولا projectRoleCheck وhasProjectCheck اللذان وضعناهما في المشروع لدخل إلى تطبيقك أيضاً.

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

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

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 مع رسالة فشلت المصادقة بعد إغلاق التسجيل الذاتي
بعد الإصلاح: لا تسجيل، ولا كشف للأسماء، ورسالة «فشلت المصادقة.»

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

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

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

// 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: '[email protected]', profile: { givenName: 'x', familyName: 'y' }, email: { email: '[email protected]', 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: '[email protected]', profile: { givenName: 'nora', familyName: 'acme' }, email: { email: '[email protected]', isVerified: true } });
console.log('create user in acme   ->', addOwn.status, addOwn.body.userId ? 'userId ' + addOwn.body.userId : addOwn.body.message);
machine user 200 pat 200 member ORG_USER_MANAGER in acme 200
list acme  -> 200 acme-hr-sync, [email protected], [email protected]
list globex -> 404 membership not found (AUTHZ-cdgFk)
v2 list all -> 200 acme-hr-sync, [email protected], [email protected]
create user in globex -> 404 membership not found (AUTHZ-cdgFk)
create user in acme   -> 200 userId 396735526033453059

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

  • عندما طلب الحساب قائمة مستخدمي جلوبكس صراحة بالترويسة لم يحصل على قائمة فارغة، وإنما على رفض membership not found، أي أن ZITADEL يتحقق من عضويته في المؤسسة المطلوبة قبل أي استعلام.
  • الواجهة البرمجية v2 التي تبحث في كل النسخة دون ترويسة أعادت مستخدمي أكمي فقط، أي أن نتيجة البحث نفسها مقيدة بصلاحيات من يسأل، وليست قائمة النسخة كلها.
  • إنشاء مستخدم داخل جلوبكس رفض، وداخل أكمي نجح، وهذا بالضبط ما يحتاجه تكامل نظام الموارد البشرية عند العميل، وقد تربطه بعد ذلك عبر SCIM بدل سكربت خاص.

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

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

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

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

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

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 '{}'
{"details":{"sequence":"5", "changeDate":"2026-10-25T23:23:15.263189Z", "resourceOwner":"396733345129590787"}}

ثم حاولت lina أن تدخل بكلمة مرورها الصحيحة، فكانت النتيجة «فشلت المصادقة.»، وعندما أعدنا تفعيل المؤسسة بالأمر _reactivate عاد كل شيء كما كان، وهذا مناسب للعميل الذي تأخر في الدفع أسبوعاً. ولاحظ أن تعطيل المؤسسة يمنع الدخول الجديد، أما الجلسات المفتوحة في تطبيقك فتبقى حتى تنتهي صلاحية توكناتها أو يتحقق التطبيق من المستخدم عبر Introspection، لذلك اجعل مدة صلاحية الـ Access Token قصيرة، وأضف في تطبيقك فحصاً لحالة العميل عند الأعمال الحساسة. أما حذف بيانات العميل نهائياً بعد انتهاء مدة الاحتفاظ في العقد فهو خطوة مختلفة تشمل قاعدة بيانات تطبيقك أيضاً، وقد شرحناها في عمليات المستأجر الواحد.

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

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

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"}'
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

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

docker compose exec postgres psql -U postgres -d zitadel -tc "select aggregate_type, count(*) from eventstore.events2 group by 1 order by 2 desc"
 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 وكل جلسة بأحداثه. وهذا يعني أن الأحداث تزيد مع عدد عمليات الدخول وليس مع عدد المستخدمين فقط، فراقب حجم قاعدة البيانات شهرياً. وإذا كنت تبني سجل تدقيق للتطبيق نفسه بجانب هذا السجل فقد شرحنا ما يجب أن يحفظ فيه في مقال سجل التدقيق.

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

الخدمةبعد التشغيلبعد التجاربملاحظة
zitadel-api82 MiB151 MiBImage بحجم 237 MB
zitadel-login79 MiB116 MiBImage بحجم 332 MB
PostgreSQL79 MiB109 MiBقاعدة البيانات 16 MB والـ Volume كله 73 MB
Caddy12 MiB20 MiB

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

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

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

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 بشكل طبيعي، أي أن كل فحوص الصحة كانت ستقول إنها سليمة. ولكن عندما استدعيناها بتوكن الحساب الآلي نفسه كان الرد:

/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. تابع التنبيهات الأمنية والإصدارات، فقد رأينا أن 2026 حمل 42 تنبيهاً، وخطط لتطبيق الإصدارات التصحيحية خلال أيام.
  2. اقرأ ملاحظات الإصدار ودليل التحديث، وخاصة عند الانتقال بين إصدارات رئيسية، فقد غيرت 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، وإذا كان عملاؤك جهات في المملكة فسوف يسألونك أين تحفظ، لأن لائحة نقل البيانات الشخصية خارج المملكة تضع شروطاً على نقلها، وقد شرحنا ما يعنيه ذلك للخدمات المستضافة في مقال PDPL للخدمات المستضافة. وهنا يظهر سبب من أسباب الاستضافة الذاتية: عندما تشغل ZITADEL على سيرفر داخل المملكة تبقى قاعدة البيانات وسجل الأحداث عندك، أما الخدمة السحابية ZITADEL Cloud فتعرض في صفحة الأسعار مناطق الاتحاد الأوروبي والولايات المتحدة وسويسرا وأستراليا لمكان البيانات Data Residency. وهذا لا يعني أن الخدمة السحابية ممنوعة، وإنما أنه قرار يجب أن تقرأ فيه اللائحة وعقود عملائك قبل أن تختار.

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

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

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

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

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

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

الخلاصة

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

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

وإذا كان منتجك داخلياً لفريق واحد فقد يناسبك أكثر authentik أو Keycloak، وفي مقال قادم بإذن الله سوف نقارن الأدوات الأربع ونشرح متى تختار كل واحدة منها.

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

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

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.