عندما يغادر موظف المؤسسة يكون أول سؤال يطرحه مسؤول الأنظمة هو «ما الحسابات التي كانت لديه؟»، وغالباً لا يجد إجابة كاملة، فالحساب الرئيسي في مزود الهوية Identity Provider معروف، ولكن هناك أيضاً الرمز Token الذي أنشأه في سيرفر Git قبل سنة، ومفتاح SSH الذي أضافه إلى سيرفر الاختبار، وكلمة مرور حساب المزود الخارجي التي يعرفها ثلاثة أشخاص، والحاسوب المحمول الذي ما زال في بيته، والمستودع الذي يملكه وحده. والمشكلة نفسها تظهر بشكل أهدأ عندما ينتقل الموظف من قسم إلى آخر، فيأخذ صلاحيات قسمه الجديد ولا يسحب منه أحد صلاحيات القسم القديم، وبعد خمس سنوات يملك حساب واحد صلاحيات ثلاثة أقسام.
وقد ذكرنا في إدارة الهوية والوصول IAM أن الحساب يمر بثلاث لحظات تسمى التعيين والنقل والمغادرة Joiner, Mover, Leaver واختصاراً JML، وشرحنا في ما هو SCIM؟ المعيار الذي يرسل به مزود الهوية هذه التغييرات إلى التطبيقات. وفي هذا المقال نأخذ الدورة كاملة كعملية تشغيلية في المؤسسة: من يقرر الحدث، وما الذي يجب أن يحدث في كل لحظة، وما الذي يبقى مفتوحاً بعد تعطيل الحساب، وسوف نناقش في هذا المقال ما يلي:
- لماذا تهم دورة حياة الحساب: الحسابات التي بقيت بلا صاحب، وتراكم الصلاحيات، والموظفون السابقون الذين ما زالوا يدخلون، مع حوادث موثقة.
- الموارد البشرية مرجعاً وحيداً Source of Truth، ولماذا لا يبدأ أي حساب ولا ينتهي إلا بحدث فيها.
- التعيين والنقل والمغادرة خطوة خطوة، ومع كل منها قائمة تحقق جاهزة للنسخ.
- مراجعة الصلاحيات Access Review، وما تطلبه الضوابط الأساسية للأمن السيبراني ECC من الهيئة الوطنية للأمن السيبراني.
- تجربة عملية: ملف موظفين من الموارد البشرية، وسكربت يحوله إلى حسابات ومجموعات في authentik وGitea، ثم نقل الموظفة ومغادرتها، ونرى بعد كل خطوة أي باب ما زال مفتوحاً.
لماذا تهم دورة حياة الحساب؟
أغلب الحوادث المرتبطة بالحسابات لا تحتاج إلى ثغرة في الكود ولا إلى مخترق محترف، وإنما إلى حساب كان يجب أن يغلق ولم يغلق، أو إلى صلاحية كان يجب أن تسحب ولم تسحب. وفيما يلي المشكلات الثلاث التي تجدها في أي مؤسسة مرت عليها بضع سنوات دون عملية واضحة:
- الحسابات التي بقيت بلا صاحب Orphaned Accounts: حساب في تطبيق لا يقابله موظف حالي، لأن صاحبه غادر ولم يغلق أحد حسابه في هذا التطبيق بالتحديد، أو لأن أحدهم أنشأه يدوياً لمتعاقد أو لتجربة ونسيه. وهذا الحساب أخطر من غيره لأنه لا يراقبه أحد، فلا أحد يلاحظ إذا دخل منه شخص في الثالثة فجراً.
- تراكم الصلاحيات Privilege Creep: الموظف الذي ينتقل من المالية إلى المبيعات ثم إلى التطوير يحصل في كل مرة على صلاحيات جديدة، ولا يسحب منه شيء، فيجتمع في حسابه ما يجب أن يكون موزعاً على عدة أشخاص، وهذا يكسر مبدأ أقل صلاحية ممكنة Least Privilege ومبدأ فصل المهام Segregation of Duties اللذين شرحناهما في مقال IAM.
- موظفون سابقون ما زالوا يدخلون: وهذه أوضح المشكلات وأكثرها كلفة، فالموظف الذي غادر غاضباً ويعرف الأنظمة من الداخل ويملك حساباً لم يغلق هو أخطر مهاجم يمكن أن تواجهه، لأنه لا يحتاج إلى البحث عن ثغرة.
الحلول الأولى، ولماذا لا تعمل
أغلب الفرق الصغيرة تبدأ بأحد حلين، وكلاهما يبدو معقولاً في البداية:
- رسالة إلى تقنية المعلومات: ترسل الموارد البشرية بريداً «انضمت سارة إلى المالية» أو «آخر يوم لعمر هو الخميس»، وينفذ مسؤول الأنظمة ما يتذكره. والمشكلة أن الرسالة قد تصل متأخرة أو لا تصل، وأن ما ينفذه يعتمد على ذاكرته، فلا توجد قائمة بالتطبيقات التي يملك فيها الموظف حساباً، ولا يوجد دليل بعد شهر على ما أغلق وما لم يغلق.
- تعطيل الحساب في مزود الهوية فقط: بعد أن ربطت تطبيقاتك بالدخول الموحد SSO قد تظن أن تعطيل الحساب في مكان واحد يغلق كل شيء. وهذا صحيح لما يمر على مزود الهوية فقط، أما الرموز Tokens ومفاتيح SSH وكلمات المرور المشتركة والأجهزة فلا تسأل مزود الهوية أصلاً، وسوف نرى ذلك بالأرقام في التجربة العملية.
وقد يتساءل البعض: إذا كان SCIM يرسل التعطيل إلى كل تطبيق، أفلا يحل المشكلة؟ والإجابة أنه يحل جزءاً مهماً منها، ولكنه يبدأ من مزود الهوية، فإذا لم يعرف مزود الهوية أن الموظف غادر فلن يرسل شيئاً، ولا يصل إلى التطبيقات التي لا تدعمه، ولا يعيد الحاسوب المحمول ولا يغير كلمة مرور Wi-Fi. لذلك تحتاج إلى عملية كاملة تبدأ من الموارد البشرية وتنتهي بقائمة تحقق موقعة، وتكون الأدوات جزءاً منها.
ما هي دورة JML؟
دورة حياة الحساب Identity Lifecycle هي كل ما يحدث لهوية الموظف وحساباته وصلاحياته وأجهزته من قبل يومه الأول إلى ما بعد يومه الأخير، وتنقسم إلى ثلاثة أحداث:
- التعيين Joiner: موظف جديد أو متعاقد جديد، يحتاج إلى حساباته وأجهزته وصلاحيات دوره من أول ساعة، ولا شيء أكثر من ذلك.
- النقل Mover: تغير في الدور، مثل الانتقال إلى قسم آخر أو الترقية إلى مدير أو الانتقال المؤقت إلى مشروع، فتسحب صلاحيات الدور القديم وتضاف صلاحيات الجديد.
- المغادرة Leaver: انتهاء العلاقة بالاستقالة أو الفصل أو انتهاء العقد، فيغلق كل باب في اليوم نفسه، ثم تنقل ملكية ما كان يملكه، ثم تحذف الحسابات لاحقاً بحسب سياسة الاحتفاظ بالبيانات Retention Policy.
والصورة التالية تبين الدورة كاملة: الموارد البشرية تسجل الحدث، ومزود الهوية يحوله إلى حسابات ومجموعات، والتطبيقات تتبعه، ومراجعة الصلاحيات الدورية تلتقط ما فات الأحداث الثلاثة:

