diff --git a/docs/content/en/customize_dojo/user_management/configure_sso.md b/docs/content/en/customize_dojo/user_management/configure_sso.md index 1aaed3b95b3..838bf01598b 100644 --- a/docs/content/en/customize_dojo/user_management/configure_sso.md +++ b/docs/content/en/customize_dojo/user_management/configure_sso.md @@ -569,6 +569,17 @@ You can also optionally set the following variables: Once these variables have been set, restart DefectDojo. Log In With OIDC should now be added to the DefectDojo login page. +### Group synchronization options: +You can set the following variables to parse the OIDC groups: + + {{< highlight python >}} + DD_SOCIAL_AUTH_OIDC_GET_GROUPS=True, # Enable group synchronization from OIDC claims + DD_SOCIAL_AUTH_OIDC_GROUPS_FILTER='', # Optional regex to filter group names + DD_SOCIAL_AUTH_OIDC_CLEANUP_GROUPS=True, # Remove user from groups not present in OIDC claim + {{< /highlight >}} + +Once these variables have been set, restart DefectDojo. + ## SAML Configuration DefectDojo Pro users can follow this guide to set up a SAML configuration using the DefectDojo UI. Open-Source users can set up SAML via environment variables, using the following [guide](./#open-source-saml). diff --git a/dojo/db_migrations/0252_alter_dojo_group_social_provider.py b/dojo/db_migrations/0252_alter_dojo_group_social_provider.py new file mode 100644 index 00000000000..7a5d2b938cf --- /dev/null +++ b/dojo/db_migrations/0252_alter_dojo_group_social_provider.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.13 on 2025-11-03 19:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0251_usercontactinfo_reset_timestamps'), + ] + + operations = [ + migrations.AlterField( + model_name='dojo_group', + name='social_provider', + field=models.CharField(blank=True, choices=[('AzureAD', 'AzureAD'), ('Remote', 'Remote'), ('OIDC', 'OIDC')], help_text='Group imported from a social provider.', max_length=10, null=True, verbose_name='Social Authentication Provider'), + ), + ] diff --git a/dojo/group/utils.py b/dojo/group/utils.py index d2245dac2a6..16d269d20eb 100644 --- a/dojo/group/utils.py +++ b/dojo/group/utils.py @@ -1,10 +1,14 @@ +import logging + from crum import get_current_user from django.conf import settings -from django.contrib.auth.models import Group +from django.contrib.auth.models import AnonymousUser, Group from django.db.models.signals import post_delete, post_save from django.dispatch import receiver -from dojo.models import Dojo_Group, Dojo_Group_Member, Role +from dojo.models import Dojo_Group, Dojo_Group_Member, Dojo_User, Role + +logger = logging.getLogger(__name__) def get_auth_group_name(group, attempt=0): @@ -32,7 +36,16 @@ def group_post_save_handler(sender, **kwargs): group.auth_group = auth_group group.save() user = get_current_user() - if user and not settings.AZUREAD_TENANT_OAUTH2_GET_GROUPS: + if not user or isinstance(user, AnonymousUser): + logger.debug("Skipping group_post_save_handler: user is anonymous or missing.") + return + if not isinstance(user, Dojo_User): + try: + user = Dojo_User.objects.get(pk=user.pk) + except Dojo_User.DoesNotExist: + logger.error(f"Group post-save: No Dojo_User found for user with pk '{user.pk}'.") + return + if not settings.AZUREAD_TENANT_OAUTH2_GET_GROUPS: # Add the current user as the owner of the group member = Dojo_Group_Member() member.user = user diff --git a/dojo/models.py b/dojo/models.py index 1160fb8b608..6bde3ec4906 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -275,9 +275,11 @@ class UserContactInfo(models.Model): class Dojo_Group(models.Model): AZURE = "AzureAD" REMOTE = "Remote" + OIDC = "OIDC" SOCIAL_CHOICES = ( (AZURE, _("AzureAD")), (REMOTE, _("Remote")), + (OIDC, _("OIDC")), ) name = models.CharField(max_length=255, unique=True) description = models.CharField(max_length=4000, null=True, blank=True) diff --git a/dojo/pipeline.py b/dojo/pipeline.py index 8aaea4079bb..9ed07c54ac1 100644 --- a/dojo/pipeline.py +++ b/dojo/pipeline.py @@ -7,6 +7,7 @@ from django.conf import settings from social_core.backends.azuread_tenant import AzureADTenantOAuth2 from social_core.backends.google import GoogleOAuth2 +from social_core.backends.open_id_connect import OpenIdConnectAuth from dojo.authorization.roles_permissions import Permissions, Roles from dojo.models import Dojo_Group, Dojo_Group_Member, Product, Product_Member, Product_Type, Role @@ -106,6 +107,30 @@ def update_azure_groups(backend, uid, user=None, social=None, *args, **kwargs): cleanup_old_groups_for_user(user, group_names) +def update_oidc_groups(backend, uid, user=None, social=None, *args, **kwargs): + if settings.OIDC_AUTH_ENABLED and settings.OIDC_GET_GROUPS and isinstance(backend, OpenIdConnectAuth): + response = kwargs.get("response", {}) + group_names = response.get("groups", []) + if not group_names: + logger.warning("No 'groups' claim found in OIDC response. Skipping group assignment.") + return + logger.debug(f"OIDC groups received: {group_names}") + filtered_group_names = [] + group_filter = getattr(settings, "OIDC_GROUPS_FILTER", None) + for group_name in group_names: + try: + if group_filter and not re.search(group_filter, group_name): + logger.debug(f"Skipping group '{group_name}' due to OIDC_GROUPS_FILTER: {group_filter}") + continue + filtered_group_names.append(group_name) + except Exception as e: + logger.error(f"Error processing group '{group_name}': {e}") + if len(filtered_group_names) > 0: + assign_user_to_groups(user, filtered_group_names, Dojo_Group.OIDC) + if getattr(settings, "OIDC_CLEANUP_GROUPS", False): + cleanup_old_groups_for_user(user, filtered_group_names) + + def is_group_id(group): return bool(re.search(r"^[a-zA-Z0-9]{8,}-[a-zA-Z0-9]{4,}-[a-zA-Z0-9]{4,}-[a-zA-Z0-9]{4,}-[a-zA-Z0-9]{12,}$", group)) diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 1f24bd653f1..1bcd1e726cd 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -118,6 +118,9 @@ DD_SOCIAL_AUTH_REDIRECT_IS_HTTPS=(bool, False), # If true, the redirect after login will use the HTTPS protocol DD_SOCIAL_AUTH_TRAILING_SLASH=(bool, True), DD_SOCIAL_AUTH_OIDC_AUTH_ENABLED=(bool, False), + DD_SOCIAL_AUTH_OIDC_GET_GROUPS=(bool, False), + DD_SOCIAL_AUTH_OIDC_GROUPS_FILTER=(str, ""), + DD_SOCIAL_AUTH_OIDC_CLEANUP_GROUPS=(bool, True), DD_SOCIAL_AUTH_OIDC_OIDC_ENDPOINT=(str, ""), DD_SOCIAL_AUTH_OIDC_ID_KEY=(str, ""), DD_SOCIAL_AUTH_OIDC_KEY=(str, ""), @@ -131,6 +134,7 @@ DD_SOCIAL_AUTH_OIDC_USERINFO_URL=(str, ""), DD_SOCIAL_AUTH_OIDC_JWKS_URI=(str, ""), DD_SOCIAL_AUTH_OIDC_LOGIN_BUTTON_TEXT=(str, "Login with OIDC"), + DD_SOCIAL_AUTH_OIDC_SCOPE=(list, ["openid", "profile", "email", "groups"]), DD_SOCIAL_AUTH_AUTH0_OAUTH2_ENABLED=(bool, False), DD_SOCIAL_AUTH_AUTH0_KEY=(str, ""), DD_SOCIAL_AUTH_AUTH0_SECRET=(str, ""), @@ -580,6 +584,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "social_core.pipeline.social_auth.load_extra_data", "social_core.pipeline.user.user_details", "dojo.pipeline.update_azure_groups", + "dojo.pipeline.update_oidc_groups", "dojo.pipeline.update_product_access", ) @@ -638,6 +643,10 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param # Mandatory settings OIDC_AUTH_ENABLED = env("DD_SOCIAL_AUTH_OIDC_AUTH_ENABLED") +OIDC_GET_GROUPS = env("DD_SOCIAL_AUTH_OIDC_GET_GROUPS") +OIDC_GROUPS_FILTER = env("DD_SOCIAL_AUTH_OIDC_GROUPS_FILTER") +OIDC_CLEANUP_GROUPS = env("DD_SOCIAL_AUTH_OIDC_CLEANUP_GROUPS") +SOCIAL_AUTH_OIDC_SCOPE = env("DD_SOCIAL_AUTH_OIDC_SCOPE") SOCIAL_AUTH_OIDC_OIDC_ENDPOINT = env("DD_SOCIAL_AUTH_OIDC_OIDC_ENDPOINT") SOCIAL_AUTH_OIDC_KEY = env("DD_SOCIAL_AUTH_OIDC_KEY") SOCIAL_AUTH_OIDC_SECRET = env("DD_SOCIAL_AUTH_OIDC_SECRET") diff --git a/unittests/test_pipeline.py b/unittests/test_pipeline.py new file mode 100644 index 00000000000..02200180edf --- /dev/null +++ b/unittests/test_pipeline.py @@ -0,0 +1,109 @@ + +import unittest +from unittest.mock import ANY, MagicMock, patch + +from social_core.backends.azuread_tenant import AzureADTenantOAuth2 +from social_core.backends.open_id_connect import OpenIdConnectAuth + +from dojo.models import Dojo_Group +from dojo.pipeline import update_azure_groups, update_oidc_groups + + +class TestUpdateOIDCGroups(unittest.TestCase): + + @patch("dojo.pipeline.settings") + @patch("dojo.pipeline.assign_user_to_groups") + @patch("dojo.pipeline.cleanup_old_groups_for_user") + def test_update_oidc_groups_with_valid_groups(self, mock_cleanup, mock_assign, mock_settings): + mock_settings.OIDC_AUTH_ENABLED = True + mock_settings.OIDC_GET_GROUPS = True + mock_settings.OIDC_GROUPS_FILTER = ".*" + mock_settings.OIDC_CLEANUP_GROUPS = True + mock_backend = MagicMock(spec=OpenIdConnectAuth) + mock_user = MagicMock() + response = {"groups": ["admin", "user"]} + update_oidc_groups(mock_backend, uid="123", user=mock_user, response=response) + mock_assign.assert_called_once_with(mock_user, ["admin", "user"], ANY) + mock_cleanup.assert_called_once_with(mock_user, ["admin", "user"]) + + @patch("dojo.pipeline.settings") + def test_update_oidc_groups_with_no_groups(self, mock_settings): + mock_settings.OIDC_AUTH_ENABLED = True + mock_settings.OIDC_GET_GROUPS = True + mock_backend = MagicMock(spec=OpenIdConnectAuth) + mock_user = MagicMock() + response = {"groups": []} + with patch("dojo.pipeline.logger.warning") as mock_logger: + update_oidc_groups(mock_backend, uid="123", user=mock_user, response=response) + mock_logger.assert_called_once_with("No 'groups' claim found in OIDC response. Skipping group assignment.") + + @patch("dojo.pipeline.settings") + @patch("dojo.pipeline.assign_user_to_groups") + def test_update_oidc_groups_with_filter(self, mock_assign, mock_settings): + mock_settings.OIDC_AUTH_ENABLED = True + mock_settings.OIDC_GET_GROUPS = True + mock_settings.OIDC_GROUPS_FILTER = "^admin$" + mock_settings.OIDC_CLEANUP_GROUPS = False + mock_backend = MagicMock(spec=OpenIdConnectAuth) + mock_user = MagicMock() + response = {"groups": ["admin", "user", "guest"]} + update_oidc_groups(mock_backend, uid="123", user=mock_user, response=response) + mock_assign.assert_called_once_with(mock_user, ["admin"], ANY) + + +class TestUpdateAzureGroups(unittest.TestCase): + + @patch("dojo.pipeline.settings") + @patch("dojo.pipeline.assign_user_to_groups") + @patch("dojo.pipeline.cleanup_old_groups_for_user") + @patch("dojo.pipeline.requests.get") + def test_update_azure_groups_with_group_ids(self, mock_requests_get, mock_cleanup, mock_assign, mock_settings): + mock_settings.AZUREAD_TENANT_OAUTH2_ENABLED = True + mock_settings.AZUREAD_TENANT_OAUTH2_GET_GROUPS = True + mock_settings.AZUREAD_TENANT_OAUTH2_GROUPS_FILTER = None + mock_settings.AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS = True + mock_settings.REQUESTS_TIMEOUT = 5 + mock_backend = MagicMock(spec=AzureADTenantOAuth2) + mock_user = MagicMock() + mock_social = MagicMock() + mock_social.extra_data = { + "access_token": "fake-token", + "resource": "https://graph.microsoft.com", + } + mock_user.social_auth.order_by.return_value.first.return_value = mock_social + mock_response = {"groups": ["group-id-1", "group-id-2"]} + mock_requests_get.return_value.json.return_value = {"displayName": "GroupName"} + mock_requests_get.return_value.raise_for_status = MagicMock() + with patch("dojo.pipeline.is_group_id", return_value=True): + update_azure_groups(mock_backend, uid="123", user=mock_user, response=mock_response) + mock_assign.assert_called_once_with(mock_user, ["GroupName", "GroupName"], Dojo_Group.AZURE) + mock_cleanup.assert_called_once_with(mock_user, ["GroupName", "GroupName"]) + + @patch("dojo.pipeline.settings") + def test_update_azure_groups_with_no_groups(self, mock_settings): + mock_settings.AZUREAD_TENANT_OAUTH2_ENABLED = True + mock_settings.AZUREAD_TENANT_OAUTH2_GET_GROUPS = True + mock_backend = MagicMock(spec=AzureADTenantOAuth2) + mock_user = MagicMock() + mock_user.social_auth.order_by.return_value.first.return_value = MagicMock() + mock_response = {"groups": []} + with patch("dojo.pipeline.logger.warning") as mock_logger: + update_azure_groups(mock_backend, uid="123", user=mock_user, response=mock_response) + mock_logger.assert_called_once_with("No groups in response. Stopping to update groups of user based on azureAD") + + @patch("dojo.pipeline.settings") + @patch("dojo.pipeline.assign_user_to_groups") + def test_update_azure_groups_with_group_name_and_filter(self, mock_assign, mock_settings): + mock_settings.AZUREAD_TENANT_OAUTH2_ENABLED = True + mock_settings.AZUREAD_TENANT_OAUTH2_GET_GROUPS = True + mock_settings.AZUREAD_TENANT_OAUTH2_GROUPS_FILTER = "^admin$" + mock_settings.AZUREAD_TENANT_OAUTH2_CLEANUP_GROUPS = False + mock_backend = MagicMock(spec=AzureADTenantOAuth2) + mock_user = MagicMock() + mock_social = MagicMock() + mock_social.extra_data = {"access_token": "fake-token", "resource": "https://graph.microsoft.com"} + mock_user.social_auth.order_by.return_value.first.return_value = mock_social + mock_response = {"groups": ["admin", "user", "guest"]} + with patch("dojo.pipeline.is_group_id", return_value=False): + update_azure_groups(mock_backend, uid="123", user=mock_user, response=mock_response) + mock_assign.assert_called_once_with(mock_user, ["admin"], Dojo_Group.AZURE)