Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
88 changes: 88 additions & 0 deletions dojo/backends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import logging
import re
from functools import cached_property

from django.conf import settings
from djangosaml2.backends import Saml2Backend as _Saml2Backend

from dojo.authorization.roles_permissions import Roles
from dojo.models import Dojo_Group, Dojo_Group_Member, Role

logger = logging.getLogger(__name__)


class Saml2Backend(_Saml2Backend):

"""Subclass to handle adding SAML2 groups as DefectDojo/Django groups to a user"""

@cached_property
def group_re(self):
if settings.SAML2_ENABLED and settings.SAML2_GROUPS_ATTRIBUTE:
if settings.SAML2_GROUPS_FILTER:
return re.compile(settings.SAML2_GROUPS_FILTER)
return re.compile(r".*")
return None

def _update_user(
self,
user,
attributes: dict,
attribute_mapping: dict,
force_save=False, # noqa: FBT002 - caannot add `*`, need to keep same signature as parent class
):
"""
Method overriden to handle groups after user object is saved.
Ideally we would only override "public" methods: in this case, get_or_create_user() would be the one but it doesn't save the NEW user
We could override that AND save_user() (each to handle new or existing users) but the latter does not receive the attributes which include the groups...

Similar to AzureAD, this creates the matching SAML groups if they do not already exist.
"""
user = super()._update_user(user, attributes, attribute_mapping, force_save=force_save)
if self.group_re is None:
return user

self._process_user_groups(user, attributes)
return user

def _process_user_groups(self, user, attributes):
# list of all existing "SAML2-mapped" groups - regexp excluded so the ones no longer matching regexp are removed
all_saml_groups = {group.name: group for group in Dojo_Group.objects.filter(social_provider=Dojo_Group.SAML)}

# list of groups user MUST have
needs_groups = set()
if attributes[settings.SAML2_GROUPS_ATTRIBUTE]:
needs_groups.update(
group_name
for group_name in attributes[settings.SAML2_GROUPS_ATTRIBUTE]
if self.group_re.match(group_name)
)

# list of groups user ALREADY has
has_groups = {
dgm.group.name: dgm
for dgm in Dojo_Group_Member.objects.filter(user_id=user.id).select_related("group")
if dgm.group.name in all_saml_groups
}

groups_to_remove = has_groups.keys() - needs_groups
groups_to_add = needs_groups - has_groups.keys()

if groups_to_remove:
# bulk .delete() can be used as it emits post_delete signal
deleted, _ = Dojo_Group_Member.objects.filter(user_id=user.id, group__name__in=groups_to_remove).delete()
logger.info("User %s removed from SAML2 groups: %s", user, ", ".join(groups_to_remove))
if deleted != len(groups_to_remove):
logger.error("User %s had %d groups to be removed but %d were", user, len(groups_to_remove), deleted)

if groups_to_add:
# .bulk_create() cannot be used as it does NOT emit post_save signal
reader_role = Role.objects.get(id=Roles.Reader)
for group_name in groups_to_add:
group = all_saml_groups.get(group_name)
if group is None:
group = Dojo_Group.objects.create(name=group_name, social_provider=Dojo_Group.SAML)
logger.error("Group %s did not exist locally so it was created", group_name)

Dojo_Group_Member.objects.create(group=group, user_id=user.pk, role=reader_role)
logger.debug("User %s became member of SAML2 group: %s", user, group.name)
return user
18 changes: 18 additions & 0 deletions dojo/db_migrations/0245_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.12 on 2025-10-05 14:11

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('dojo', '0244_pghistory_indices'),
]

operations = [
migrations.AlterField(
model_name='dojo_group',
name='social_provider',
field=models.CharField(blank=True, choices=[('AzureAD', 'AzureAD'), ('Remote', 'Remote'), ('SAML2', 'SAML2')], help_text='Group imported from a social provider.', max_length=10, null=True, verbose_name='Social Authentication Provider'),
),
]
3 changes: 1 addition & 2 deletions dojo/group/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from crum import get_current_user
from django.conf import settings
from django.contrib.auth.models import Group
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
Expand Down Expand Up @@ -32,7 +31,7 @@ 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 user and not group.social_provider:
# 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"
SAML = "SAML2"
SOCIAL_CHOICES = (
(AZURE, _("AzureAD")),
(REMOTE, _("Remote")),
(SAML, _("SAML2")),
)
name = models.CharField(max_length=255, unique=True)
description = models.CharField(max_length=4000, null=True, blank=True)
Expand Down
9 changes: 8 additions & 1 deletion dojo/settings/settings.dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@
DD_SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET=(str, ""),
DD_SAML2_ENABLED=(bool, False),
# Allows to override default SAML authentication backend. Check https://djangosaml2.readthedocs.io/contents/setup.html#custom-user-attributes-processing
DD_SAML2_AUTHENTICATION_BACKENDS=(str, "djangosaml2.backends.Saml2Backend"),
DD_SAML2_AUTHENTICATION_BACKENDS=(str, "dojo.backends.Saml2Backend"),
# Force Authentication to make SSO possible with SAML2
DD_SAML2_FORCE_AUTH=(bool, True),
DD_SAML2_LOGIN_BUTTON_TEXT=(str, "Login with SAML"),
Expand All @@ -196,6 +196,11 @@
"Lastname": "last_name",
}),
DD_SAML2_ALLOW_UNKNOWN_ATTRIBUTE=(bool, False),
# SAML2 attribute with groups to match in Dojo. If value is not set, no group processing is done.
DD_SAML2_GROUPS_ATTRIBUTE=(str, ""),
# similar to DD_SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_GROUPS_FILTER, regular expression for which SAML2 groups to map to Dojo groups (with same name)
# Groups will be created if needed. If value is not set, any group will match.
DD_SAML2_GROUPS_FILTER=(str, ""),
# Authentication via HTTP Proxy which put username to HTTP Header REMOTE_USER
DD_AUTH_REMOTEUSER_ENABLED=(bool, False),
# Names of headers which will be used for processing user data.
Expand Down Expand Up @@ -1115,6 +1120,8 @@ def saml2_attrib_map_format(din):
},
"valid_for": 24, # how long is our metadata valid
}
SAML2_GROUPS_ATTRIBUTE = env("DD_SAML2_GROUPS_ATTRIBUTE")
SAML2_GROUPS_FILTER = env("DD_SAML2_GROUPS_FILTER")

# ------------------------------------------------------------------------------
# REMOTE_USER
Expand Down