وهذه العملية ليست اختراعاً جديداً، فالضابط AC-2 في NIST SP 800-53 يطلب أن تنشئ الحسابات وتعدلها وتعطلها وتحذفها بحسب إجراءات محددة، وأن تتوافق إدارة الحسابات مع إجراءات إنهاء الخدمة والنقل في الموارد البشرية، والضابط PS-4 يفصل إنهاء الخدمة: تعطيل الوصول خلال مدة تحددها المؤسسة، وإلغاء كل وسائل المصادقة المرتبطة بالشخص، واسترجاع الممتلكات، والاحتفاظ بالوصول إلى المعلومات التي كان يتحكم بها، والضابط PS-5 يطلب عند النقل أن تراجع الحاجة إلى كل صلاحية وتعدلها.
الموارد البشرية هي المرجع الوحيد Source of Truth
القاعدة الأولى في هذه العملية أن الحساب لا ينشأ ولا ينقل ولا يغلق إلا بسبب حدث في نظام الموارد البشرية HR System، والسبب أن الموارد البشرية هي الجهة الوحيدة التي تعرف يقيناً من يعمل في المؤسسة اليوم، وفي أي قسم، ومتى ينتهي عقده، أما مزود الهوية فيعرف ما أدخله فيه أحدهم. لذلك يكون اتجاه البيانات دائماً من الموارد البشرية إلى مزود الهوية ثم إلى التطبيقات، ولا يعدل أحد حسابات الموظفين يدوياً في التطبيقات إلا بتذكرة مسجلة.
والصورة التالية تبين هذا الاتجاه، والطرق الثلاث التي تصل بها الحسابات إلى التطبيقات بحسب ما يدعمه كل تطبيق:

ولكي يعمل هذا الاتجاه تحتاج إلى أربعة أشياء من الموارد البشرية:
- معرف ثابت لكل موظف Employee ID: لا يتغير عند تغير الاسم أو البريد أو القسم، وهو ما تربط به الحساب في مزود الهوية، وقد رأينا في مقال SCIM لماذا تربط الحسابات بالمعرفات وليس بالأسماء.
- القسم والمسمى الوظيفي: لأنهما يحددان الدور Role، والدور يحدد المجموعات، فلا يطلب أحد صلاحيات لكل شخص على حدة.
- الحالة والتواريخ: تاريخ البدء، وتاريخ آخر يوم، وتاريخ إبلاغ الموظف بإنهاء خدمته إذا اختلف عنه، والإجازات الطويلة التي يعلق فيها الحساب.
- المتعاقدون Contractors والموردون: وهنا تخطئ أغلب المؤسسات، فالمتعاقد لا يظهر غالباً في نظام الرواتب، فلا يعرف عنه أحد عندما ينتهي عقده. لذلك سجل كل متعاقد في نظام الموارد البشرية أو في سجل مستقل، ومعه موظف مسؤول عنه Sponsor وتاريخ انتهاء إلزامي.
أما طريقة النقل من الموارد البشرية إلى مزود الهوية فتختلف بحسب النظام: ملف تصدير دوري بصيغة CSV كما سنفعل في التجربة، أو واجهة برمجية API يقرأ منها سكربت المزامنة، أو إشعار Webhook يرسله نظام الموارد البشرية عند كل تغيير. والمهم في كل الحالات أن المزامنة تقارن الحالة الكاملة في كل مرة، أي أنها تسأل «من يجب أن يكون موجوداً الآن؟» وليس «ما الرسائل التي وصلتني؟»، والسبب أن الرسالة الضائعة في الطريقة الثانية تعني حساباً لا يغلق أبداً.
التعيين Joiner: كل ما يحتاجه الموظف من أول يوم، ولا شيء أكثر
الهدف في التعيين أن يجد الموظف الجديد كل شيء جاهزاً في يومه الأول، وأن يكون كل ما حصل عليه مشتقاً من دوره، والسبب أن الصلاحية التي تعطى «لأنه طلبها» دون دور هي أول صلاحية سوف تنسى عند النقل والمغادرة. وفيما يلي ما يحدث بالترتيب:
- الحساب قبل اليوم الأول: ينشأ الحساب في مزود الهوية عند تسجيل الموظف في الموارد البشرية، ويصل إلى التطبيقات قبل أن يدخل، حتى يستطيع زملاؤه أن يضيفوه إلى قناة أو يشاركوه مستنداً.
- المجموعات من الدور: جدول واحد يربط كل قسم أو مسمى بمجموعاته، مثل
Finance → financeوEngineering → engineering, vpn-users، وأي صلاحية خارج هذا الجدول تحتاج إلى طلب موثق وتاريخ انتهاء. - أول دخول دون كلمة مرور مرسلة: لا ترسل كلمة مرور في البريد، وإنما أرسل رابط تسجيل أو رابط استعادة Recovery Link صالحاً لمدة قصيرة، فـ authentik مثلاً يستطيع أن ينشئ رابط استعادة ويرسله للمستخدم أو أن يرسل دعوة Invitation، فيضع الموظف كلمة مروره بنفسه.
- المصادقة متعددة العوامل MFA من أول دخول: اجعل تسجيل العامل الثاني جزءاً من أول دخول وليس خطوة اختيارية لاحقاً، كما في فرض المصادقة الثنائية في authentik، والأفضل أن يكون مفتاح مرور Passkey كما شرحنا في مقال Passkeys.
- الأجهزة: حاسوب مسجل في نظام إدارة الأجهزة MDM قبل تسليمه، مع التشفير وسياسة القفل، كما في إدارة أجهزة الموظفين، وإذا كانت المؤسسة تسمح بالأجهزة الشخصية فالقواعد في مقال BYOD.
- التوعية والتوقيع: توقيع سياسة الاستخدام المقبول وبند السرية، وجلسة توعية قصيرة، وهذا مطلوب في الضوابط كما سيأتي.
والجدول التالي قائمة تحقق للتعيين، انسخها إلى نظام التذاكر لديك واجعل لكل سطر مسؤولاً:
| الخطوة | المسؤول | متى | ملاحظة |
|---|---|---|---|
| تسجيل الموظف أو المتعاقد بمعرف ثابت وقسم ومدير وتاريخ بدء (وتاريخ انتهاء للمتعاقد) | الموارد البشرية | عند توقيع العقد | المتعاقد يحتاج إلى موظف مسؤول عنه |
| إنشاء الحساب في مزود الهوية من بيانات الموارد البشرية | المزامنة أو تقنية المعلومات | قبل اليوم الأول بيومين على الأقل | لا يدخل أحد البيانات يدوياً |
| إضافة المجموعات من جدول الأدوار | المزامنة | مع إنشاء الحساب | أي صلاحية إضافية بطلب موثق وتاريخ انتهاء |
| وصول الحساب إلى التطبيقات (SCIM أو واجهة برمجية أو تذكرة) | المزامنة ومسؤول كل تطبيق | قبل اليوم الأول | التطبيق اليدوي يحتاج تذكرة مغلقة بدليل |
| إرسال رابط التسجيل أو الاستعادة، لا كلمة مرور | تقنية المعلومات | صباح اليوم الأول | صلاحية الرابط ساعات وليس أياماً |
| تسجيل MFA أو Passkey في أول دخول | الموظف | أول دخول | إلزامي في مزود الهوية |
| تجهيز الحاسوب وتسجيله في MDM وتسليمه بمحضر استلام | تقنية المعلومات | قبل اليوم الأول | رقم الجهاز في سجل الأصول باسم الموظف |
| توقيع سياسة الاستخدام المقبول وبند السرية، وجلسة التوعية | الموارد البشرية والأمن السيبراني | الأسبوع الأول | يحفظ التوقيع في ملف الموظف |
| تأكيد المدير أن الوصول يكفي العمل ولا يزيد عليه | المدير المباشر | نهاية الأسبوع الأول | أول مراجعة صلاحيات للموظف |
النقل Mover: اسحب القديم قبل أن تضيف الجديد
النقل هو الحدث الذي ينساه الجميع، والسبب أن الموظف نفسه يطلب الصلاحيات الجديدة لأنه يحتاجها، ولا يطلب أحد سحب القديمة لأن غيابها لا يزعج أحداً، فتتراكم مع كل انتقال. والصورة التالية تبين الفرق بين نقل يضيف فقط ونقل يستبدل الدور:

