الأدلة التقنية

مستخدم أم مدير؟ إدارة الأدوار عبر تطبيقاتك من authentik

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

مستخدم أم مدير؟ إدارة الأدوار عبر تطبيقاتك من authentik

بعد أن تربط Nextcloud وGitea وGrafana بمزود الهوية Identity Provider الذي ثبتناه في دليل authentik، يصبح الدخول سهلاً، فالموظف يضغط زراً واحداً ويدخل إلى كل التطبيقات بحساب واحد، ولكن يظهر بعد أيام سؤال لم يجب عنه الدخول الموحد SSO: من هو المستخدم العادي ومن هو المدير Admin في كل تطبيق؟ وهل نضبط ذلك في كل تطبيق على حدة أم في authentik مرة واحدة؟ وماذا يحدث داخل التطبيقات عندما نسحب الصلاحية من أحدهم أو يغادر الشركة؟

والإجابة المختصرة أن الصلاحيات Permissions هنا ثلاث طبقات وليست طبقة واحدة: الطبقة الأولى مركزية في authentik وتحدد من يستطيع فتح التطبيق أصلاً، والطبقة الثانية هي دور المستخدم Role داخل التطبيق (مستخدم أو مدير أو مقيد أو قارئ فقط)، وهذه يرسلها authentik في صورة أسماء مجموعات Groups ولكن كل تطبيق يترجمها بطريقته وبإعداده الخاص، والطبقة الثالثة هي الصلاحيات التفصيلية على مستودع أو مجلد أو لوحة مراقبة، وهذه تبقى داخل التطبيق. وقد شرحنا في دليل ربط Gitea مع authentik حالة Gitea وحده، وفي هذا المقال سوف نوسع الصورة إلى عدة تطبيقات ونقيس بالتجربة ما يحدث في كل منها عند كل تغيير.

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

  • الطبقات الثلاث، وما الذي يضبط مركزياً وما الذي يبقى في التطبيق.
  • طريقة تسمية المجموعات، وكيف تتحكم بما يصل إلى كل تطبيق عبر Scope Mappings في authentik.
  • جدول يبين لثلاثة عشر تطبيقاً هل تدعم ربط المجموعات بالأدوار، ومتى تطبقه، وهل يعمل سحب الدور، وهل الميزة مجانية.
  • تجربة عملية على authentik وGitea وGrafana وNextcloud: نرقي مستخدمة ثم نسحب الدور ثم نخرجها من مجموعات التطبيقات ثم نعطلها، ونسجل ما يفعله كل تطبيق بالجلسات Sessions والتوكنات Tokens.
  • ثلاثة أعطال نصنعها عمداً، ثم خيارات التطبيقات التي لا تفهم المجموعات، ثم مغادرة الموظف والتدقيق Audit.
دليلتثبيت authentik للدخول الموحد SSO إلى تطبيقاتككيف تثبت authentik حتى يدخل كل موظف إلى تطبيقات الفريق بحساب واحد، مع فرض المصادقة الثنائية على الجميع.دليلدخول موحد إلى Gitea بحسابات authentikاجعل موظفيك يدخلون إلى Gitea بحساباتهم في authentik، فتنشأ حساباتهم تلقائياً وتتبع صلاحياتهم المجموعة التي ينتمون إليها.دليلشرح OAuth وOpenID Connect للمبتدئينلماذا لا يجب أن يعرف أي تطبيق كلمة مرورك، وكيف يحل OAuth ذلك بتوكنات محدودة، وكيف يضيف OpenID Connect الدخول الموحد.دليلAuthorization: كيف تجمع قرارات الصلاحيات في مكان واحدلماذا تتحول شروط الصلاحيات المتفرقة في الكود إلى ثغرات، وكيف تنقل القرار إلى محرك سياسات واحد.دليلما هو SCIM؟ إدارة حسابات الموظفين تلقائياً في كل تطبيقاتكالدخول الموحد SSO يمنع الموظف المغادر من الدخول لكن حساباته تبقى نشطة، وSCIM ينشئ الحسابات ويعطلها في كل تطبيق تلقائياً.دليلدورة حياة حساب الموظف JML من أول يوم إلى آخر يومكيف تجعل الموارد البشرية مصدر كل حساب، فيجد الموظف ما يحتاجه في يومه الأول وتغلق كل أبوابه في يوم مغادرته.

ثلاث طبقات: من يدخل، وبأي دور، وعلى ماذا؟

الحل البسيط الذي يبدأ به أغلب الفرق هو أن يدخل المدير إلى كل تطبيق بعد أن ينشئ authentik حساب الموظف فيه، ثم يرفعه يدوياً إلى مدير في Gitea ومحرر في Grafana وعضو في مجموعة المالية في Nextcloud، وهذا يعمل في الأسبوع الأول، ولكنه يفشل مع الوقت، والسبب أن الصلاحية أصبحت موزعة على خمسة أماكن لا يعرف أحدها عن الآخر، فعندما ينتقل الموظف إلى قسم آخر لا يتذكر أحد أن يسحب منه صلاحية المدير في Grafana، وهذا بالضبط تراكم الصلاحيات Privilege Creep الذي شرحناه في دورة حياة حساب الموظف JML.

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

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

الطبقة الأولى: من يستطيع فتح التطبيق؟

هذه الطبقة مركزية بالكامل، وتضبطها من تبويب Policy / Group / User Bindings في صفحة التطبيق Application داخل authentik، حيث تربط التطبيق بمجموعة أو مستخدم أو سياسة Policy، فمن لا ينطبق عليه أي ربط يرى صفحة «Permission denied» في authentik ولا يصل إلى التطبيق أصلاً، وبالتالي لا ينشئ له التطبيق حساباً. ويجدر الإشارة هنا إلى قاعدتين من توثيق إدارة التطبيقات:

  • التطبيق الذي لا يملك أي ربط Binding متاح لكل المستخدمين، وهذا هو الافتراضي، لذلك اربط كل تطبيق بمجموعاته قبل أن تعلن عنه للموظفين.
  • عندما تربط أكثر من مجموعة بالتطبيق نفسه فإن وضع محرك السياسات Policy Engine Mode يحدد النتيجة: any يكفيه أن ينجح ربط واحد، وall يشترط نجاح كل الروابط. ونحن نريد any، فعضو مجموعة المستخدمين أو المديرين أو المقيدين يدخل.

الطبقة الثانية: ما دوره داخل التطبيق؟

هنا يقع أغلب الالتباس، فالدور يقرره authentik لأنه هو من يضع المستخدم في مجموعة app-gitea-admins، ولكن التطبيق هو من ينفذه، فهو يقرأ claim باسم groups في التوكن ويقارنه بإعداد عنده، وهذا الإعداد يختلف من تطبيق إلى آخر: Gitea يملك حقلاً لمجموعة المديرين وحقلاً لمجموعة المقيدين، وGrafana يقيم تعبير JMESPath يعيد اسم الدور، وNextcloud ينشئ مجموعاته من القائمة ويجعل من في مجموعة اسمها admin مديراً. وبالتالي فالجواب عن سؤال «مركزياً أم في كل تطبيق؟» هو: العضوية مركزية، والترجمة في كل تطبيق، وتضبط الترجمة مرة واحدة عند ربط التطبيق ثم لا تلمسها.

الطبقة الثالثة: على ماذا يعمل داخل التطبيق؟

