Skip to content

Commit

Permalink
Feat: SSO group sync (#7293)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
p-fruck and SchrodingersGat authored Jun 29, 2024
1 parent b924530 commit 60e22c5
Show file tree
Hide file tree
Showing 10 changed files with 273 additions and 11 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ InvenTree is designed to be **extensible**, and provides multiple options for **
<li><a href="https://www.djangoproject.com/">Django</a></li>
<li><a href="https://www.django-rest-framework.org/">DRF</a></li>
<li><a href="https://django-q.readthedocs.io/">Django Q</a></li>
<li><a href="https://django-allauth.readthedocs.io/">Django-Allauth</a></li>
<li><a href="https://docs.allauth.org/">Django-Allauth</a></li>
</ul>
</details>

Expand Down
39 changes: 32 additions & 7 deletions docs/docs/settings/SSO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:

Expand All @@ -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.
Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/settings/global.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br><br>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.<br><br>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.<br><br>This forces each user to setup MFA and use it on each authentication | False |
Expand Down
8 changes: 8 additions & 0 deletions src/backend/InvenTree/InvenTree/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down
4 changes: 3 additions & 1 deletion src/backend/InvenTree/InvenTree/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
59 changes: 59 additions & 0 deletions src/backend/InvenTree/InvenTree/sso.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 changes: 122 additions & 0 deletions src/backend/InvenTree/InvenTree/test_sso.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 60e22c5

Please sign in to comment.