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

الطبقة الأولى: من يستطيع فتح التطبيق؟
هذه الطبقة مركزية بالكامل، وتضبطها من تبويب 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.
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. والصورة التالية تبين الفرق:

لذلك أنشأنا لـ 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.2 | role_attribute_path يعيد Viewer أو Editor أو Admin، وorg_mapping للمؤسسات | نعم ما لم تفعل skip_org_role_sync (اختبرناه) | نعم (اختبرناه) | الأدوار نعم، أما Team Sync ففي Enterprise |
| Nextcloud مع user_oidc 8.11 | ينشئ مجموعات Nextcloud من claim، ومن في admin مدير | نعم (اختبرناه) | نعم (اختبرناه) | نعم |
| BookStack | OIDC_USER_TO_GROUPS يطابق المجموعات مع أسماء الأدوار أو حقل External Authentication IDs | نعم | فقط مع OIDC_REMOVE_FROM_GROUPS=true، وقيمته الافتراضية false | نعم |
| Proxmox VE 8.4 وما بعده | groups-claim يملأ مجموعات Proxmox، والأدوار تأتي من ACL على المجموعات | عند الدخول | فقط مع groups-overwrite | نعم |
| OpenSearch Dashboards | roles_key يقرأ الأدوار من التوكن، ثم roles_mapping | من التوكن نفسه | لم نتحقق منه | نعم |
| Guacamole 1.6 | claim المجموعات يطابق مجموعات قاعدة البيانات بالاسم، والصلاحيات على مجموعة القاعدة | مع كل توكن | لم نتحقق منه | نعم |
| Portainer | ربط الفرق وصلاحية المدير من claims في Business Edition، أما CE فيضع المستخدم في فريق افتراضي فقط | لم نتحقق منه | لم نتحقق منه | Business Edition |
| Mattermost | OpenID 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 requestsunknown ولم يظهر تدفق 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 لا يصل إلى أي تطبيق حتى تسجل المستخدمة دخولاً جديداً، فجلستها المفتوحة بقيت بصلاحية المدير في الثلاثة، والأخطر أن توكن 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 ورغم تعطيل الحساب.
والصورة التالية تجمع نتيجة التعطيل في التطبيقات الثلاثة:

الخطوة 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 على من هو المدير؟ والسكربت 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.
سجل التحديثات
- أكتوبر 2026: كتابة المقال واختباره على authentik 2026.8.3 وGitea 28.1.0 وGrafana 13.2.3 وNextcloud 35.0.1 مع user_oidc 8.11.0.