diff --git a/dojo/backends.py b/dojo/backends.py new file mode 100644 index 00000000000..46699f7cdc0 --- /dev/null +++ b/dojo/backends.py @@ -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 diff --git a/dojo/db_migrations/0245_alter_dojo_group_social_provider.py b/dojo/db_migrations/0245_alter_dojo_group_social_provider.py new file mode 100644 index 00000000000..6e5f1aa8ca8 --- /dev/null +++ b/dojo/db_migrations/0245_alter_dojo_group_social_provider.py @@ -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'), + ), + ] diff --git a/dojo/group/utils.py b/dojo/group/utils.py index d2245dac2a6..bf3fd65e9c5 100644 --- a/dojo/group/utils.py +++ b/dojo/group/utils.py @@ -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 @@ -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 diff --git a/dojo/models.py b/dojo/models.py index e3958d76172..f7441ba4e3c 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -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) diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 5b4713a9c43..baf8a340fb7 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -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"), @@ -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. @@ -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