Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/0247_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', '0246_endpoint_idx_ep_product_lower_host_and_more'),
]

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 @@ -273,9 +273,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 @@ -117,6 +117,9 @@
DD_SOCIAL_LOGIN_AUTO_REDIRECT=(bool, False), # auto-redirect if there is only one social login method
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 @@ -130,6 +133,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 @@ -571,6 +575,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 @@ -628,6 +633,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)