mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 12:35:46 +00:00
Feat: SSO group sync (#7293)
* feat: Add settings for SSO group sync * feat: Handle SSO group sync * fix(SSO): Add default group only if it is the only one When syncing SSO groups on first user creation, the default group should not be added if there is already another group synced by the IdP * docs: Add SSO goup sync instructions * fix: Run pre-commit hooks * i18n(SSO): Wrap settings name and description * docs(SSO): Fix links to allauth docs * fix(frontend): Add SSO_GROUP_KEY option * add unittests for SSO * docs(SSO): Make hint for example comfiguration a tip * docs(SSO): Describe relation between SSO sync and signup group * fix(SSO): Avoid potential key error * feat(SSO): Create mapped group if it does not exist * docs(SSO): Describe how groups can be created during signup --------- Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
@ -11,6 +11,8 @@ from django.core.exceptions import AppRegistryNotReady
|
||||
from django.db import transaction
|
||||
from django.db.utils import IntegrityError, OperationalError
|
||||
|
||||
from allauth.socialaccount.signals import social_account_added, social_account_updated
|
||||
|
||||
import InvenTree.conversion
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
@ -70,6 +72,12 @@ class InvenTreeConfig(AppConfig):
|
||||
self.add_user_on_startup()
|
||||
self.add_user_from_file()
|
||||
|
||||
# register event receiver and connect signal for SSO group sync. The connected signal is
|
||||
# used for account updates whereas the receiver is used for the initial account creation.
|
||||
from InvenTree import sso
|
||||
|
||||
social_account_updated.connect(sso.ensure_sso_groups)
|
||||
|
||||
def remove_obsolete_tasks(self):
|
||||
"""Delete any obsolete scheduled tasks in the database."""
|
||||
obsolete = [
|
||||
|
@ -269,7 +269,9 @@ class RegistratonMixin:
|
||||
|
||||
# Check if a default group is set in settings
|
||||
start_group = get_global_setting('SIGNUP_GROUP')
|
||||
if start_group:
|
||||
if (
|
||||
start_group and user.groups.count() == 0
|
||||
): # check that no group has been added through SSO group sync
|
||||
try:
|
||||
group = Group.objects.get(id=start_group)
|
||||
user.groups.add(group)
|
||||
|
@ -1,7 +1,14 @@
|
||||
"""Helper functions for Single Sign On functionality."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from allauth.socialaccount.models import SocialAccount, SocialLogin
|
||||
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
@ -75,3 +82,55 @@ def registration_enabled() -> bool:
|
||||
def auto_registration_enabled() -> bool:
|
||||
"""Return True if SSO auto-registration is enabled."""
|
||||
return str2bool(get_global_setting('LOGIN_SIGNUP_SSO_AUTO'))
|
||||
|
||||
|
||||
def ensure_sso_groups(sender, sociallogin: SocialLogin, **kwargs):
|
||||
"""Sync groups from IdP each time a SSO user logs on.
|
||||
|
||||
This event listener is registered in the apps ready method.
|
||||
"""
|
||||
if not get_global_setting('LOGIN_ENABLE_SSO_GROUP_SYNC'):
|
||||
return
|
||||
|
||||
group_key = get_global_setting('SSO_GROUP_KEY')
|
||||
group_map = json.loads(get_global_setting('SSO_GROUP_MAP'))
|
||||
# map SSO groups to InvenTree groups
|
||||
group_names = []
|
||||
for sso_group in sociallogin.account.extra_data.get(group_key, []):
|
||||
if mapped_name := group_map.get(sso_group):
|
||||
group_names.append(mapped_name)
|
||||
|
||||
# ensure user has groups
|
||||
user = sociallogin.account.user
|
||||
for group_name in group_names:
|
||||
try:
|
||||
user.groups.get(name=group_name)
|
||||
except Group.DoesNotExist:
|
||||
# user not in group yet
|
||||
try:
|
||||
group = Group.objects.get(name=group_name)
|
||||
except Group.DoesNotExist:
|
||||
logger.info(f'Creating group {group_name} as it did not exist')
|
||||
group = Group(name=group_name)
|
||||
group.save()
|
||||
logger.info(f'Adding group {group_name} to user {user}')
|
||||
user.groups.add(group)
|
||||
|
||||
# remove groups not listed by SSO if not disabled
|
||||
if get_global_setting('SSO_REMOVE_GROUPS'):
|
||||
for group in user.groups.all():
|
||||
if not group.name in group_names:
|
||||
logger.info(f'Removing group {group.name} from {user}')
|
||||
user.groups.remove(group)
|
||||
|
||||
|
||||
@receiver(post_save, sender=SocialAccount)
|
||||
def on_social_account_created(sender, instance: SocialAccount, created: bool, **kwargs):
|
||||
"""Sync SSO groups when new SocialAccount is added.
|
||||
|
||||
Since the allauth `social_account_added` signal is not sent for some reason, this
|
||||
signal is simulated using post_save signals. The issue has been reported as
|
||||
https://github.com/pennersr/django-allauth/issues/3834
|
||||
"""
|
||||
if created:
|
||||
ensure_sso_groups(None, SocialLogin(account=instance))
|
||||
|
122
src/backend/InvenTree/InvenTree/test_sso.py
Normal file
122
src/backend/InvenTree/InvenTree/test_sso.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""Test the sso module functionality."""
|
||||
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.test import override_settings
|
||||
from django.test.testcases import TransactionTestCase
|
||||
|
||||
from allauth.socialaccount.models import SocialAccount, SocialLogin
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree import sso
|
||||
from InvenTree.forms import RegistratonMixin
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
|
||||
|
||||
class Dummy:
|
||||
"""Simulate super class of RegistratonMixin."""
|
||||
|
||||
def save_user(self, _request, user: User, *args) -> User:
|
||||
"""This method is only used that the super() call of RegistrationMixin does not fail."""
|
||||
return user
|
||||
|
||||
|
||||
class MockRegistrationMixin(RegistratonMixin, Dummy):
|
||||
"""Mocked implementation of the RegistrationMixin."""
|
||||
|
||||
|
||||
class TestSsoGroupSync(TransactionTestCase):
|
||||
"""Tests for the SSO group sync feature."""
|
||||
|
||||
def setUp(self):
|
||||
"""Construct sociallogin object for test cases."""
|
||||
# configure SSO
|
||||
InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO_GROUP_SYNC', True)
|
||||
InvenTreeSetting.set_setting('SSO_GROUP_KEY', 'groups')
|
||||
InvenTreeSetting.set_setting(
|
||||
'SSO_GROUP_MAP', '{"idp_group": "inventree_group"}'
|
||||
)
|
||||
# configure sociallogin
|
||||
extra_data = {'groups': ['idp_group']}
|
||||
self.group = Group(name='inventree_group')
|
||||
self.group.save()
|
||||
# ensure default group exists
|
||||
user = User(username='testuser', first_name='Test', last_name='User')
|
||||
user.save()
|
||||
account = SocialAccount(user=user, extra_data=extra_data)
|
||||
self.sociallogin = SocialLogin(account=account)
|
||||
|
||||
def test_group_added_to_user(self):
|
||||
"""Check that a new SSO group is added to the user."""
|
||||
user: User = self.sociallogin.account.user
|
||||
self.assertEqual(user.groups.count(), 0)
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'inventree_group')
|
||||
|
||||
def test_group_already_exists(self):
|
||||
"""Check that existing SSO group is not modified."""
|
||||
user: User = self.sociallogin.account.user
|
||||
user.groups.add(self.group)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'inventree_group')
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'inventree_group')
|
||||
|
||||
@override_settings(SSO_REMOVE_GROUPS=True)
|
||||
def test_remove_non_sso_group(self):
|
||||
"""Check that any group not provided by IDP is removed."""
|
||||
user: User = self.sociallogin.account.user
|
||||
# group must be saved to database first
|
||||
group = Group(name='local_group')
|
||||
group.save()
|
||||
user.groups.add(group)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'local_group')
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'inventree_group')
|
||||
|
||||
def test_override_default_group_with_sso_group(self):
|
||||
"""The default group should be overridden if SSO groups are available."""
|
||||
user: User = self.sociallogin.account.user
|
||||
self.assertEqual(user.groups.count(), 0)
|
||||
Group(id=42, name='default_group').save()
|
||||
InvenTreeSetting.set_setting('SIGNUP_GROUP', 42)
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
MockRegistrationMixin().save_user(None, user, None)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'inventree_group')
|
||||
|
||||
def test_default_group_without_sso_group(self):
|
||||
"""If no SSO group is specified, the default group should be applied."""
|
||||
self.sociallogin.account.extra_data = {}
|
||||
user: User = self.sociallogin.account.user
|
||||
self.assertEqual(user.groups.count(), 0)
|
||||
Group(id=42, name='default_group').save()
|
||||
InvenTreeSetting.set_setting('SIGNUP_GROUP', 42)
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
MockRegistrationMixin().save_user(None, user, None)
|
||||
self.assertEqual(user.groups.count(), 1)
|
||||
self.assertEqual(user.groups.first().name, 'default_group')
|
||||
|
||||
@override_settings(SSO_REMOVE_GROUPS=True)
|
||||
def test_remove_groups_overrides_default_group(self):
|
||||
"""If no SSO group is specified, the default group should not be added if SSO_REMOVE_GROUPS=True."""
|
||||
user: User = self.sociallogin.account.user
|
||||
self.sociallogin.account.extra_data = {}
|
||||
self.assertEqual(user.groups.count(), 0)
|
||||
Group(id=42, name='default_group').save()
|
||||
InvenTreeSetting.set_setting('SIGNUP_GROUP', 42)
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
MockRegistrationMixin().save_user(None, user, None)
|
||||
# second ensure_sso_groups will be called by signal if social account changes
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
self.assertEqual(user.groups.count(), 0)
|
||||
|
||||
def test_sso_group_created_if_not_exists(self):
|
||||
"""If the mapped group does not exist, a new group with the same name should be created."""
|
||||
self.group.delete()
|
||||
self.assertEqual(Group.objects.filter(name='inventree_group').count(), 0)
|
||||
sso.ensure_sso_groups(None, self.sociallogin)
|
||||
self.assertEqual(Group.objects.filter(name='inventree_group').count(), 1)
|
@ -1909,6 +1909,38 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'LOGIN_ENABLE_SSO_GROUP_SYNC': {
|
||||
'name': _('Enable SSO group sync'),
|
||||
'description': _(
|
||||
'Enable synchronizing InvenTree groups with groups provided by the IdP'
|
||||
),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'SSO_GROUP_KEY': {
|
||||
'name': _('SSO group key'),
|
||||
'description': _(
|
||||
'The name of the groups claim attribute provided by the IdP'
|
||||
),
|
||||
'default': 'groups',
|
||||
'validator': str,
|
||||
},
|
||||
'SSO_GROUP_MAP': {
|
||||
'name': _('SSO group map'),
|
||||
'description': _(
|
||||
'A mapping from SSO groups to local InvenTree groups. If the local group does not exist, it will be created.'
|
||||
),
|
||||
'validator': json.loads,
|
||||
'default': '{}',
|
||||
},
|
||||
'SSO_REMOVE_GROUPS': {
|
||||
'name': _('Remove groups outside of SSO'),
|
||||
'description': _(
|
||||
'Whether groups assigned to the user should be removed if they are not backend by the IdP. Disabling this setting might cause security issues'
|
||||
),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'LOGIN_MAIL_REQUIRED': {
|
||||
'name': _('Email required'),
|
||||
'description': _('Require user to supply mail on signup'),
|
||||
@ -1945,7 +1977,9 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
},
|
||||
'SIGNUP_GROUP': {
|
||||
'name': _('Group on signup'),
|
||||
'description': _('Group to which new users are assigned on registration'),
|
||||
'description': _(
|
||||
'Group to which new users are assigned on registration. If SSO group sync is enabled, this group is only set if no group can be assigned from the IdP.'
|
||||
),
|
||||
'default': '',
|
||||
'choices': settings_group_options,
|
||||
},
|
||||
|
@ -39,6 +39,10 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-user-shield" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO_REG" icon="fa-user-plus" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-key" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO_GROUP_SYNC" icon="fa-users" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SSO_GROUP_KEY" icon="fa-key" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SSO_GROUP_MAP" icon="fa-book" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="SSO_REMOVE_GROUPS" icon="fa-user-minus" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
@ -77,7 +77,11 @@ export default function SystemSettings() {
|
||||
'LOGIN_SIGNUP_MAIL_RESTRICTION',
|
||||
'LOGIN_ENABLE_SSO',
|
||||
'LOGIN_ENABLE_SSO_REG',
|
||||
'LOGIN_SIGNUP_SSO_AUTO'
|
||||
'LOGIN_SIGNUP_SSO_AUTO',
|
||||
'LOGIN_ENABLE_SSO_GROUP_SYNC',
|
||||
'SSO_GROUP_MAP',
|
||||
'SSO_GROUP_KEY',
|
||||
'SSO_REMOVE_GROUPS'
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
Reference in New Issue
Block a user