والطريقة الصحيحة أن يعامل النقل كاستبدال للدور Role Swap: تحسب الفرق بين مجموعات الدور القديم والجديد، فتسحب ما لم يعد في الدور وتضيف الجديد، في اليوم نفسه الذي يسجل فيه النقل في الموارد البشرية. وقد يتساءل البعض: وماذا لو احتاج الموظف إلى صلاحيات قسمه القديم أسبوعين حتى يسلم عمله؟ والإجابة أن هذا استثناء له اسم ومدة، فيبقى ما يحتاجه بالتحديد في مجموعة مؤقتة لها تاريخ انتهاء ويوافق عليها مدير القسم القديم، ولا يبقى الدور القديم كاملاً «إلى أن نتذكر».
ولاحظ أن النقل يشمل أيضاً الترقية إلى مدير، والانتقال إلى فريق الإنتاج Production، والعودة من إجازة طويلة، والتحول من متعاقد إلى موظف، فكل تغيير في الدور هو نقل حتى لو بقي القسم نفسه.
| الخطوة | المسؤول | متى | ملاحظة |
|---|---|---|---|
| تسجيل القسم أو المسمى الجديد وتاريخ النقل | الموارد البشرية | قبل تاريخ النقل | هذا الحدث هو ما تقرؤه المزامنة |
| سحب مجموعات الدور القديم وإضافة مجموعات الجديد | المزامنة | يوم النقل | الفرق بين الدورين وليس إضافة فقط |
| إخراج الموظف من الفرق والقنوات والمشاركات اليدوية للقسم القديم | مسؤول كل تطبيق | يوم النقل | هنا تختبئ أغلب الصلاحيات المتراكمة |
| تحديد ما يبقى مؤقتاً للتسليم، في مجموعة لها تاريخ انتهاء | مدير القسم القديم | قبل النقل | أسبوعان مثلاً، ثم تسحب تلقائياً |
| تدوير الأسرار المشتركة للقسم القديم التي كان يعرفها | مسؤول النظام | خلال أسبوع | إذا كان القسم القديم حساساً |
| تأكيد المدير الجديد أن الوصول يطابق الدور الجديد | المدير الجديد | نهاية الأسبوع الأول | يسجل في نظام التذاكر |
المغادرة Leaver: كل باب في اليوم نفسه
المغادرة أهم الأحداث الثلاثة، وأكثرها أبواباً، والقاعدة فيها أن الإغلاق يحدث في يوم المغادرة نفسه، وفي حالات إنهاء الخدمة غير الودية يحدث قبل أن يعرف الموظف أو في الساعة التي يبلغ فيها. والسبب أن أخطر وقت هو الفترة بين قرار إنهاء الخدمة وإغلاق الحسابات، كما تبين الحادثتان التاليتان.
والصورة التالية تبين ما يغلقه تعطيل الحساب في مزود الهوية وما يبقى مفتوحاً، والأبواب الأربعة على اليسار نتائج حقيقية من التجربة العملية في آخر المقال:

وفيما يلي الخطوات بالترتيب الذي نطبقه:
- عطل الحساب في مزود الهوية: وهذا يغلق الدخول الموحد إلى كل التطبيقات المربوطة به، وفي authentik يلغي التعطيل الجلسات النشطة ويمنع المصادقة كما رأينا في التجربة. ولا تقم بحذف الحساب في هذه المرحلة، والسبب أن الحذف يمسح سجل من فعل ماذا ويكسر نقل الملكية.
- ألغ الجلسات والرموز في كل تطبيق: الجلسة المفتوحة في تطبيق يحتفظ بجلساته، والرموز الشخصية Personal Access Tokens، وكلمات مرور التطبيقات App Passwords، ومفاتيح API، وتوكنات التحديث Refresh Tokens التي شرحنا عمرها في مقال OAuth، فكل هذه تعمل حتى تلغى أو تنتهي صلاحيتها.
- احذف مفاتيح SSH: من سيرفر Git ومن ملفات
authorized_keysعلى السيرفرات، والأفضل أن تكون مفاتيح SSH شهادات قصيرة العمر كما في تجربة شهادات SSH، فلا يبقى شيء تحذفه. - غير الأسرار المشتركة التي كان يعرفها: كلمات المرور المشتركة، ومفتاح Wi-Fi المشترك، وحسابات الخدمة، وكلمات مرور حسابات المزودين الخارجيين، وأسرار CI/CD التي كان يستطيع قراءتها، والسبب أن هذه الأسرار لا ترتبط بحسابه فلا يلغيها تعطيله، وهذا ما يطلبه البند k في الضابط AC-2 من NIST.
- البريد: حول البريد الوارد إلى المدير أو إلى رد تلقائي لمدة محددة، واحذف أي قاعدة تحويل Forwarding Rule إلى عنوان خارجي، واحتفظ بصندوق البريد بحسب سياسة الاحتفاظ، وقد فصلنا ذلك في دليل استضافة البريد للمؤسسات.
- الأجهزة: استرجع الحاسوب والهاتف وبطاقة الدخول ومفتاح الأمان بمحضر تسليم، ثم امسح الجهاز أو امسح قسم العمل في الجهاز الشخصي كما في مقال BYOD، وإذا لم يعد الجهاز فاقفله ومسحه عن بعد من نظام إدارة الأجهزة.
- نقل الملكية: المستودعات والملفات والمستندات المشتركة والبوتات Bots والمهام المجدولة التي يملكها، تنقل إلى المدير أو إلى حساب الفريق قبل الحذف، والسبب أن التطبيقات تحذف غالباً ما يملكه المستخدم عند حذفه.
- السجلات والمراجعة: راجع آخر دخول له وما نزله في الأسابيع الأخيرة، واحتفظ بسجل الإغلاق نفسه، وقد شرحنا كيف تحمي هذه السجلات في سجل التدقيق Audit Trail.
- الحذف لاحقاً: بعد مدة الاحتفاظ التي تحددها سياستكم، 90 يوماً مثلاً، تحذف الحسابات المعطلة، وتذكر ما رأيناه في مقال SCIM: إزالة المستخدم من مجموعة التطبيق في authentik ترسل حذفاً وليس تعطيلاً.
| الخطوة | المسؤول | متى | ملاحظة |
|---|---|---|---|
| تسجيل تاريخ آخر يوم وتاريخ الإبلاغ ونوع المغادرة | الموارد البشرية | فور القرار | الإنهاء غير الودي يغلق قبل الإبلاغ أو معه |
| إغلاق الوصول المميز وحسابات الخدمة التي يستخدمها | مسؤول الأنظمة | يوم الإبلاغ | لا تنتظر آخر يوم |
| تعطيل الحساب في مزود الهوية | المزامنة | نهاية آخر يوم عمل | تعطيل وليس حذفاً |
| إلغاء الجلسات والرموز وكلمات مرور التطبيقات ومفاتيح API | المزامنة ومسؤول كل تطبيق | الساعة نفسها | كل تطبيق يحتفظ بجلساته |
| حذف مفاتيح SSH من سيرفر Git والسيرفرات | مسؤول الأنظمة | الساعة نفسها | ابحث في authorized_keys على كل سيرفر |
| تدوير كلمات المرور المشتركة وأسرار CI/CD وحسابات المزودين | مالك كل سر | خلال 24 ساعة | قائمة الأسرار التي كان يصل إليها |
| تحويل البريد وحذف قواعد التحويل الخارجية | مسؤول البريد | يوم المغادرة | الاحتفاظ بحسب السياسة |
| استرجاع الحاسوب والهاتف والبطاقة ومفتاح الأمان، ثم المسح | تقنية المعلومات | آخر يوم | إن لم يعد: قفل ومسح عن بعد |
| نقل ملكية المستودعات والملفات والبوتات والمهام المجدولة | المدير المباشر | قبل الحذف | قائمة يصدرها السكربت لكل تطبيق |
| إلغاء الوصول المادي والإشعار للمزودين والعملاء إن لزم | الموارد البشرية والمرافق | آخر يوم | بطاقة المبنى وحسابات البوابات الخارجية |
| التحقق من إغلاق كل باب، وحفظ الدليل | الأمن السيبراني | خلال 48 ساعة | مخرج سكربت الفحص أو لقطة من كل تطبيق |
| حذف الحسابات المعطلة | مسؤول كل تطبيق | بعد مدة الاحتفاظ | بعد نقل الملكية فقط |
مراجعة الصلاحيات Access Review: ما فات الأحداث الثلاثة
حتى مع مزامنة تعمل جيداً سوف تجد استثناءات: صلاحية أعطيت يدوياً لحل مشكلة عاجلة، ومتعاقد أنشئ له حساب محلي في تطبيق واحد، وحساب لم يدخل منه أحد منذ ستة أشهر. لذلك تحتاج إلى مراجعة دورية، وقد عرفناها باختصار في مقال IAM، وهنا ما نبحث عنه فيها بالتحديد:
- الحسابات التي لا يقابلها موظف: كل حساب نشط في تطبيق لا يرتبط بمعرف موظف حالي في الموارد البشرية.
- العضوية خارج الدور: كل عضوية في مجموعة لا يبررها جدول الأدوار، فإما أن تسحب وإما أن تسجل استثناءً له مالك وتاريخ انتهاء.
- الحسابات الخاملة Dormant Accounts: حسابات لم تدخل منذ مدة تحددها، 90 يوماً مثلاً، فتعطل حتى يطلبها صاحبها، وهذا ما يقصده البند (d) في الضابط AC-2(3) من NIST.
- الحسابات المميزة وحسابات الخدمة: تراجع أكثر من غيرها، كل شهر مثلاً، ولكل حساب خدمة مالك من الموظفين الحاليين، فإذا غادر المالك انتقلت الملكية ودورت الأسرار.
والمراجعة التي لا تسحب شيئاً ليست مراجعة، لذلك اجعل لكل سطر قراراً: «يبقى» أو «يسحب» أو «استثناء حتى تاريخ كذا»، واحفظ القرارات واسم من اتخذها، لأن هذا هو الدليل الذي يطلبه المدقق.
ماذا تطلب الضوابط الأساسية للأمن السيبراني ECC؟
إذا كانت مؤسستك ضمن نطاق الضوابط الأساسية للأمن السيبراني ECC-2:2024 من الهيئة الوطنية للأمن السيبراني فدورة JML ليست اختياراً، وفيما يلي الضوابط التي تمسها مباشرة كما وردت في النسخة العربية والنسخة الإنجليزية من الوثيقة:
| الضابط | ما يطلبه | أين يظهر في JML |
|---|---|---|
| 1-9 الأمن السيبراني المتعلق بالموارد البشرية | إدارة متطلبات الأمن السيبراني للعاملين، موظفين ومتعاقدين، قبل العمل وأثناءه وعند انتهائه | الموارد البشرية هي المرجع، والمتعاقدون داخل العملية |
| 1-9-3 و1-9-4 | بنود المسؤولية والسرية في العقود، والتحقق من خلفية شاغلي الوظائف ذات الصلاحيات الحساسة، والتوعية عند التعيين وأثناء العمل | قائمة التعيين |
| 1-9-5 | مراجعة صلاحيات العاملين وإلغاؤها فور انتهاء أو إنهاء العلاقة الوظيفية | المغادرة في اليوم نفسه |
| 2-2-3-3 | منح الصلاحيات بحسب الحاجة إلى المعرفة والاستخدام، وأقل صلاحية ممكنة، وفصل المهام | جدول الأدوار، والنقل كاستبدال للدور |
| 2-2-3-4 | إدارة الصلاحيات الهامة والحساسة Privileged Access Management | إغلاق الوصول المميز يوم الإبلاغ |
| 2-2-3-5 | المراجعة الدورية لهويات الدخول والصلاحيات | مراجعة الصلاحيات |
| 2-2-4 | مراجعة تطبيق متطلبات إدارة الهويات والصلاحيات دورياً | مراجعة العملية نفسها وليس الحسابات فقط |
| 2-12-3-2 | تفعيل سجلات الأحداث للحسابات ذات الصلاحيات الهامة وللدخول عن بعد | سجل الإغلاق ومراجعة آخر دخول |
| 4-1-2 | بنود في عقود الأطراف الخارجية منها عدم الإفصاح والحذف الآمن لبيانات الجهة عند انتهاء الخدمة | مغادرة المتعاقدين والموردين |
ولاحظ أن الضوابط تطلب في كل مكون أن تحدد المتطلبات وتوثق وتعتمد ثم تطبق ثم تراجع، أي أن المدقق سوف يسأل عن الإجراء المكتوب، وعن الدليل على تطبيقه، مثل قوائم التحقق المغلقة وتقارير المراجعة، وليس عن الأداة.
تجربة عملية: من ملف الموارد البشرية إلى إغلاق آخر باب
الآن نطبق الدورة كاملة على مثال صغير لكن حقيقي، وهدفنا أن نرى بالأرقام ما يفتحه كل حدث وما يغلقه. وسوف نستخدم authentik مزود هوية، كما في دليل تثبيت authentik، وGitea تطبيقاً، واخترناه لأنه لا يدعم SCIM، فهو مثال على التطبيقات التي تصلها الحسابات عبر واجهتها البرمجية، وأي تطبيق يدعم SCIM يقوم authentik بالجزء الخاص به بنفسه كما شرحنا في مقال SCIM. والصورة التالية تبين مكونات التجربة:

