diff --git a/README.md b/README.md
index f329c20758cb..3f6b0dd27cab 100644
--- a/README.md
+++ b/README.md
@@ -66,7 +66,7 @@ InvenTree is designed to be **extensible**, and provides multiple options for **
Django
DRF
Django Q
- Django-Allauth
+ Django-Allauth
diff --git a/docs/docs/settings/SSO.md b/docs/docs/settings/SSO.md
index 351c594b5f8b..2832ba1950e4 100644
--- a/docs/docs/settings/SSO.md
+++ b/docs/docs/settings/SSO.md
@@ -4,13 +4,13 @@ title: InvenTree Single Sign On
## Single Sign On
-InvenTree provides the possibility to use 3rd party services to authenticate users. This functionality makes use of [django-allauth](https://django-allauth.readthedocs.io/en/latest/) and supports a wide array of OpenID and OAuth [providers](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html).
+InvenTree provides the possibility to use 3rd party services to authenticate users. This functionality makes use of [django-allauth](https://docs.allauth.org/en/latest/) and supports a wide array of OpenID and OAuth [providers](https://docs.allauth.org/en/latest/socialaccount/providers/index.html).
!!! tip "Provider Documentation"
- There are a lot of technical considerations when configuring a particular SSO provider. A good starting point is the [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html)
+ There are a lot of technical considerations when configuring a particular SSO provider. A good starting point is the [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html)
!!! warning "Advanced Users"
- The SSO functionality provided by django-allauth is powerful, but can prove challenging to configure. Please ensure that you understand the implications of enabling SSO for your InvenTree instance. Specific technical details of each available SSO provider are beyond the scope of this documentation - please refer to the [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) for more information.
+ The SSO functionality provided by django-allauth is powerful, but can prove challenging to configure. Please ensure that you understand the implications of enabling SSO for your InvenTree instance. Specific technical details of each available SSO provider are beyond the scope of this documentation - please refer to the [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) for more information.
## SSO Configuration
@@ -31,8 +31,8 @@ There are two variables in the configuration file which define the operation of
| Environment Variable |Configuration File | Description | More Info |
| --- | --- | --- | --- |
-| INVENTREE_SOCIAL_BACKENDS | `social_backends` | A *list* of provider backends enabled for the InvenTree instance | [django-allauth docs](https://django-allauth.readthedocs.io/en/latest/installation/quickstart.html) |
-| INVENTREE_SOCIAL_PROVIDERS | `social_providers` | A *dict* of settings specific to the installed providers | [provider documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) |
+| INVENTREE_SOCIAL_BACKENDS | `social_backends` | A *list* of provider backends enabled for the InvenTree instance | [django-allauth docs](https://docs.allauth.org/en/latest/installation/quickstart.html) |
+| INVENTREE_SOCIAL_PROVIDERS | `social_providers` | A *dict* of settings specific to the installed providers | [provider documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) |
In the example below, SSO provider modules are activated for *google*, *github* and *microsoft*. Specific configuration options are specified for the *microsoft* provider module:
@@ -44,7 +44,7 @@ In the example below, SSO provider modules are activated for *google*, *github*
Note that the provider modules specified in `social_backends` must be prefixed with `allauth.socialaccounts.providers`
!!! warning "Provider Documentation"
- We do not provide any specific documentation for each provider module. Please refer to the [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) for more information.
+ We do not provide any specific documentation for each provider module. Please refer to the [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) for more information.
!!! tip "Restart Server"
As the [configuration file](../start/config.md) is only read when the server is launched, ensure you restart the server after editing the file.
@@ -57,7 +57,7 @@ The next step is to create an external authentication app with your provider of
The provider application will be created as part of your SSO provider setup. This is *not* the same as the *SocialApp* entry in the InvenTree admin interface.
!!! info "Read the Documentation"
- The [django-allauth documentation](https://django-allauth.readthedocs.io/en/latest/socialaccount/providers/index.html) is a good starting point here. There are also a number of good tutorials online (at least for the major supported SSO providers).
+ The [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) is a good starting point here. There are also a number of good tutorials online (at least for the major supported SSO providers).
In general, the external app will generate a *key* and *secret* pair - although different terminology may be used, depending on the provider.
@@ -132,6 +132,31 @@ In the [settings screen](./global.md), navigate to the *Login Settings* panel. H
Note that [email settings](./email.md) must be correctly configured before SSO will be activated. Ensure that your email setup is correctly configured and operational.
+## SSO Group Sync Configuration
+
+InvenTree has the ability to synchronize groups assigned to each user directly from the IdP. To enable this feature, navigate to the *Login Settings* panel in the [settings screen](./global.md) first. Here, the following options are available:
+
+| Setting | Description |
+| --- | --- |
+| Enable SSO group sync | Enable synchronizing InvenTree groups with groups provided by the IdP |
+| SSO group key | The name of the claim containing all groups, e.g. `groups` or `roles` |
+| SSO group map | A mapping from SSO groups to InvenTree groups as JSON, e.g. `{"/inventree/admins": "admin"}`. If the mapped group does not exist once a user signs up, a new group without assigned permissions will be created. |
+| Remove groups outside of SSO | Whether groups should be removed from the user if they are not present in the IdP data |
+
+!!! warning "Remove groups outside of SSO"
+ Disabling this feature might cause security issues as groups that are removed in the IdP will stay assigned in InvenTree
+
+### Keycloak OIDC example configuration
+
+!!! tip "Configuration for different IdPs"
+ The main challenge in enabling the SSO group sync feature is for the SSO admin to configure the IdP such that the groups are correctly represented in in the Django allauth `extra_data` attribute. The SSO group sync feature has been developed and tested using integrated Keycloak users/groups and OIDC. If you are utilizing this feature using another IdP, kindly consider documenting your configuration steps as well.
+
+Keycloak groups are not sent to the OIDC client by default. To enable such functionality, create a new client scope named `groups` in the Keycloak admin console. For this scope, add a new mapper ('By Configuration') and select 'Group Membership'. Give it a descriptive name and set the token claim name to `groups`.
+
+For each OIDC client that relies on those group, explicitly add the `groups` scope to client scopes. The groups will now be sent to client upon request.
+
+**Note:** A group named `foo` will be displayed as `/foo`. For this reason, the example above recommends using group names like `appname/rolename` which will be sent to the client as `/appname/rolename`.
+
## Security Considerations
You should use SSL for your website if you want to use this feature. Also set your callback-endpoints to `https://` addresses to reduce the risk of leaking user's tokens.
diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md
index ae5107ca8960..967651e46d60 100644
--- a/docs/docs/settings/global.md
+++ b/docs/docs/settings/global.md
@@ -37,6 +37,10 @@ Change how logins, password-forgot, signups are handled.
| Enable registration | Boolean | Enable self-registration for users on the login-pages | False |
| Enable SSO | Boolean | Enable SSO on the login-pages | False |
| Enable SSO registration | Boolean | Enable self-registration for users via SSO on the login-pages | False |
+| Enable SSO group sync | Boolean | Enable synchronizing InvenTree groups directly from the IdP | False |
+| SSO group key | String | The name of the groups claim attribute provided by the IdP | |
+| SSO group map | String (JSON) | A mapping from SSO groups to local InvenTree groups | {} |
+| Remove groups outside of SSO | Boolean | Whether groups assigned to the user should be removed if they are not backend by the IdP. Disabling this setting might cause security issues | True |
| Enable password forgot | Boolean | Enable password forgot function on the login-pages.
This will let users reset their passwords on their own. For this feature to work you need to configure E-mail | True |
| E-Mail required | Boolean | Require user to supply e-mail on signup.
Without a way (e-mail) to contact the user notifications and security features might not work! | False |
| Enforce MFA | Boolean | Users must use multifactor security.
This forces each user to setup MFA and use it on each authentication | False |
diff --git a/src/backend/InvenTree/InvenTree/apps.py b/src/backend/InvenTree/InvenTree/apps.py
index 85d42bf18d27..0cd448361ff3 100644
--- a/src/backend/InvenTree/InvenTree/apps.py
+++ b/src/backend/InvenTree/InvenTree/apps.py
@@ -11,6 +11,8 @@
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 @@ def ready(self):
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 = [
diff --git a/src/backend/InvenTree/InvenTree/forms.py b/src/backend/InvenTree/InvenTree/forms.py
index ff4babe3bfe4..b4a992e1d996 100644
--- a/src/backend/InvenTree/InvenTree/forms.py
+++ b/src/backend/InvenTree/InvenTree/forms.py
@@ -269,7 +269,9 @@ def save_user(self, request, user, form, commit=True):
# 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)
diff --git a/src/backend/InvenTree/InvenTree/sso.py b/src/backend/InvenTree/InvenTree/sso.py
index b3fb551cf2f1..9279d5819824 100644
--- a/src/backend/InvenTree/InvenTree/sso.py
+++ b/src/backend/InvenTree/InvenTree/sso.py
@@ -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))
diff --git a/src/backend/InvenTree/InvenTree/test_sso.py b/src/backend/InvenTree/InvenTree/test_sso.py
new file mode 100644
index 000000000000..60a5619c0b74
--- /dev/null
+++ b/src/backend/InvenTree/InvenTree/test_sso.py
@@ -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)
diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py
index 645abd28e6a2..e7a3827c8e7b 100644
--- a/src/backend/InvenTree/common/models.py
+++ b/src/backend/InvenTree/common/models.py
@@ -1909,6 +1909,38 @@ def save(self, *args, **kwargs):
'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 @@ def save(self, *args, **kwargs):
},
'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,
},
diff --git a/src/backend/InvenTree/templates/InvenTree/settings/login.html b/src/backend/InvenTree/templates/InvenTree/settings/login.html
index d2b557978c4d..d94a3a18315c 100644
--- a/src/backend/InvenTree/templates/InvenTree/settings/login.html
+++ b/src/backend/InvenTree/templates/InvenTree/settings/login.html
@@ -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" %}
diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
index f787f77b9947..bd0ac884b84a 100644
--- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
+++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
@@ -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'
]}
/>
)