Skip to content
Open
11 changes: 11 additions & 0 deletions docs/content/en/customize_dojo/user_management/configure_sso.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<span style="background-color:rgba(242, 86, 29, 0.3)">DefectDojo Pro</span> 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).
Expand Down
18 changes: 18 additions & 0 deletions dojo/db_migrations/0252_alter_dojo_group_social_provider.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
19 changes: 16 additions & 3 deletions dojo/group/utils.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions dojo/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions dojo/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand Down
9 changes: 9 additions & 0 deletions dojo/settings/settings.dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ""),
Expand All @@ -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, ""),
Expand Down Expand Up @@ -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",
)

Expand Down Expand Up @@ -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")
Expand Down
109 changes: 109 additions & 0 deletions unittests/test_pipeline.py
Original file line number Diff line number Diff line change
@@ -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)
Loading