الخطوة 1: تشغيل authentik وGitea
الملف التالي يشغل authentik مع قاعدة بياناته، وGitea بقاعدة SQLite، وكل المنافذ مربوطة بالعنوان 127.0.0.1 فقط:
name: lab-jml
services:
db:
image: postgres:16-alpine
container_name: lab-jml-db
environment:
POSTGRES_USER: authentik
POSTGRES_PASSWORD: lab-db-pass-91c2
POSTGRES_DB: authentik
volumes: [lab-jml-db:/var/lib/postgresql/data]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U authentik"], interval: 5s, retries: 20 }
authentik:
image: ghcr.io/goauthentik/server:2026.8.3
container_name: lab-jml-authentik
command: server
environment: &ak
AUTHENTIK_SECRET_KEY: lab-secret-key-change-me-0b7d4f2a9e8c1d3f5a6b
AUTHENTIK_POSTGRESQL__HOST: db
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: lab-db-pass-91c2
AUTHENTIK_BOOTSTRAP_PASSWORD: lab-admin-pass-5e1a
AUTHENTIK_BOOTSTRAP_TOKEN: lab-api-token-3c9f7a2e
AUTHENTIK_BOOTSTRAP_EMAIL: [email protected]
ports: ["127.0.0.1:19400:9000"]
depends_on: { db: { condition: service_healthy } }
worker:
image: ghcr.io/goauthentik/server:2026.8.3
container_name: lab-jml-worker
command: worker
environment: *ak
depends_on: { db: { condition: service_healthy } }
gitea:
image: gitea/gitea:1.27.3
container_name: lab-jml-gitea
environment:
GITEA__database__DB_TYPE: sqlite3
GITEA__security__INSTALL_LOCK: "true"
GITEA__server__ROOT_URL: http://127.0.0.1:19430/
GITEA__service__DISABLE_REGISTRATION: "true"
GITEA__server__SSH_PORT: "19432"
ports: ["127.0.0.1:19430:3000", "127.0.0.1:19432:22"]
volumes: [lab-jml-gitea:/data]
volumes:
lab-jml-db:
name: lab-jml-db
lab-jml-gitea:
name: lab-jml-gitea
networks:
default:
name: lab-jml-netفي الإعداد أعلاه لاحظ التالي:
- المتغير
AUTHENTIK_BOOTSTRAP_TOKENينشئ عند أول تشغيل توكن API لحساب المديرakadminكما في توثيق التثبيت الآلي، وهو ما يستخدمه السكربت، وفي بيئة حقيقية أنشئ حساب خدمة Service Account له الصلاحيات التي يحتاجها السكربت فقط. - المنفذ
19432هو سيرفر SSH المدمج في Docker Image الخاص بـ Gitea، ونحتاجه لنختبر مفتاح SSH الخاص بسارة. - كلمات المرور والمفاتيح هنا للتجربة فقط، فلا تقم باستخدامها في أي بيئة أخرى.
docker compose up -dثم نجهز Gitea بحساب مدير وتوكن للسكربت، ومؤسسة acme فيها مستودعان خاصان، وفريقين لكل منهما مستودعه، عبر أداة سطر الأوامر في Gitea وواجهته البرمجية:
#!/usr/bin/env bash
# One-time Gitea setup for the lab: an admin with a token for the script, an organisation, two private repos, two teams.
set -e
docker exec -u git lab-jml-gitea gitea admin user create --username itadmin --password 'lab-gitea-pass-7d2b' \
--email [email protected] --admin --must-change-password=false
GITEA_TOKEN=$(docker exec -u git lab-jml-gitea gitea admin user generate-access-token \
--username itadmin --token-name jml-sync --scopes all --raw)
echo "$GITEA_TOKEN" > gitea_admin_token
GT=http://127.0.0.1:19430/api/v1
H=(-s -o /dev/null -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json")
curl "${H[@]}" -X POST $GT/orgs -d '{"username":"acme","visibility":"private"}'
for r in finance-reports platform; do
curl "${H[@]}" -X POST $GT/orgs/acme/repos -d "{\"name\":\"$r\",\"private\":true,\"auto_init\":true}"
done
for t in finance engineering; do
curl "${H[@]}" -X POST $GT/orgs/acme/teams -d "{\"name\":\"$t\",\"permission\":\"write\",\"units\":[\"repo.code\"]}"
done
team() { curl -s -H "Authorization: token $GITEA_TOKEN" $GT/orgs/acme/teams | python3 -c "import sys,json;print({t['name']:t['id'] for t in json.load(sys.stdin)}['$1'])"; }
curl "${H[@]}" -X PUT $GT/teams/$(team finance)/repos/acme/finance-reports
curl "${H[@]}" -X PUT $GT/teams/$(team engineering)/repos/acme/platform
echo "gitea ready"الخطوة 2: ملف الموارد البشرية وسكربت المزامنة
ملف الموارد البشرية هنا ملف CSV فيه معرف الموظف واسم المستخدم والقسم والحالة، وهو ما يصدره أي نظام موارد بشرية:
employee_id,username,full_name,email,department,status
1042,sara,Sara Ahmed,[email protected],Finance,active
1043,omar,Omar Khalid,[email protected],Engineering,activeوالسكربت التالي هو قلب التجربة، وهو مكتوب بلغة Python دون أي مكتبة خارجية، ويقارن الملف بما في authentik ثم يقرر لكل موظف هل هو تعيين أو نقل أو مغادرة، ويطبق القرار على authentik عبر واجهته البرمجية وعلى Gitea عبر واجهة الإدارة:
#!/usr/bin/env python3
"""Reconcile an HR export with authentik and Gitea: joiners, movers, leavers and orphans.
python3 jml.py hr.csv print the plan only (dry run)
python3 jml.py hr.csv --apply apply it
python3 jml.py --review access review: who is in each group, and who should not be"""
import csv, json, os, sys, secrets, urllib.request, urllib.error
AK = os.environ.get("AK_URL", "http://127.0.0.1:19400") + "/api/v3"
GT = os.environ.get("GITEA_URL", "http://127.0.0.1:19430") + "/api/v1"
AK_TOKEN, GT_TOKEN = os.environ["AK_TOKEN"], os.environ["GITEA_TOKEN"]
ORG, PATH = "acme", "employees" # Gitea organisation, authentik path of the accounts this script owns
# The role map: what each department gets. Change access here, never person by person.
ROLES = {
"Finance": {"groups": ["finance"], "team": "finance"},
"Engineering": {"groups": ["engineering", "vpn-users"], "team": "engineering"},
}
def call(base, scheme, token, method, path, body=None):
req = urllib.request.Request(base + path, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": f"{scheme} {token}", "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req) as r:
raw = r.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
sys.exit(f"{method} {path} -> {e.code} {e.read()[:200]!r}")
ak = lambda m, p, b=None: call(AK, "Bearer", AK_TOKEN, m, p, b)
gt = lambda m, p, b=None: call(GT, "token", GT_TOKEN, m, p, b)
ak_users = lambda: {u["attributes"].get("employee_id"): u
for u in ak("GET", f"/core/users/?path={PATH}&page_size=500")["results"]}
def group_pk(name, cache={}):
if name not in cache:
found = ak("GET", f"/core/groups/?name={name}")["results"]
cache[name] = found[0]["pk"] if found else ak("POST", "/core/groups/", {"name": name})["pk"]
return cache[name]
def team_id(name, cache={}):
if not cache:
cache.update({t["name"]: t["id"] for t in gt("GET", f"/orgs/{ORG}/teams")})
return cache[name]
def joiner(row, apply):
role = ROLES[row["department"]]
print(f"JOINER {row['username']:<6} {row['department']:<12} groups={role['groups']} team={role['team']}")
if not apply: return
user = ak("POST", "/core/users/", {"username": row["username"], "name": row["full_name"], "email": row["email"],
"path": PATH, "type": "internal", "is_active": True,
"attributes": {"employee_id": row["employee_id"], "department": row["department"]}})
for g in role["groups"]:
ak("POST", f"/core/groups/{group_pk(g)}/add_user/", {"pk": user["pk"]})
# Gitea has no SCIM: create the account through its admin API, with a random password nobody receives
gt("POST", "/admin/users", {"username": row["username"], "email": row["email"], "full_name": row["full_name"],
"password": secrets.token_urlsafe(24), "must_change_password": False, "send_notify": False})
gt("PUT", f"/teams/{team_id(role['team'])}/members/{row['username']}")
def mover(row, user, apply):
old, new = ROLES[user["attributes"]["department"]], ROLES[row["department"]]
drop = [g for g in old["groups"] if g not in new["groups"]]
add = [g for g in new["groups"] if g not in old["groups"]]
print(f"MOVER {row['username']:<6} {user['attributes']['department']} -> {row['department']}"
f" remove={drop} add={add} team {old['team']} -> {new['team']}")
if not apply: return
for g in drop: ak("POST", f"/core/groups/{group_pk(g)}/remove_user/", {"pk": user["pk"]})
for g in add: ak("POST", f"/core/groups/{group_pk(g)}/add_user/", {"pk": user["pk"]})
ak("PATCH", f"/core/users/{user['pk']}/", {"attributes": {**user["attributes"], "department": row["department"]}})
if old["team"] != new["team"]:
gt("DELETE", f"/teams/{team_id(old['team'])}/members/{row['username']}")
gt("PUT", f"/teams/{team_id(new['team'])}/members/{row['username']}")
def leaver(row, user, apply):
name = row["username"]
tokens = ak("GET", f"/core/tokens/?user__username={name}")["results"]
sessions = ak("GET", f"/core/authenticated_sessions/?user__username={name}")["results"]
gacct = [u for u in gt("GET", "/admin/users?limit=50") if u["login"] == name][0]
keys = gt("GET", f"/users/{name}/keys")
repos = [r["full_name"] for r in gt("GET", f"/users/{name}/repos")]
team = ROLES[user["attributes"]["department"]]["team"]
if not (user["is_active"] or tokens or sessions or not gacct["prohibit_login"] or keys):
return # already closed everywhere
print(f"LEAVER {name:<6} authentik: active={user['is_active']} sessions={len(sessions)} tokens={len(tokens)}"
f" | gitea: login_allowed={not gacct['prohibit_login']} ssh_keys={len(keys)} repos={repos}")
if not apply: return
ak("PATCH", f"/core/users/{user['pk']}/", {"is_active": False})
for s in sessions: ak("DELETE", f"/core/authenticated_sessions/{s['uuid']}/")
for t in tokens: ak("DELETE", f"/core/tokens/{t['identifier']}/")
gt("PATCH", f"/admin/users/{name}", {"login_name": name, "source_id": 0, "active": False, "prohibit_login": True})
for k in keys: gt("DELETE", f"/admin/users/{name}/keys/{k['id']}")
gt("DELETE", f"/teams/{team_id(team)}/members/{name}")
for r in repos: print(f" TRANSFER {r}: the manager decides (POST /repos/{r}/transfer)")
def sync(path, apply):
rows = list(csv.DictReader(open(path, encoding="utf-8")))
users = ak_users()
for row in rows:
user = users.get(row["employee_id"])
if row["status"] == "active" and user is None:
joiner(row, apply)
elif row["status"] == "active" and user["attributes"].get("department") != row["department"]:
mover(row, user, apply)
elif row["status"] == "terminated" and user:
leaver(row, user, apply)
# Orphans: active accounts that no HR record owns. Report them; a person decides.
in_hr = {r["employee_id"] for r in rows}
users = ak_users()
for emp, u in users.items():
if emp not in in_hr and u["is_active"]:
print(f"ORPHAN authentik:{u['username']} (employee_id {emp} is not in the HR export)")
known = {u["username"] for u in users.values()} | {"itadmin"}
for g in gt("GET", "/admin/users?limit=50"):
if g["login"] not in known and not g["prohibit_login"]:
print(f"ORPHAN gitea:{g['login']} (no identity in authentik, created {g['created'][:10]})")
print("applied" if apply else "dry run: nothing changed (add --apply)")
def review():
users = {u["pk"]: u for u in ak_users().values()}
for g in sorted({g for r in ROLES.values() for g in r["groups"]}):
print(f"[{g}]")
for pk in ak("GET", f"/core/groups/{group_pk(g)}/")["users"]:
u = users.get(pk)
if not u or not u["is_active"]: continue
dept = u["attributes"].get("department")
ok = g in ROLES.get(dept, {}).get("groups", [])
print(f" {u['username']:<6} {dept:<12} last_login={(u['last_login'] or 'never')[:16]:<16}"
f" {'ok' if ok else 'NOT IN ROLE: remove it or record an exception'}")
if "--review" in sys.argv: review()
else: sync(sys.argv[1], "--apply" in sys.argv)في الكود أعلاه لاحظ التالي:
- القاموس
ROLESهو جدول الأدوار، فلا توجد في السكربت صلاحية لشخص بعينه، وإنما لقسم. - الحسابات التي يديرها السكربت في authentik تحت المسار
employees، وهي ميزة المسارات Paths في authentik، فلا يلمس السكربت حساب المدير ولا حسابات الخدمة، ويحفظ معرف الموظف والقسم في خصائص المستخدمattributes. - السكربت يقارن الحالة الكاملة في كل تشغيل، فإذا فشل تشغيل أو فاته تغيير فإن التشغيل التالي يصلحه، وإذا شغلته مرتين فلن يفعل شيئاً في المرة الثانية.
- دون الخيار
--applyيطبع السكربت الخطة فقط، وهذا ما تريده قبل أول تشغيل على بيانات حقيقية. - الحسابات التي لا يقابلها موظف تظهر في التقرير بكلمة
ORPHANولا يعطلها السكربت وحده، والسبب أن الحساب الذي لا يعرفه السكربت قد يكون حساب طوارئ أو حساب خدمة، فيقرر فيه شخص. - في Gitea ينشئ السكربت حساباً محلياً بكلمة مرور عشوائية لا يعرفها أحد، وفي بيئة حقيقية مربوطة بـ authentik كما في ربط Gitea مع authentik تضع في الطلب نفسه الحقلين
source_idوlogin_nameليرتبط الحساب بمصدر الدخول الموحد.
الخطوة 3: التعيين
نبدأ بالتشغيل دون --apply لنرى الخطة، ثم نطبقها، ثم نشغله مرة ثالثة:
export AK_TOKEN=lab-api-token-3c9f7a2e GITEA_TOKEN=$(cat gitea_admin_token)
python3 jml.py hr.csv
python3 jml.py hr.csv --apply
python3 jml.py hr.csvوالمخرج سوف يكون كما يلي:
$ python3 jml.py hr.csv
JOINER sara Finance groups=['finance'] team=finance
JOINER omar Engineering groups=['engineering', 'vpn-users'] team=engineering
dry run: nothing changed (add --apply)
$ python3 jml.py hr.csv --apply
JOINER sara Finance groups=['finance'] team=finance
JOINER omar Engineering groups=['engineering', 'vpn-users'] team=engineering
applied
$ python3 jml.py hr.csv
dry run: nothing changed (add --apply)لاحظ أن التشغيل الثالث لم يجد شيئاً يفعله، وهذا ما نعنيه بأن السكربت يقارن الحالة وليس الأحداث. وهذا ما أصبح في authentik:
$ curl -s "${H[@]}" "$AK/core/users/?path=employees" | python3 -c "import sys,json; [print(u['username'], u['is_active'], u['attributes'], [g['name'] for g in u['groups_obj']]) for u in json.load(sys.stdin)['results']]"
omar True {'department': 'Engineering', 'employee_id': '1043'} ['engineering', 'vpn-users']
sara True {'department': 'Finance', 'employee_id': '1042'} ['finance']الخطوة 4: سارة تعمل، وتجمع المفاتيح
الآن نفعل ما يفعله أي موظف خلال أسابيعه الأولى: تدخل إلى authentik من المتصفح، وتنشئ توكن API لسكربت تقارير، وتنشئ توكن في Gitea لعمليات git، وتضيف مفتاح SSH، وتنشئ مستودعاً خاصاً بها. وفي التجربة وضعنا لها كلمة المرور عبر الواجهة البرمجية لنختصر، أما في الواقع فهي تضعها بنفسها عبر رابط التسجيل:
AK=http://127.0.0.1:19400/api/v3; GT=http://127.0.0.1:19430/api/v1
H=(-H "Authorization: Bearer $AK_TOKEN" -H "Content-Type: application/json")
PK=$(curl -s "${H[@]}" "$AK/core/users/?username=sara" | python3 -c "import sys,json;print(json.load(sys.stdin)['results'][0]['pk'])")
curl -s -o /dev/null "${H[@]}" -X POST $AK/core/users/$PK/set_password/ -d '{"password":"Sara-lab-pass-4411"}'
# sara: a browser session, an API token, a Gitea token, an SSH key and her own repository
python3 session.py login sara Sara-lab-pass-4411
curl -s -o /dev/null "${H[@]}" -X POST $AK/core/tokens/ -d "{\"identifier\":\"sara-report-script\",\"intent\":\"api\",\"user\":$PK,\"expiring\":false}"
curl -s "${H[@]}" $AK/core/tokens/sara-report-script/view_key/ | python3 -c "import sys,json;print(json.load(sys.stdin)['key'])" > sara_ak_token
docker exec -u git lab-jml-gitea gitea admin user generate-access-token --username sara --token-name laptop --scopes all --raw > sara_gitea_token
ssh-keygen -t ed25519 -N '' -C sara@laptop -f sara_key -q
ST=$(cat sara_gitea_token)
curl -s -o /dev/null -H "Authorization: token $ST" -H "Content-Type: application/json" -X POST $GT/user/keys -d "{\"title\":\"sara-laptop\",\"key\":\"$(cat sara_key.pub)\"}"
curl -s -o /dev/null -H "Authorization: token $ST" -H "Content-Type: application/json" -X POST $GT/user/repos -d '{"name":"budget-scripts","private":true,"auto_init":true}'والسكربت session.py يمثل متصفح سارة، فيدخل عبر واجهة تنفيذ التدفقات Flow Executor في authentik ويحفظ ملف تعريف الارتباط Cookie للجلسة، ثم يستخدمه لاحقاً ليسأل «هل ما زالت جلستي صالحة؟»:
"""python3 session.py login USER PASS | python3 session.py check (keeps the authentik session cookie in ak.cookies)"""
import json, sys, urllib.request, urllib.error, http.cookiejar
base = "http://127.0.0.1:19400"
jar = http.cookiejar.LWPCookieJar("ak.cookies")
if sys.argv[1] == "check": jar.load(ignore_discard=True)
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def req(m, p, body=None):
r = urllib.request.Request(base + p, method=m, data=json.dumps(body).encode() if body else None,
headers={"Content-Type": "application/json"})
try:
with op.open(r) as x: return x.status, json.loads(x.read() or b"null")
except urllib.error.HTTPError as e: return e.code, json.loads(e.read() or b"null")
if sys.argv[1] == "login":
flow = "/api/v3/flows/executor/default-authentication-flow/?query="
req("GET", flow)
req("POST", flow, {"component": "ak-stage-identification", "uid_field": sys.argv[2]})
status, r = req("POST", flow, {"component": "ak-stage-password", "password": sys.argv[3]})
print("login:", status, r.get("component"))
jar.save(ignore_discard=True)
status, me = req("GET", "/api/v3/core/users/me/")
print("session /me:", status, me["user"]["username"] if status == 200 else me)والسكربت check.sh يجرب كل مفتاح تملكه سارة من جهتها، وسوف نشغله بعد كل خطوة:
#!/usr/bin/env bash
# What can sara still reach? Every door she holds a key to, tested from her side.
AK=http://127.0.0.1:19400/api/v3
GT=http://127.0.0.1:19430/api/v1
SSH="ssh -i ./sara_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
echo "authentik session (browser cookie) $(python3 session.py check | cut -d' ' -f3)"
echo "authentik API token $(code -H "Authorization: Bearer $(cat sara_ak_token)" $AK/core/users/me/)"
for repo in finance-reports platform; do
printf 'gitea token -> acme/%-16s %s\n' "$repo" "$(code -H "Authorization: token $(cat sara_gitea_token)" $GT/repos/acme/$repo)"
done
for repo in finance-reports platform; do
if GIT_SSH_COMMAND="$SSH" git ls-remote ssh://[email protected]:19432/acme/$repo.git >/dev/null 2>&1
then r=allowed; else r=denied; fi
printf 'ssh key -> acme/%-16s %s\n' "$repo" "$r"
doneوالمخرج بعد التعيين سوف يكون كما يلي:
$ python3 session.py login sara Sara-lab-pass-4411
login: 200 xak-flow-redirect
session /me: 200 sara
$ bash check.sh
authentik session (browser cookie) 200
authentik API token 200
gitea token -> acme/finance-reports 200
gitea token -> acme/platform 404
ssh key -> acme/finance-reports allowed
ssh key -> acme/platform deniedسارة في المالية، فتصل إلى finance-reports بالتوكن وبمفتاح SSH، ولا تصل إلى platform. ولاحظ أن Gitea يرد بالرمز 404 وليس 403 على المستودع الخاص الذي لا تملك صلاحية عليه، والسبب أنه لا يكشف وجود المستودع لمن لا يملك الوصول إليه.
الخطوة 5: النقل من المالية إلى التطوير
تسجل الموارد البشرية انتقال سارة إلى التطوير، أي يتغير سطر واحد في الملف:
$ cat hr.csv
employee_id,username,full_name,email,department,status
1042,sara,Sara Ahmed,[email protected],Engineering,active
1043,omar,Omar Khalid,[email protected],Engineering,active
$ python3 jml.py hr.csv --apply
MOVER sara Finance -> Engineering remove=['finance'] add=['engineering', 'vpn-users'] team finance -> engineering
applied
$ bash check.sh
authentik session (browser cookie) 200
authentik API token 200
gitea token -> acme/finance-reports 404
gitea token -> acme/platform 200
ssh key -> acme/finance-reports denied
ssh key -> acme/platform allowedفي المخرج أعلاه لاحظ التالي:
- السكربت حسب الفرق بين الدورين، فأزال
financeوأضافengineeringوvpn-users، ونقلها من فريق المالية إلى فريق التطوير في Gitea. - التوكن نفسه ومفتاح SSH نفسه لم يتغيرا، ولكن ما يصلان إليه تغير، فأصبح
finance-reportsيرد بـ404وplatformبـ200، أي أن صلاحيات المالية اختفت في الدقيقة نفسها ولم تبق مع سارة.
الخطوة 6: مراجعة الصلاحيات والحسابات التي بلا صاحب
الآن نصنع المشكلتين اللتين تحدث عنهما المقال: مدير يضيف عمر إلى مجموعة finance «مؤقتاً» من لوحة authentik مباشرة، وزميل ينشئ في Gitea حساباً لمتعاقد خارج العملية:
FIN=$(curl -s "${H[@]}" "$AK/core/groups/?name=finance" | python3 -c "import sys,json;print(json.load(sys.stdin)['results'][0]['pk'])")
OMAR=$(curl -s "${H[@]}" "$AK/core/users/?username=omar" | python3 -c "import sys,json;print(json.load(sys.stdin)['results'][0]['pk'])")
curl -s -o /dev/null -w "omar added to finance by hand: %{http_code}\n" "${H[@]}" -X POST $AK/core/groups/$FIN/add_user/ -d "{\"pk\":$OMAR}"
docker exec -u git lab-jml-gitea gitea admin user create --username contractor1 --password 'lab-contr-pass-2f8e' \
--email [email protected] --must-change-password=false
python3 jml.py --review
python3 jml.py hr.csvوالمخرج سوف يكون كما يلي:
omar added to finance by hand: 204
New user 'contractor1' has been successfully created!
$ python3 jml.py --review
[engineering]
omar Engineering last_login=never ok
sara Engineering last_login=2026-10-08T20:42 ok
[finance]
omar Engineering last_login=never NOT IN ROLE: remove it or record an exception
[vpn-users]
omar Engineering last_login=never ok
sara Engineering last_login=2026-10-08T20:42 ok
$ python3 jml.py hr.csv
ORPHAN gitea:contractor1 (no identity in authentik, created 2026-10-08)
dry run: nothing changed (add --apply)المراجعة وجدت عمر في finance وقسمه التطوير، فهذه عضوية لا يبررها جدول الأدوار، والقرار فيها لمدير المالية: يسحبها أو يسجلها استثناءً بتاريخ انتهاء. والمزامنة وجدت contractor1 في Gitea دون أي هوية في authentik، وهذا بالضبط الحساب الذي لا يغلقه تعطيل أي شخص في مزود الهوية، لأنه لم يمر عليه أصلاً.
الخطوة 7: نعطل سارة في authentik فقط
قبل أن نطبق المغادرة كاملة نجرب الحل البسيط الذي يطبقه أغلب الفرق، أي تعطيل الحساب في مزود الهوية فقط، ثم نرى ما يبقى مفتوحاً:
curl -s -o /dev/null -w "PATCH is_active=false: %{http_code}\n" "${H[@]}" -X PATCH $AK/core/users/$PK/ -d '{"is_active":false}'
bash check.sh
curl -s "${H[@]}" "$AK/core/authenticated_sessions/?user__username=sara" | python3 -c "import sys,json;print('authentik sessions left:',json.load(sys.stdin)['pagination']['count'])"
curl -s "${H[@]}" "$AK/core/tokens/?user__username=sara" | python3 -c "import sys,json;print('authentik tokens left:',[t['identifier'] for t in json.load(sys.stdin)['results']])"والمخرج سوف يكون كما يلي:
PATCH is_active=false: 200
$ bash check.sh
authentik session (browser cookie) 403
authentik API token 403
gitea token -> acme/finance-reports 404
gitea token -> acme/platform 200
ssh key -> acme/finance-reports denied
ssh key -> acme/platform allowed
authentik sessions left: 0
authentik tokens left: ['sara-report-script']وهذه أهم نتيجة في التجربة:
- authentik قام بعمله كاملاً، فألغى جلسة المتصفح فوراً ولم يبق منها شيء، ورفض توكن API بالرمز
403رغم أن التوكن ما زال موجوداً في قائمته. - Gitea لم يسأل authentik عن شيء، فتوكن سارة ما زال يقرأ
platformبالرمز200، ومفتاح SSH ما زال يقرأ المستودع، وفريق التطوير الذي ما زالت عضواً فيه يملك صلاحية الكتابة عليه.
الخطوة 8: المغادرة كاملة
الآن تسجل الموارد البشرية مغادرة سارة، ونشغل السكربت مرة للخطة ومرة للتطبيق، ثم نفحص:
$ cat hr.csv
employee_id,username,full_name,email,department,status
1042,sara,Sara Ahmed,[email protected],Engineering,terminated
1043,omar,Omar Khalid,[email protected],Engineering,active
$ python3 jml.py hr.csv
LEAVER sara authentik: active=False sessions=0 tokens=1 | gitea: login_allowed=True ssh_keys=1 repos=['sara/budget-scripts']
ORPHAN gitea:contractor1 (no identity in authentik, created 2026-10-08)
dry run: nothing changed (add --apply)
$ python3 jml.py hr.csv --apply
LEAVER sara authentik: active=False sessions=0 tokens=1 | gitea: login_allowed=True ssh_keys=1 repos=['sara/budget-scripts']
TRANSFER sara/budget-scripts: the manager decides (POST /repos/sara/budget-scripts/transfer)
ORPHAN gitea:contractor1 (no identity in authentik, created 2026-10-08)
applied
$ bash check.sh
authentik session (browser cookie) 403
authentik API token 403
gitea token -> acme/finance-reports 403
gitea token -> acme/platform 403
ssh key -> acme/finance-reports denied
ssh key -> acme/platform denied
$ python3 jml.py hr.csv
ORPHAN gitea:contractor1 (no identity in authentik, created 2026-10-08)
dry run: nothing changed (add --apply)
$ curl -s -H "Authorization: token $ST" $GT/user
{"message":"This account is prohibited from signing in, please contact your site administrator."}
$ GIT_SSH_COMMAND="ssh -i ./sara_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR" git ls-remote ssh://[email protected]:19432/acme/platform.git
[email protected]: Permission denied (publickey).
fatal: Could not read from remote repository.
Please make sure you have the correct access rights
and the repository exists.
$ curl -s -H "Authorization: token $GITEA_TOKEN" "$GT/admin/users?limit=50" | python3 -c "import sys,json; [print(u['login'], 'active=', u['active'], 'prohibit_login=', u['prohibit_login']) for u in json.load(sys.stdin)]"
contractor1 active= True prohibit_login= False
itadmin active= True prohibit_login= False
omar active= True prohibit_login= False
sara active= False prohibit_login= Trueفي المخرج أعلاه لاحظ التالي:
- السكربت لم يكتف بأن الحساب معطل في authentik، وإنما وجد ما بقي: توكن في authentik، ودخولاً مسموحاً في Gitea، ومفتاح SSH، ومستودعاً تملكه سارة.
- بعد التطبيق أصبح كل باب مغلقاً، وGitea يرد على التوكن بالرمز
403ومعه رسالة واضحة، والسبب أن السكربت جعل الحسابprohibit_loginوهو حقل في واجهة إدارة المستخدمين يمنع كل طرق الدخول، ثم حذف مفتاح SSH وأخرجها من الفريق. - المستودع
sara/budget-scriptsلم يحذف ولم ينقل تلقائياً، وإنما ظهر في سطرTRANSFERليقرر المدير أين ينقله عبرPOST /repos/{owner}/{repo}/transfer، والسبب أن نقل الملكية قرار يحتاج إلى شخص يعرف ما في المستودع. - الحساب في Gitea معطل وليس محذوفاً، فيبقى سجل من فعل ماذا، ويحذف بعد مدة الاحتفاظ.
- الحساب
contractor1ما زال يظهر في كل تشغيل حتى يقرر فيه أحد، وهذا هو المقصود.
إزالة التجربة
docker compose down -v
rm -f ak.cookies sara_key sara_key.pub sara_ak_token sara_gitea_token gitea_admin_tokenوالأمر الأول يحذف Containers التجربة الأربع والشبكة lab-jml-net والـ Volumes lab-jml-db وlab-jml-gitea بأسمائها، ولا يلمس أي شيء آخر على جهازك.
الأخطاء الشائعة
- اعتبار تعطيل مزود الهوية نهاية المغادرة: رأينا في التجربة أن التوكن ومفتاح SSH بقيا يعملان، لذلك اجعل قائمة المغادرة تشمل كل تطبيق باسمه.
- الحذف بدل التعطيل: الحذف في يوم المغادرة يمسح السجلات ويحذف ما يملكه الموظف في بعض التطبيقات، لذلك عطل أولاً وانقل الملكية ثم احذف بعد مدة الاحتفاظ.
- النقل الذي يضيف فقط: كل نقل يحتاج إلى سحب، وإذا لم يكن لديك جدول أدوار فلن تعرف ما الذي تسحبه.
- نسيان المتعاقدين: المتعاقد الذي لا يظهر في الموارد البشرية لا تصل مغادرته إلى أحد، فسجله بموظف مسؤول وتاريخ انتهاء.
- الانتظار حتى آخر يوم في الإنهاء غير الودي: الوصول المميز وحسابات الخدمة تغلق يوم الإبلاغ، كما رأينا في حادثة Vituity.
- الأسرار المشتركة: كل كلمة مرور يعرفها أكثر من شخص تحتاج إلى تدوير عند مغادرة أي منهم، والحل الدائم أن تقلل هذه الأسرار وتضعها في خزنة كما في ضوابط الوصول المميز.
- مراجعة بلا قرارات: تقرير يصدر كل ربع سنة ولا يسحب منه شيء لا يحمي أحداً، فاجعل لكل سطر قراراً ومالكاً.
- سكربت مزامنة بصلاحية مدير كامل: السكربت الذي ينشئ الحسابات ويضيفها إلى المجموعات يستطيع أن يضيف نفسه إلى مجموعة المديرين، لذلك أعطه حساب خدمة بأقل صلاحية، واحم التوكن كما تحمي كلمة مرور المدير.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- دورة حياة الحساب ثلاثة أحداث هي التعيين والنقل والمغادرة، ومعها مراجعة دورية، وكل حدث يبدأ من الموارد البشرية لأنها الجهة الوحيدة التي تعرف من يعمل اليوم وفي أي قسم.
- التعيين يعطي صلاحيات الدور من جدول واحد، وأول دخول برابط وليس بكلمة مرور مرسلة، مع MFA من البداية وجهاز مسجل.
- النقل استبدال للدور، يسحب القديم ويضيف الجديد في اليوم نفسه، وما يبقى مؤقتاً له مالك وتاريخ انتهاء، وإلا تراكمت الصلاحيات.
- المغادرة تعطيل في اليوم نفسه، وإغلاق للوصول المميز يوم الإبلاغ، ثم الجلسات والرموز ومفاتيح SSH والأسرار المشتركة والبريد والأجهزة ونقل الملكية، والحذف بعد مدة الاحتفاظ.
- في التجربة ألغى تعطيل authentik الجلسة ورفض التوكن، ولكن توكن Gitea بقي يرد بـ
200ومفتاح SSH بقي يقرأ المستودع، حتى أغلقهما سكربت المزامنة. - الضوابط الأساسية ECC تطلب إلغاء الصلاحيات فور انتهاء العلاقة الوظيفية في 1-9-5، وأقل صلاحية وفصل المهام في 2-2-3-3، والمراجعة الدورية في 2-2-3-5، والمدقق يسأل عن الإجراء المكتوب والدليل على تطبيقه.
دليلإدارة الهوية والوصول IAM والوصول المميز PAMمن يملك صلاحية root على سيرفراتك ومنذ متى، وكيف تضبط حسابات المديرين بصلاحيات مؤقتة وجلسات مسجلة.
دليلما هو SCIM؟ إدارة حسابات الموظفين تلقائياً في كل تطبيقاتكالدخول الموحد SSO يمنع الموظف المغادر من الدخول لكن حساباته تبقى نشطة، وSCIM ينشئ الحسابات ويعطلها في كل تطبيق تلقائياً.
دليلتثبيت authentik للدخول الموحد SSO إلى تطبيقاتككيف تثبت authentik حتى يدخل كل موظف إلى تطبيقات الفريق بحساب واحد، مع فرض المصادقة الثنائية على الجميع.
دليلدخول موحد إلى Gitea بحسابات authentikاجعل موظفيك يدخلون إلى Gitea بحساباتهم في authentik، فتنشأ حساباتهم تلقائياً وتتبع صلاحياتهم المجموعة التي ينتمون إليها.
دليلMDM: إدارة أجهزة الموظفين وتأمينها بأدوات مفتوحةكيف تدير الشركة حواسيب موظفيها وهواتفهم وتفرض عليها التشفير والتحديث، وما الذي تستطيع أن تراه فعلاً على كل جهاز.
دليلBYOD: كيف تحمي بيانات الشركة على أجهزة الموظفين الشخصيةكيف تختار طريقة وصول الموظفين من أجهزتهم الشخصية، وتعرف ما يحق للشركة أن تراه، وتكتب سياسة واضحة لذلك.
دليلالضوابط الأساسية للأمن السيبراني NCA ECC بلغة بسيطةكيف تقرأ الضوابط الأساسية ECC دون أن تضيع في أرقامها، وعلى من تنطبق، وما المطلوب من جهتك فعلاً في كل مكون.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على authentik 2026.8.3 وGitea 1.27.3، ومراجعة الضوابط من نص ECC-2:2024 ومن NIST SP 800-53، والحوادث من بيانات وزارة العدل الأمريكية.