2
0
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:
Philipp Fruck
2024-06-29 10:32:28 +00:00
committed by GitHub
parent b924530627
commit 60e22c50cd
10 changed files with 273 additions and 11 deletions

View File

@ -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 = [

View File

@ -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)

View File

@ -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))

View 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)

View File

@ -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,
},

View File

@ -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>

View File

@ -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'
]}
/>
)