من يقرأ مستودع finance-reports، ومن يرى مجلد العقود، ومن يعدل لوحة مراقبة قاعدة البيانات، كل هذا يبقى في التطبيق عبر الفرق Teams والمشاركات Shares والمجلدات، وبعض التطبيقات تستطيع أن تبني فرقها من المجموعات نفسها، مثل حقل Map claimed groups to Organization teams في Gitea، ولكن القرار النهائي على المورد Resource يبقى عند التطبيق. وإذا كنت تبني تطبيقك الخاص وتريد أن تنقل هذه الطبقة إلى محرك مركزي، فهذا موضوع الصلاحيات المركزية Authorization.

كيف تسمي المجموعات؟

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

المجموعةمعناهامن يستخدمها
app-<app>-usersيدخل التطبيق بالدور العاديربط التطبيق في authentik
app-<app>-adminsمدير التطبيقالربط، وإعداد الدور في التطبيق
app-<app>-restricted أو -viewersدور محدود: مقيد في Gitea، أو قارئ فقطالربط، وإعداد الدور في التطبيق
app-grafana-editorsدور خاص بتطبيق واحدعند الحاجة فقط

وفي هذه القاعدة لاحظ التالي:

  • البادئة Prefix app- تسمح لنا بأن نرسل إلى التطبيق مجموعاته وحدها كما سيأتي، وتجعل مراجعة الصلاحيات Access Review سهلة، فكل من في مجموعة تبدأ بـ app-gitea- له علاقة بـ Gitea.
  • الأسماء بحروف صغيرة وبشرطة عادية بلا مسافات، والسبب أن بعض التطبيقات تحول الأسماء قبل المقارنة، فمثلاً BookStack يحول اسم المجموعة إلى حروف صغيرة ويستبدل المسافات بشرطات قبل أن يطابقه مع اسم الدور.
  • لا تقم بتسمية مجموعة admin أو admins بلا اسم تطبيق، والسبب أن Nextcloud مثلاً يجعل كل من يصله اسم المجموعة admin مديراً للنظام كله، كما سوف نرى في التجربة.
  • لكل مجموعة صاحب Owner يوافق على الإضافة إليها، وهو عادة مسؤول التطبيق، وليس مسؤول authentik.
💡
يملك authentik منذ الإصدار 2024.12 طريقة أخرى للطبقة الثانية اسمها صلاحيات التطبيق Application Entitlements، وهي أدوار تعرفها داخل التطبيق في authentik نفسه وتربطها بمستخدمين أو مجموعات، ثم يرسلها نطاق entitlements في claim باسم entitlements وroles. وميزتها أنها تحذف مع التطبيق ولا تملأ قائمة المجموعات العامة، ولكن كل تطبيق في الجدول أدناه يقرأ claim واحداً للمجموعات، لذلك اخترنا المجموعات في هذا المقال، وإذا استخدمت Entitlements فاجعل التطبيق يقرأ roles بدلاً من groups.

ما الذي يصل إلى كل تطبيق؟ Scope Mappings في authentik

كل مزود OAuth2/OpenID Provider في authentik يملك قائمة ربط النطاقات Scope Mappings، وكل ربط منها هو كود Python صغير يعيد الحقول Claims التي تضاف إلى التوكن عندما يطلب التطبيق ذلك النطاق Scope. والربط الافتراضي لنطاق profile في authentik 2026.8.3 هو التالي، كما قرأناه من الواجهة البرمجية API في التجربة:

avatar = request.user.avatar
return delete_none_values({
    "name": request.user.name,
    "given_name": ak_obj_attr(request.user, "given_name", "name"),
    "family_name": ak_obj_attr(request.user, "family_name"),
    "preferred_username": request.user.username,
    "nickname": request.user.username,
    "groups": [group.name for group in request.user.groups.all()],
    # Only expose URL-based avatars: generated avatars are inline base64
    # SVG data URIs, which would bloat the ID/access token for every
    # user without a real avatar URL (OIDC expects a URL here).
    "picture": avatar if avatar and not avatar.startswith("data:") else None,
})

لاحظ السطر groups: كل تطبيق يطلب profile يستلم أسماء كل مجموعات المستخدم، وهذا يكفي لكي يعمل الربط مع Gitea وGrafana دون أي إعداد إضافي، ولكن له عيبان: الأول أن أسماء مجموعات مثل hr-investigations تصل إلى تطبيقات لا علاقة لها بها، والثاني أن التطبيق قد يفهم اسماً لم تقصده، وهذا ما يحدث مع Nextcloud. والصورة التالية تبين الفرق:

رسم يبين مستخدمة واحدة في authentik وثلاثة مزودين: مزودا Gitea وGrafana يرسلان كل أسماء المجموعات عبر نطاق profile، ومزود Nextcloud يرسل عبر نطاق مخصص مجموعات Nextcloud وحدها مع تحويل مجموعة المديرين إلى admin
النطاق المخصص يرسل إلى التطبيق مجموعاته وحدها وبالاسم الذي يفهمه

لذلك أنشأنا لـ Nextcloud ربطاً مخصصاً من Customization ← Property Mappings ← Scope Mapping، باسم نطاق nextcloud، والتعبير Expression فيه كما يلي، وتجد المتغيرات المتاحة فيه في توثيق تعبيرات الربط:

# Send only the Nextcloud groups, and translate the admin group to Nextcloud's own "admin" group
names = [g.name for g in request.user.groups.all() if g.name.startswith("app-nextcloud-")]
return {"nc_groups": ["admin" if n == "app-nextcloud-admins" else n for n in names]}

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

  • اسم الـ claim هنا nc_groups وليس groups، والسبب أن المزود نفسه يرسل profile أيضاً، فلو استخدمنا الاسم نفسه لأصبح لدينا ربطان يكتبان الحقل نفسه، وسوف نخبر Nextcloud أن يقرأ nc_groups.
  • الترجمة من app-nextcloud-admins إلى admin تحدث في authentik، فلا يحتاج أحد إلى أن يسمي مجموعة في authentik باسم admin.
  • في الإصدارات القديمة من authentik كانت الأمثلة تستخدم request.user.ak_groups، أما الربط الافتراضي في 2026.8 فيستخدم request.user.groups كما ترى أعلاه، لذلك انسخ الصيغة من الربط الافتراضي في نسختك قبل أن تكتب ربطاً جديداً.

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

ماذا يدعم كل تطبيق؟

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

التطبيقربط المجموعات بالأدواركل دخول؟سحب الدورمجاني؟
Gitea 28.1مجموعة للمديرين وأخرى للمقيدين، وربط المجموعات بفرق المنظماتنعم (اختبرناه)نعم (اختبرناه)نعم
Grafana 13.2role_attribute_path يعيد Viewer أو Editor أو Admin، وorg_mapping للمؤسساتنعم ما لم تفعل skip_org_role_sync (اختبرناه)نعم (اختبرناه)الأدوار نعم، أما Team Sync ففي Enterprise
Nextcloud مع user_oidc 8.11ينشئ مجموعات Nextcloud من claim، ومن في admin مديرنعم (اختبرناه)نعم (اختبرناه)نعم
BookStackOIDC_USER_TO_GROUPS يطابق المجموعات مع أسماء الأدوار أو حقل External Authentication IDsنعمفقط مع OIDC_REMOVE_FROM_GROUPS=true، وقيمته الافتراضية falseنعم
Proxmox VE 8.4 وما بعدهgroups-claim يملأ مجموعات Proxmox، والأدوار تأتي من ACL على المجموعاتعند الدخولفقط مع groups-overwriteنعم
OpenSearch Dashboardsroles_key يقرأ الأدوار من التوكن، ثم roles_mappingمن التوكن نفسهلم نتحقق منهنعم
Guacamole 1.6claim المجموعات يطابق مجموعات قاعدة البيانات بالاسم، والصلاحيات على مجموعة القاعدةمع كل توكنلم نتحقق منهنعم
Portainerربط الفرق وصلاحية المدير من claims في Business Edition، أما CE فيضع المستخدم في فريق افتراضي فقطلم نتحقق منهلم نتحقق منهBusiness Edition
MattermostOpenID Connect في خطط Entry وما فوقها وليس في Team Edition، ولا يوجد حقل للمدير مع OIDC، وإنما مع SAML وLDAPلا ينطبقلا ينطبقEntry مجانية بحدود
Outlineلا يوجد إعداد للأدوار أو المجموعات مع OIDCلا ينطبقلا ينطبقلا ينطبق
Moodle مع auth_oidcلا يوجد، فالحقول تملأ الملف الشخصي فقطلا ينطبقلا ينطبقنعم
Seafileلا يوجد مع OAuth، وخيارات الأدوار مع SAML وLDAP في الإصدار Proلا ينطبقلا ينطبقPro
Vaultwarden 1.35 وما بعدهدخول OIDC فقط، بلا ربط للمؤسسات أو الأدوارلا ينطبقلا ينطبقنعم

والمصادر بالترتيب: كود مزامنة الدخول في Gitea وأوامر Gitea، وGeneric OAuth في Grafana، وكود التجهيز في user_oidc، وOIDC في BookStack، وإدارة المستخدمين في Proxmox VE، وOpenID Connect في OpenSearch، وOpenID في Guacamole، وOAuth في Portainer، وOpenID Connect في Mattermost، وOIDC في Outline، وإضافة auth_oidc، وOAuth في Seafile، وSSO في Vaultwarden.

ولاحظ النمط المتكرر: التطبيق الذي لا يفهم المجموعات يبقى قادراً على استخدام الطبقة الأولى، فمن ليس في app-outline-users لا يدخل Outline أصلاً، ولكن الطبقة الثانية فيه تدار يدوياً داخل التطبيق، وسوف نعود إلى هذه التطبيقات في قسم التطبيقات التي لا تفهم المجموعات.

تجربة عملية: نرقي ونسحب ونعطل، ونراقب ثلاثة تطبيقات

الآن سوف نقيس كل ما سبق بأنفسنا، فنشغل authentik وGitea وGrafana وNextcloud، وننشئ المستخدمة layla، ثم نغير مجموعاتها في authentik خطوة بعد خطوة، وبعد كل خطوة نسأل كل تطبيق: ماذا تعرف عنها الآن؟ وهل جلستها المفتوحة ما زالت تعمل؟ وهل توكنها ما زال يعمل؟

الخطوة 1: التشغيل

الملف التالي يشغل التطبيقات الأربعة مع قاعدة بيانات authentik، وContainer سادس باسم lab-sso-client يقوم بدور المتصفح، وكل المنافذ مربوطة بالعنوان 127.0.0.1:

name: lab-sso
services:
  db:
    image: postgres:16-alpine
    container_name: lab-sso-db
    environment:
      POSTGRES_USER: authentik
      POSTGRES_PASSWORD: lab-db-pass-4e7a
      POSTGRES_DB: authentik
    volumes: [lab-sso-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-sso-authentik
    command: server
    environment: &ak
      AUTHENTIK_SECRET_KEY: lab-secret-key-change-me-7c1e9b3d5f2a8e4c6b0d
      AUTHENTIK_POSTGRESQL__HOST: db
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: lab-db-pass-4e7a
      AUTHENTIK_BOOTSTRAP_PASSWORD: lab-admin-pass-2b8d
      AUTHENTIK_BOOTSTRAP_TOKEN: lab-api-token-9d4e1f
      AUTHENTIK_BOOTSTRAP_EMAIL: [email protected]
    ports: ["127.0.0.1:19500:9000"]
    depends_on: { db: { condition: service_healthy } }
  worker:
    image: ghcr.io/goauthentik/server:2026.8.3
    container_name: lab-sso-worker
    command: worker
    environment: *ak
    depends_on: { db: { condition: service_healthy } }
  gitea:
    image: gitea/gitea:28.1.0
    container_name: lab-sso-gitea
    environment:
      GITEA__database__DB_TYPE: sqlite3
      GITEA__security__INSTALL_LOCK: "true"
      GITEA__server__ROOT_URL: http://lab-sso-gitea:3000/
      GITEA__service__DISABLE_REGISTRATION: "true"
      GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION: "true"
      GITEA__oauth2_client__USERNAME: preferred_username
      GITEA__oauth2_client__ACCOUNT_LINKING: login
      GITEA__oauth2_client__OPENID_CONNECT_SCOPES: "email profile"
    ports: ["127.0.0.1:19530:3000"]
    volumes: [lab-sso-gitea:/data]
  grafana:
    image: grafana/grafana:13.2.3
    container_name: lab-sso-grafana
    environment:
      GF_SERVER_ROOT_URL: http://lab-sso-grafana:3000/
      GF_SECURITY_ADMIN_USER: localadmin
      GF_SECURITY_ADMIN_PASSWORD: lab-grafana-pass-6a1c
      GF_AUTH_GENERIC_OAUTH_ENABLED: "true"
      GF_AUTH_GENERIC_OAUTH_NAME: authentik
      GF_AUTH_GENERIC_OAUTH_CLIENT_ID: grafana
      GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: lab-grafana-oidc-secret-3f9b
      GF_AUTH_GENERIC_OAUTH_SCOPES: ${GF_SCOPES:-openid email profile}
      GF_AUTH_GENERIC_OAUTH_AUTH_URL: http://lab-sso-authentik:9000/application/o/authorize/
      GF_AUTH_GENERIC_OAUTH_TOKEN_URL: http://lab-sso-authentik:9000/application/o/token/
      GF_AUTH_GENERIC_OAUTH_API_URL: http://lab-sso-authentik:9000/application/o/userinfo/
      GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH: preferred_username
      GF_AUTH_GENERIC_OAUTH_GROUPS_ATTRIBUTE_PATH: groups
      GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH: "contains(groups, 'app-grafana-admins') && 'Admin' || contains(groups, 'app-grafana-editors') && 'Editor' || contains(groups, 'app-grafana-users') && 'Viewer'"
      GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT: "true"
      GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN: "false"
      GF_AUTH_GENERIC_OAUTH_SKIP_ORG_ROLE_SYNC: ${GF_SKIP_ORG_ROLE_SYNC:-false}
      GF_AUTH_GENERIC_OAUTH_USE_REFRESH_TOKEN: ${GF_USE_REFRESH_TOKEN:-false}
    ports: ["127.0.0.1:19540:3000"]
    volumes: [lab-sso-grafana:/var/lib/grafana]
  nextcloud:
    image: nextcloud:35.0.1-apache
    container_name: lab-sso-nextcloud
    environment:
      SQLITE_DATABASE: nextcloud
      NEXTCLOUD_ADMIN_USER: localadmin
      NEXTCLOUD_ADMIN_PASSWORD: lab-nextcloud-pass-8e2f
      NEXTCLOUD_TRUSTED_DOMAINS: lab-sso-nextcloud 127.0.0.1
    ports: ["127.0.0.1:19550:80"]
    volumes: [lab-sso-nextcloud:/var/www/html]
  client:
    image: python:3.13-slim
    container_name: lab-sso-client
    command: sleep infinity
    working_dir: /lab
    volumes: ["./:/lab"]
volumes:
  lab-sso-db: { name: lab-sso-db }
  lab-sso-gitea: { name: lab-sso-gitea }
  lab-sso-grafana: { name: lab-sso-grafana }
  lab-sso-nextcloud: { name: lab-sso-nextcloud }
networks:
  default:
    name: lab-sso-net

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

  • كل العناوين داخلية مثل http://lab-sso-authentik:9000، والسبب أن التطبيق يجلب التوكن من authentik عبر الشبكة الداخلية، والمتصفح يجب أن يرى العنوان نفسه حتى يتطابق الـ issuer، لذلك يعمل «المتصفح» في التجربة داخل الشبكة نفسها كما سيأتي. وفي بيئة حقيقية يكون العنوان هو النطاق العام مثل https://auth.example.com للطرفين.
  • تعبير ROLE_ATTRIBUTE_PATH في Grafana يقرأ من الأعلى إلى الأسفل، فالمدير يأخذ Admin حتى لو كان في مجموعة المستخدمين أيضاً، ومع ROLE_ATTRIBUTE_STRICT يرفض Grafana الدخول إذا لم يعد التعبير دوراً صالحاً. وأما GROUPS_ATTRIBUTE_PATH فيستخدمه Team Sync، وهو في Grafana Enterprise.
  • القيمتان ${GF_SKIP_ORG_ROLE_SYNC} و${GF_USE_REFRESH_TOKEN} معطلتان افتراضياً، وسوف نفعلهما في خطوات لاحقة.
  • لكل تطبيق حساب مدير محلي باسم localadmin لا يمر على authentik، وهو حساب الطوارئ Break-glass، وكلمات المرور هنا للتجربة فقط.
docker compose up -d
docker exec lab-sso-client pip install -q requests
⚠️
في تجربتنا أنهى authentik تهيئة قاعدة البيانات Migrations بعد عدة دقائق، ولكن بعض الإعدادات المضمنة Blueprints بقيت بحالة unknown ولم يظهر تدفق default-provider-authorization-implicit-consent، فطبقناها يدوياً بالأمر docker exec lab-sso-worker ak apply_blueprint default/flow-default-provider-authorization-implicit-consent.yaml. وإذا أنشأت المزود عبر الـ API فأرسل الحقل grant_types صراحة، والسبب أن المزود الذي أنشأناه بدونه رد على Gitea بالخطأ «The request is otherwise malformed»، وفي السجل «Invalid grant_type for provider».

الخطوة 2: المجموعات والمزودون في authentik

السكربت setup_authentik.py ينشئ عبر واجهة authentik المجموعات الثماني، والمستخدمة layla في مجموعات المستخدمين الثلاث، وربط نطاق Nextcloud الذي رأيناه أعلاه، ثم لكل تطبيق مزوداً وتطبيقاً وربطاً بمجموعاته. وهذا هو الجزء الذي يتكرر لكل تطبيق:

for slug, (secret, uris, maps) in APPS.items():
    prov = post("/providers/oauth2/", {
        "name": f"Provider for {slug}", "authorization_flow": authz, "invalidation_flow": inval,
        "client_type": "confidential", "client_id": slug, "client_secret": secret,
        "grant_types": ["authorization_code", "refresh_token"],
        "redirect_uris": [{"matching_mode": "strict", "url": u, "redirect_uri_type": "authorization"} for u in uris],
        "property_mappings": maps, "signing_key": key})
    app = post("/core/applications/", {"name": slug.capitalize(), "slug": slug, "provider": prov["pk"],
                                       "policy_engine_mode": "any"})
    # Layer 1: only members of the app's groups may open it
    for i, g in enumerate([g for g in GROUPS if g.startswith(f"app-{slug}-")]):
        post("/policies/bindings/", {"target": app["pk"], "group": gpk[g], "order": i})
    print(f"{slug}: provider {prov['pk']}, bound groups:",
          [g for g in GROUPS if g.startswith(f"app-{slug}-")])

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

gitea: provider 1, bound groups: ['app-gitea-users', 'app-gitea-admins', 'app-gitea-restricted']
grafana: provider 2, bound groups: ['app-grafana-users', 'app-grafana-editors', 'app-grafana-admins']
nextcloud: provider 3, bound groups: ['app-nextcloud-users', 'app-nextcloud-admins']
user layla pk 5

ثم ربطنا مزود Nextcloud بعنوان Back-channel Logout الخاص بـ user_oidc، وهي ميزة يرسل فيها authentik طلب خروج مباشراً إلى التطبيق عندما تنتهي جلسة المستخدم، وقد أضيفت في authentik 2025.8 بحالة Preview، ولم نربطها في Gitea وGrafana لأننا لم نجد لها دعماً فيهما:

curl -s -o /dev/null -w "%{http_code}\n" -X PATCH -H "Authorization: Bearer lab-api-token-9d4e1f" \
  -H "Content-Type: application/json" \
  -d '{"logout_uri":"http://lab-sso-nextcloud/index.php/apps/user_oidc/backchannel-logout/authentik","logout_method":"backchannel"}' \
  http://127.0.0.1:19500/api/v3/providers/oauth2/3/

الخطوة 3: ربط التطبيقات الثلاثة

Gitea أعددناه من سطر الأوامر كما في دليل Gitea مع authentik، مع حساب مدير محلي للطوارئ، والجديد هنا مجموعة المقيدين:

docker exec -u git lab-sso-gitea gitea admin user create --username localadmin \
  --password 'lab-gitea-pass-4c1e' --email [email protected] --admin --must-change-password=false
docker exec -u git lab-sso-gitea gitea admin auth add-oauth --name authentik --provider openidConnect \
  --key gitea --secret lab-gitea-oidc-secret-7c2d \
  --auto-discover-url http://lab-sso-authentik:9000/application/o/gitea/.well-known/openid-configuration \
  --scopes "email profile" --group-claim-name groups \
  --admin-group app-gitea-admins --restricted-group app-gitea-restricted

وأما Grafana فإعداده كله في متغيرات البيئة التي رأيناها في ملف Compose. وبقي Nextcloud، فقمنا بتثبيت تطبيق user_oidc ثم أنشأنا المزود:

docker exec -u www-data lab-sso-nextcloud php occ app:install user_oidc
docker exec -u www-data lab-sso-nextcloud php occ config:system:set allow_local_remote_servers --value=true --type=boolean
# lab only: user_oidc refuses plain http unless this is set
docker exec -u www-data lab-sso-nextcloud php occ config:app:set user_oidc allow_insecure_http --value=1 --type=boolean --lazy
docker exec -u www-data lab-sso-nextcloud php occ user_oidc:provider authentik \
  --clientid=nextcloud --clientsecret=lab-nextcloud-oidc-secret-5b8e \
  --discoveryuri=http://lab-sso-authentik:9000/application/o/nextcloud/.well-known/openid-configuration \
  --scope="openid email profile nextcloud" \
  --unique-uid=0 --mapping-uid=preferred_username \
  --group-provisioning=1 --mapping-groups=nc_groups \
  --group-whitelist-regex='/^(admin|app-nextcloud-.*)$/' --check-bearer=0

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

  • --unique-uid=0 هو أهم سطر هنا، والسبب أن قيمته الافتراضية 1، ومعها يمرر user_oidc اسم كل مجموعة على دالة getId فيصبح الاسم hash من نوع SHA-256، كما في كود LocalIdService.php في الإصدار 8.11.0، وبالتالي لا تصبح admin أبداً مجموعة المديرين الحقيقية، ولا تظهر أي رسالة خطأ.
  • --group-whitelist-regex يحدد المجموعات التي يديرها user_oidc، فهو يضيف المستخدم إلى ما يطابقها في claim ويخرجه مما يطابقها ولم يعد في claim، ويترك المجموعات المحلية الأخرى كما هي. وبدون هذا التعبير يخرج المستخدم من كل مجموعة لا يذكرها claim، حتى المجموعات التي أضفته إليها يدوياً في Nextcloud.
  • الإعداد allow_insecure_http للتجربة فقط، فبدونه يرفض user_oidc الدخول برسالة «You must access Nextcloud with HTTPS to use OpenID Connect»، وفي بيئة حقيقية يكون Nextcloud خلف HTTPS فلا تحتاجه.

الخطوة 4: «المتصفح» الذي يسجل الدخول

السكربت lab.py يتبع إعادة التوجيه Redirects كما يفعل المتصفح، وعندما يصل إلى صفحة تدفق authentik يجيب عنها عبر واجهة تنفيذ التدفق التي تستخدمها واجهة authentik نفسها، ثم يحفظ ملفات تعريف الارتباط Cookies لكل تطبيق حتى نستطيع فحص الجلسة القديمة لاحقاً دون دخول جديد. وبعد الدخول يسأل كل تطبيق عن المستخدمة: في Gitea صفحة الإعدادات وصفحة الإدارة /-/admin وحقلا is_admin وrestricted، وفي Grafana الواجهة /api/user ودور المؤسسة، وفي Nextcloud قائمة المجموعات وهل تفتح لها واجهة المستخدمين التي لا يراها إلا المدير. وهذا هو جزء الإجابة عن التدفق:

def run_flow(s, url):
    """Answer the authentik flow at /if/flow/<slug>/?... the way the web interface does."""
    p = urlparse(url)
    slug = p.path.split("/")[3]
    ex = f"{AK}/api/v3/flows/executor/{slug}/?query={quote(p.query)}"
    ch = s.get(ex).json()
    while True:
        c = ch.get("component")
        csrf = {"X-authentik-CSRF": s.cookies.get("authentik_csrf", "")}
        if c == "xak-flow-redirect":
            return urljoin(AK, ch["to"])
        if c == "ak-stage-access-denied":
            return "DENIED: " + (ch.get("error_message") or "access denied")
        if c == "ak-stage-identification":
            body = {"component": c, "uid_field": "layla", "password": "lab-layla-pass-1a2b"}
        elif c == "ak-stage-password":
            body = {"component": c, "password": "lab-layla-pass-1a2b"}
        else:
            return f"UNEXPECTED {c}: {json.dumps(ch)[:300]}"
        r = s.post(ex, json=body, headers=csrf)
        ch = r.json()
        if ch.get("component") == c and ch.get("response_errors"):
            return f"ERROR {ch['response_errors']}"

وبعد ذلك ينفذ السكربت run-all.sh الخطوات التالية بالترتيب، وكل المخرجات أدناه من تشغيل واحد له.

الخطوة 5: الترقية إلى مدير

== 0. layla is in app-*-users only
  gitea      is_admin=False restricted=False admin_page=403
  grafana    login=layla org_role=Viewer grafana_admin=False
  nextcloud  groups=['app-nextcloud-users'] admin_api=403
== 1. promote: add the three admin groups in authentik
  add_user app-gitea-admins: 204
  add_user app-grafana-admins: 204
  add_user app-nextcloud-admins: 204
-- sessions opened before the change:
  gitea      is_admin=False restricted=False admin_page=403
  grafana    login=layla org_role=Viewer grafana_admin=False
  nextcloud  groups=['app-nextcloud-users'] admin_api=403
-- new login:
  gitea      is_admin=True restricted=False admin_page=200
  grafana    login=layla org_role=Admin grafana_admin=False
  nextcloud  groups=['app-nextcloud-users', 'admin'] admin_api=200
  tokens created: gitea, nextcloud, grafana
  gitea PAT          /user 200 layla  /admin/users 200
  nextcloud app pw   /cloud/user 200  /cloud/users (admin) 200
  grafana SA token   /api/org/users 200

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

  • إضافة layla إلى مجموعات المديرين لم تغير شيئاً في جلساتها المفتوحة، فهي ما زالت مستخدمة عادية في التطبيقات الثلاثة حتى سجلت دخولاً جديداً، والسبب أن التطبيق يقرأ المجموعات من التوكن الذي يستلمه عند الدخول فقط.
  • بعد الدخول الجديد أصبحت مديرة في الثلاثة: is_admin=True في Gitea، ودور Admin في Grafana، ومجموعة admin في Nextcloud التي جاءت من الترجمة في ربط النطاق.
  • ثم أنشأت وهي مديرة ثلاثة توكنات كما يفعل أي موظف: توكن وصول شخصي Personal Access Token (PAT) في Gitea لـ git على اللابتوب، وكلمة مرور تطبيق App Password في Nextcloud من صفحة الأمان لبرنامج البريد، وحساب خدمة Service Account بدور Admin مع توكن له في Grafana لسكربت تقارير.

الخطوة 6: سحب صلاحية المدير

== 2. demote: remove the three admin groups
  remove_user app-gitea-admins: 204
  remove_user app-grafana-admins: 204
  remove_user app-nextcloud-admins: 204
-- admin sessions, no new login:
  gitea      is_admin=True restricted=False admin_page=200
  grafana    login=layla org_role=Admin grafana_admin=False
  nextcloud  groups=['app-nextcloud-users', 'admin'] admin_api=200
-- tokens, no new login:
  gitea PAT          /user 200 layla  /admin/users 200
  nextcloud app pw   /cloud/user 200  /cloud/users (admin) 200
  grafana SA token   /api/org/users 200
-- new login:
  gitea      is_admin=False restricted=False admin_page=403
  grafana    login=layla org_role=Viewer grafana_admin=False
  nextcloud  groups=['app-nextcloud-users'] admin_api=403
-- tokens after the new login:
  gitea PAT          /user 200 layla  /admin/users 403
  nextcloud app pw   /cloud/user 200  /cloud/users (admin) 403
  grafana SA token   /api/org/users 200

وهذه أهم نتيجة في التجربة، والصورة التالية تلخصها:

خط زمني يبين أن الجلسات المفتوحة والتوكنات تبقى بصلاحية المدير بعد سحب المجموعة في authentik حتى الدخول التالي، وأن توكن حساب الخدمة في Grafana يبقى Admin حتى بعد ذلك
بين السحب في authentik والدخول التالي فجوة لا يراها أحد
  • سحب المجموعة في authentik لا يصل إلى أي تطبيق حتى تسجل المستخدمة دخولاً جديداً، فجلستها المفتوحة بقيت بصلاحية المدير في الثلاثة، والأخطر أن توكن Gitea وكلمة مرور التطبيق في Nextcloud بقيا يفتحان واجهات المدير، فالتوكن يرث صلاحية الحساب كما هي في قاعدة بيانات التطبيق لحظة الطلب، ولا يسأل authentik عن شيء.
  • عندما سجلت دخولاً جديداً نزلت صلاحيتها في الثلاثة، ونزلت معها صلاحية توكن Gitea وكلمة مرور Nextcloud، لأنهما يتبعان الحساب. وهذا يعني أن موظفاً سحبت منه صلاحية المدير ولا يسجل الدخول عبر الويب، ويعمل فقط بتوكن من سطر الأوامر، يبقى مديراً في التطبيق إلى أجل غير محدود.
  • توكن حساب الخدمة في Grafana بقي يعمل بدور Admin حتى بعد الدخول الجديد، والسبب أن حساب الخدمة ملك للمؤسسة Organization داخل Grafana وليس للمستخدمة التي أنشأته، فلا علاقة له بمجموعاتها في authentik.

الخطوة 7: الدور المقيد في Gitea

== 3. restricted in Gitea
  add_user app-gitea-restricted: 204
  gitea      is_admin=False restricted=True admin_page=403
  remove_user app-gitea-restricted: 204
  gitea      is_admin=False restricted=False admin_page=403
  grafana    login=layla org_role=Viewer grafana_admin=False
  nextcloud  groups=['app-nextcloud-users'] admin_api=403

المستخدم المقيد Restricted في Gitea لا يرى إلا المستودعات والمنظمات التي تضيفه إليها صراحة، وهو الدور المناسب للمتعاقدين Contractors، ولاحظ أن الحقل يعود إلى false في الدخول التالي بعد إخراجها من المجموعة، فالمزامنة في الاتجاهين.

الخطوة 8: الإخراج من مجموعات التطبيقات

== 4. remove layla from the three app-*-users groups
  remove_user app-gitea-users: 204
  remove_user app-grafana-users: 204
  remove_user app-nextcloud-users: 204
-- open sessions:
  gitea      is_admin=False restricted=False admin_page=403
  grafana    login=layla org_role=Viewer grafana_admin=False
  nextcloud  groups=['app-nextcloud-users'] admin_api=403
-- tokens:
  gitea PAT          /user 200 layla  /admin/users 403
  nextcloud app pw   /cloud/user 200  /cloud/users (admin) 403
  grafana SA token   /api/org/users 200
-- new login:
  gitea      login refused by authentik: Permission denied
  grafana    login refused by authentik: Permission denied
  nextcloud  login refused by authentik: Permission denied

الطبقة الأولى عملت كما توقعنا، فرفض authentik كل دخول جديد بصفحة «Permission denied» قبل أن يصل الطلب إلى أي تطبيق، ولكن لاحظ أن الجلسات المفتوحة والتوكنات بقيت كلها تعمل، فالربط في authentik يقرر من يحصل على توكن جديد، ولا يغلق ما أصدره التطبيق سابقاً.

الخطوة 9: تعطيل الحساب في authentik

أعدنا layla إلى مجموعات المستخدمين وسجلنا دخولاً جديداً إلى الثلاثة، ثم عطلنا حسابها في authentik:

== 5. disable layla in authentik
  is_active=false: 200
-- open sessions, right away:
  gitea      is_admin=False restricted=False admin_page=403
  grafana    login=layla org_role=Viewer grafana_admin=False
  nextcloud  groups=['app-nextcloud-users'] admin_api=403
-- open sessions, 20 seconds later:
  gitea      is_admin=False restricted=False admin_page=403
  grafana    login=layla org_role=Viewer grafana_admin=False
  nextcloud  session rejected (401)
-- tokens:
  gitea PAT          /user 200 layla  /admin/users 403
  nextcloud app pw   /cloud/user 200  /cloud/users (admin) 403
  grafana SA token   /api/org/users 200
-- new login:
  gitea      ERROR {'password': [{'string': 'Invalid password', 'code': 'invalid'}]}
  grafana    ERROR {'password': [{'string': 'Invalid password', 'code': 'invalid'}]}
  nextcloud  ERROR {'password': [{'string': 'Invalid password', 'code': 'invalid'}]}

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

  • authentik قام بما يذكره توثيقه، فحذف جلساتها لديه، ورفض الدخول الجديد برسالة «Invalid password» دون أن يقول إن الحساب معطل، وهذا مقصود حتى لا يعرف المهاجم أن الحساب موجود.
  • جلسة Nextcloud أغلقت بعد ثوان، ووجدنا في سجل Nextcloud طلبات POST /index.php/apps/user_oidc/backchannel-logout/authentik قادمة من authentik، وهذا أثر Back-channel Logout الذي ربطناه في الخطوة 2. ولاحظ أنها لم تغلق في الفحص الأول مباشرة بعد التعطيل، والسبب أن authentik يرسلها من الـ Worker في الخلفية.
  • جلسة Gitea وجلسة Grafana بقيتا مفتوحتين، وفحصناهما مرة أخرى بعد ثلاث دقائق فوجدناهما كما هما، لأن كلاً منهما يملك جلسته الخاصة التي لا يسأل عنها authentik.
  • التوكنات الثلاثة ما زالت تعمل، ومنها كلمة مرور التطبيق في Nextcloud رغم أن جلسة الويب أغلقت، لأن Nextcloud لم يعطل الحساب نفسه، وإنما أنهى الجلسة فقط.

وقد يتساءل البعض: ألا يستطيع Grafana أن يكتشف وحده أن المستخدم عطل؟ والإجابة نعم، ولكن بشرطين ذكرهما توثيق Grafana: أن تفعل use_refresh_token، وأن يحصل Grafana على Refresh Token، وهو في authentik يحتاج إلى نطاق offline_access وإضافة ربطه إلى المزود. وكنا قد جعلنا مدة الـ Access Token في مزود Grafana دقيقتين بدلاً من ساعة قبل تشغيل run-all.sh، فهي كذلك في كل المخرجات السابقة، ثم أضفنا ربط offline_access إلى المزود وأعدنا التجربة:

curl -s -o /dev/null -w "%{http_code}\n" -X PATCH -H "Authorization: Bearer lab-api-token-9d4e1f" \
  -H "Content-Type: application/json" -d '{"access_token_validity":"minutes=2"}' \
  http://127.0.0.1:19500/api/v3/providers/oauth2/2/
GF_USE_REFRESH_TOKEN=true GF_SCOPES="openid email profile offline_access" docker compose up -d grafana

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

  grafana    login=layla org_role=Viewer grafana_admin=False
after 2.5 min, active:
  grafana    login=layla org_role=Viewer grafana_admin=False
  is_active=false: 200
2.5 min after disable:
  grafana    session rejected (401)

وفي سجل Grafana سبب الإغلاق كما هو:

logger=oauth_token.sync userID=2 level=error msg="Failed to refresh OAuth access token" id=2 error="oauth2: \"invalid_grant\" \"The provided authorization grant or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client\""

فبعد دقيقتين ونصف والمستخدمة نشطة جدد Grafana التوكن بصمت وبقيت الجلسة، وبعد التعطيل رفض authentik التجديد لأنه يحذف توكنات OAuth للمستخدم المعطل، فأغلق Grafana الجلسة عند أول طلب بعد انتهاء الـ Access Token. وبالتالي فمدة الـ Access Token في مزود Grafana هي أقصى مدة تبقى فيها جلسة المغادر مفتوحة، فاخترها بوعي. وأما في الإعداد الافتراضي use_refresh_token=false فقد بقيت الجلسة مفتوحة رغم انتهاء الـ Access Token ورغم تعطيل الحساب.

والصورة التالية تجمع نتيجة التعطيل في التطبيقات الثلاثة:

جدول مرسوم لثلاثة تطبيقات بعد تعطيل الحساب في authentik: الدخول الجديد مرفوض في الثلاثة، وجلسة Nextcloud مغلقة عبر Back-channel Logout، وجلسة Gitea مفتوحة، وجلسة Grafana تعتمد على إعداد التجديد، والتوكنات الثلاثة تعمل
authentik يغلق أبوابه، وأبواب التطبيقات تغلق من داخلها

الخطوة 10: ثلاثة أعطال نصنعها عمداً

العطل الأول: claim المجموعات لا يصل. أزلنا ربط النطاق nextcloud من مزود Nextcloud، وهذا يحدث عندما يعيد أحدهم إنشاء المزود وينسى الربط المخصص، ثم أخرجنا layla وهي مديرة من مجموعة المديرين وسجلت دخولاً:

  nextcloud  groups=['app-nextcloud-users', 'admin'] admin_api=200
  remove_user app-nextcloud-admins: 204
  nextcloud  groups=[] admin_api=403

لم يظهر أي خطأ، وتعامل user_oidc مع الـ claim الغائب كأنه قائمة فارغة، فأخرجها من كل المجموعات التي يطابقها التعبير، ومنها app-nextcloud-users، وبالتالي فقدت كل مشاركات Nextcloud المبنية على هذه المجموعة دون أن يعرف أحد السبب. وأما Grafana فمع role_attribute_strict رفض الدخول صراحة عندما أزلنا نطاق profile من مزوده، وهذا ما وجدناه في سجله:

logger=oauth.generic_oauth level=warn msg="Failed to extract role" err="[oauth.role_attribute_strict_violation] idP did not return a role attribute, but role_attribute_strict is set"

والفرق بين السلوكين مهم: الرفض الصريح أفضل من سحب صامت، لذلك فعل الوضع الصارم في كل تطبيق يدعمه، وافحص تبويب Preview في authentik بعد أي تعديل على المزود.

العطل الثاني: الدور لا يأتي من authentik أصلاً. شغلنا Grafana مع GF_SKIP_ORG_ROLE_SYNC=true، ثم أضفنا layla إلى app-grafana-admins وسجلت دخولاً، ثم رفعها مدير Grafana يدوياً إلى Admin، ثم أخرجناها من المجموعة وسجلت دخولاً:

  add_user app-grafana-admins: 204
  grafana    login=layla org_role=Viewer grafana_admin=False
{'message': 'Organization user updated'}
  remove_user app-grafana-admins: 204
  grafana    login=layla org_role=Admin grafana_admin=False

مع هذا الخيار يتجاهل Grafana الدور القادم من authentik تماماً، فلم ترتفع عندما أضفناها إلى المجموعة، ولم تنزل عندما أخرجناها، وأصبح الدور يدار داخل Grafana وحده. والخيار مفيد إذا كنت تريد أن يبقى الدور في التطبيق، ولكن عندها لا معنى لمجموعة app-grafana-admins، فاحذفها حتى لا يظن أحد أنها تعمل. ويقع السلوك نفسه بصورة أخف في BookStack، فمع OIDC_REMOVE_FROM_GROUPS=false الافتراضي يضيف الأدوار عند الدخول ولا يسحبها.

العطل الثالث: مدير محلي من قبل الدخول الموحد. أنشأنا في Gitea حساباً محلياً omar بصلاحية مدير، كما يحدث عندما يكون التطبيق قد عمل شهوراً قبل ربطه بـ authentik، وهذا الحساب لا يراه authentik ولا يستطيع تعطيله، وسوف نكتشفه في قسم التدقيق.

إزالة التجربة

docker compose down -v
rm -rf state

والأمر الأول يحذف Containers التجربة السبعة والشبكة lab-sso-net والـ Volumes الأربعة lab-sso-db وlab-sso-gitea وlab-sso-grafana وlab-sso-nextcloud بأسمائها، ولا يلمس أي شيء آخر على جهازك.

التطبيقات التي لا تفهم المجموعات

في الجدول السابق تطبيقات مثل Outline وMoodle لا تقرأ أي claim للأدوار، وأمامك فيها ثلاثة خيارات، وقد شرحنا كل واحد منها بالتفصيل في مقال SCIM، فنكتفي هنا بما يخص الأدوار:

الطريقةماذا تنقل؟متى تناسب؟
الإنشاء عند أول دخول Just-in-Time (JIT)الحساب فقط، والدور يضبط يدوياً في التطبيقتطبيق صغير بمديرين أو ثلاثة، مع الطبقة الأولى في authentik ومراجعة شهرية للمديرين
SCIM Provider في authentikالحسابات والمجموعات فوراً عند كل تغيير، والتعطيل يصل إلى التطبيقعندما يستقبل التطبيق SCIM، وهو في كثير من الأدوات ميزة مدفوعة
LDAP Provider عبر Outpostالمستخدمين والمجموعات، والتطبيق يربط مجموعة LDAP بالدور عندهتطبيق قديم يفهم LDAP ولا يفهم OIDC، ولاحظ أن التغيير يصل مع المزامنة التالية فقط

والميزة المهمة في SCIM لموضوعنا أنه يغلق الفجوة التي رأيناها في الخطوة 6، لأن التغيير يصل إلى التطبيق لحظة حدوثه في authentik ولا ينتظر الدخول التالي، ولكن انتبه لما وجدناه في تجربة SCIM: إخراج المستخدم من مجموعة التطبيق يرسل DELETE، أي أن الطبقة الأولى نفسها قد تحذف بيانات المستخدم في التطبيق.

مغادرة الموظف: أغلق كل باب من داخل تطبيقه

رأينا في الخطوة 9 أن التعطيل في authentik يغلق ما يملكه authentik فقط، فما الذي يغلق الباقي؟ نفذنا من حساب المدير المحلي في كل تطبيق ما يلي على المستخدمة نفسها وجلساتها مفتوحة:

a = ("localadmin", "lab-grafana-pass-6a1c")
u = requests.get(GF + "/api/users/lookup?loginOrEmail=layla", auth=a).json()
print("grafana disable:", requests.post(GF + f"/api/admin/users/{u['id']}/disable", auth=a).json())
print("grafana logout:", requests.post(GF + f"/api/admin/users/{u['id']}/logout", auth=a).json())
t = open("state/gitea_admin_token").read().strip()
print("gitea prohibit_login:", requests.patch(GT + "/api/v1/admin/users/layla", headers={"Authorization": "token " + t},
      json={"login_name": "layla", "source_id": 1, "prohibit_login": True}).status_code)
docker exec -u www-data lab-sso-nextcloud php occ user:disable layla

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

grafana disable: {'message': 'Could not disable external user'}
grafana logout: {'message': 'User logged out'}
gitea prohibit_login: 200
The specified user is disabled
  gitea      session blocked (Sign-In Prohibited page)
  grafana    session rejected (401)
  nextcloud  session rejected (401)
  gitea PAT          /user 403 This account is prohibited from signing in, please contact y  /admin/users 403
  nextcloud app pw   /cloud/user 401  /cloud/users (admin) 401
  grafana SA token   /api/org/users 200

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

  • Grafana رفض تعطيل المستخدم القادم من OAuth برسالة «Could not disable external user»، ولكن الأمر logout أنهى جلساته، وبما أن authentik سوف يرفض أي دخول جديد فهذا يكفي للمستخدم نفسه.
  • في Gitea جعل الحقل prohibit_login الجلسة المفتوحة تعرض صفحة «Sign-In Prohibited» ورفض التوكن بالرمز 403، والتفاصيل الكاملة لمغادرة موظف في Gitea، ومنها مفاتيح SSH ونقل المستودعات، في مقال JML.
  • في Nextcloud أغلق occ user:disable كلمة مرور التطبيق أيضاً، وهي الباب الذي بقي مفتوحاً بعد Back-channel Logout.
  • توكن حساب الخدمة في Grafana ما زال يعمل، فهذا لا يغلقه تعطيل أي شخص، وإنما مراجعة حسابات الخدمة التي أنشأها المغادر ونقل ملكيتها أو حذفها.
💡
يملك authentik منذ الإصدار 2026.5 ميزة Account Lockdown في الإصدار التجاري Enterprise، وهي تعطل الحساب وتلغي كلمة المرور وتنهي الجلسات وتلغي كل توكناته في authentik بخطوة واحدة مع سجل بالسبب، ولكنها مثل التعطيل العادي تعمل داخل authentik، فلا تغني عن الخطوات أعلاه في كل تطبيق.

التدقيق: من هو المدير الآن في كل تطبيق؟

بعد كل ما سبق يصبح السؤال الذي يجب أن تطرحه كل شهر هو: هل يتفق كل تطبيق مع authentik على من هو المدير؟ والسكربت audit.py يجيب عنه، فيجمع المديرين من الواجهة البرمجية لكل تطبيق، ويعرف مصدر كل حساب (authentik أم محلي)، ثم يقارنه بأعضاء مجموعة المديرين في authentik:

rows = []
for u in requests.get(f"{GT}/api/v1/admin/users", headers=GTH, params={"limit": 50}).json():
    if u["is_admin"]:
        rows.append(("gitea", u["login"], "authentik" if u["source_id"] else "local", "app-gitea-admins"))
for u in requests.get(f"{GF}/api/org/users", auth=GFA).json():
    if u["role"] == "Admin":
        src = "authentik" if "OAuth" in " ".join(u.get("authLabels") or []) else "local"
        rows.append(("grafana", u["login"], src, "app-grafana-admins"))
admins = requests.get(f"{NC}/ocs/v2.php/cloud/groups/admin/users?format=json", headers=OCS, auth=NCA).json()
for uid in admins["ocs"]["data"]["users"]:
    info = requests.get(f"{NC}/ocs/v2.php/cloud/users/{uid}?format=json", headers=OCS, auth=NCA).json()["ocs"]["data"]
    rows.append(("nextcloud", uid, "authentik" if info["backend"] == "user_oidc" else "local", "app-nextcloud-admins"))

print(f"{'app':<10} {'account':<12} {'source':<10} verdict")
for app, user, src, group in rows:
    if src == "local":
        verdict = "break-glass (documented)" if user in BREAK_GLASS else "LOCAL ADMIN: authentik cannot remove it"
    elif user in ak_members(group) and ak_active(user):
        verdict = f"ok ({group})"
    else:
        verdict = f"STALE: not in {group} or disabled, still admin until next login"
    print(f"{app:<10} {user:<12} {src:<10} {verdict}")

شغلناه بعد أن رقينا layla في الثلاثة ثم أخرجناها من app-grafana-admins دون دخول جديد، ومع الحساب المحلي omar في Gitea، والمخرج سوف يكون كما يلي:

app        account      source     verdict
gitea      layla        authentik  ok (app-gitea-admins)
gitea      localadmin   local      break-glass (documented)
gitea      omar         local      LOCAL ADMIN: authentik cannot remove it
grafana    layla        authentik  STALE: not in app-grafana-admins or disabled, still admin until next login
grafana    localadmin   local      break-glass (documented)
nextcloud  layla        authentik  ok (app-nextcloud-admins)
nextcloud  localadmin   local      break-glass (documented)

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

  • السطر STALE هو الفجوة التي رأيناها في الخطوة 6 وقد أصبحت مرئية، والحل أن تنهي جلسات المستخدم في التطبيق (كما فعلنا مع Grafana) حتى يضطر إلى دخول جديد يحمل الدور الصحيح.
  • السطر LOCAL ADMIN هو العطل الثالث، فحساب omar لا يمر على authentik، ولا يغلقه تعطيله فيه لأنه غير موجود فيه أصلاً، فإما أن تربطه بحساب authentik أو تعطله.
  • حسابات localadmin مقبولة لأنها موثقة في قائمة BREAK_GLASS، وكل حساب طوارئ يجب أن يكون معروفاً ومحفوظاً في مدير كلمات المرور ومراقباً.

وفي جهة authentik فإن سجل الأحداث Events يسجل كل دخول إلى تطبيق في حدث authorize_application مع اسم التطبيق والنطاقات، ويسجل تعديل المجموعة في حدث model_updated. ولكن لاحظ ما وجدناه في التجربة: حدث إضافة عضو إلى المجموعة يذكر اسم المجموعة ومن عدلها والمسار /api/v3/core/groups/<id>/add_user/ أو remove_user، ولا يذكر اسم العضو الذي أضيف أو أخرج:

{
 "action": "model_updated",
 "created": "2026-10-12T07:48:22.226903Z",
 "user": {
  "pk": 4,
  "email": "[email protected]",
  "username": "akadmin"
 },
 "context": {
  "model": {
   "pk": "00b07d99e16544f9a7d6329a07e540f1",
   "app": "authentik_core",
   "name": "app-grafana-admins",
   "model_name": "group"
  },
  "http_request": {
   "args": {},
   "path": "/api/v3/core/groups/00b07d99-e165-44f9-a7d6-329a07e540f1/remove_user/",
   "method": "POST",
   "request_id": "3d9aca5e20ce466fa442bc79f05d4e5c",
   "user_agent": "python-requests/2.34.2"
  }
 }
}

لذلك لا تعتمد على سجل authentik وحده لتعرف من كان مديراً في تاريخ معين، وإنما احفظ ناتج سكربت التدقيق كل شهر في مكان لا يعدله من يدير الصلاحيات، فهو الذي يجيب عن سؤال المدقق: من كان مديراً لـ Grafana في مارس؟

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

  • تطبيق بلا ربط في authentik: التطبيق الذي لا يملك ربطاً متاح لكل المستخدمين، فاربطه بمجموعاته قبل أن تعلن عنه.
  • الاعتماد على سحب المجموعة وحده: السحب لا يصل إلا مع الدخول التالي، والتوكنات تبقى بصلاحية الحساب حتى ذلك الحين، فأنه الجلسات في التطبيق بعد أي سحب لصلاحية مدير.
  • --unique-uid على قيمته الافتراضية في user_oidc: تتحول أسماء المجموعات إلى hash، فلا يصبح أحد مديراً عبر admin ولا تظهر رسالة خطأ.
  • إرسال كل المجموعات إلى كل تطبيق: نطاق profile الافتراضي يكشف أسماء كل المجموعات، فاستخدم ربطاً مخصصاً للتطبيقات الحساسة أو التي تفهم أسماء عامة مثل admin.
  • إعادة إنشاء المزود دون ربطه المخصص: يختفي claim المجموعات بصمت، فيسحب Nextcloud المجموعات ويرفض Grafana الدخول، فافحص Preview بعد أي تعديل.
  • نسيان حسابات الخدمة: حساب الخدمة في Grafana وما يشبهه في التطبيقات الأخرى لا يتبع أي شخص، فراجعها عند مغادرة من أنشأها.
  • حساب الطوارئ بلا توثيق: حساب المدير المحلي ضروري ليوم يتعطل فيه authentik، ولكن كل حساب محلي غير موثق هو باب لا يغلقه أحد.

الخلاصة

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

  • من يدخل التطبيق يقرره authentik مركزياً عبر ربط التطبيق بمجموعاته، ومن لا ينطبق عليه أي ربط لا يحصل على توكن أصلاً.
  • دور المستخدم يأتي من عضويته في مجموعات authentik، ولكن كل تطبيق يترجمها بإعداده الخاص، فتضبط الترجمة مرة واحدة لكل تطبيق، والصلاحيات التفصيلية تبقى داخل التطبيق.
  • سم المجموعات app-<app>-<role>، وأرسل إلى كل تطبيق مجموعاته وحدها عبر Scope Mapping مخصص عندما يلزم.
  • Gitea وGrafana وNextcloud تعيد حساب الدور مع كل دخول وتسحبه، ولكن لا شيء يتغير قبل الدخول التالي، والتوكنات تبقى بالدور القديم حتى ذلك الحين.
  • التعطيل في authentik يغلق جلساته ويرسل Back-channel Logout لمن يدعمه، أما جلسات التطبيقات وتوكناتها وحسابات الخدمة فتغلق من داخل كل تطبيق.
  • شغل سكربت تدقيق شهرياً يقارن مديري كل تطبيق بمجموعات authentik، ويكشف الحسابات المحلية التي لا يراها authentik.

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

نشرة عرب رووت | ArabRoot

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

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

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

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