From 2bfbc8c05a61e96eb0b9c41f80cfbc02f045d432 Mon Sep 17 00:00:00 2001 From: Filipe Pina Date: Sat, 16 Nov 2024 01:11:16 +0000 Subject: [PATCH 1/2] allow mapping SAML2 IdP groups to local ones --- dojo/backends.py | 82 +++++++++++++++++++++++ dojo/settings/.settings.dist.py.sha256sum | 2 +- dojo/settings/settings.dist.py | 9 ++- 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 dojo/backends.py diff --git a/dojo/backends.py b/dojo/backends.py new file mode 100644 index 00000000000..adb0ab4f39c --- /dev/null +++ b/dojo/backends.py @@ -0,0 +1,82 @@ +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 not settings.SAML2_ENABLED or not settings.SAML2_GROUPS_ATTRIBUTE or not settings.SAML2_GROUPS_FILTER: + return None + return re.compile(settings.SAML2_GROUPS_FILTER) + + def _update_user( + self, + user, + attributes: dict, + attribute_mapping: dict, + force_save=False, + ): + """ + 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... + + This does NOT create the groups if they do not exist. They have to be created in the UI + This is not a big issue and it works around an existing bug with dojo/group/utils.py::group_post_save_handler (user does not yet exist and he is forcefully added to the new group - boom) + """ + user = super()._update_user(user, attributes, attribute_mapping, force_save=force_save) + if self.group_re is None: + return user + + # list of all existing "SAML2-mapped" groups + all_saml_groups = {group.name: group for group in Dojo_Group.objects.all() if self.group_re.match(group.name)} + + # 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: + logger.error("Group %s is mapped for SAML2 but it does not exist in Dojo", group_name) + else: + 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/settings/.settings.dist.py.sha256sum b/dojo/settings/.settings.dist.py.sha256sum index f4b9461b96e..1d803fb0529 100644 --- a/dojo/settings/.settings.dist.py.sha256sum +++ b/dojo/settings/.settings.dist.py.sha256sum @@ -1 +1 @@ -01215b397651163c0403b028adb08b18fa83c4abb188b0536dfb9e43eddcd9cd +a3b00e1e7ef4d362201f8f2cb4b217fa4c72695008b8f0b6996853ed3371c29c diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 8c68bf88005..0cc02520ac5 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -155,7 +155,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"), @@ -177,6 +177,11 @@ "Lastname": "last_name", }), DD_SAML2_ALLOW_UNKNOWN_ATTRIBUTE=(bool, False), + # 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 need to already exist in Dojo. And if value is not set, no group processing is done + DD_SAML2_GROUPS_FILTER=(str, ""), + # SAML2 attribute with groups to match in Dojo. And if value is not set, no group processing is done + DD_SAML2_GROUPS_ATTRIBUTE=(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. @@ -1054,6 +1059,8 @@ def saml2_attrib_map_format(dict): }, "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 From 7f45acb972c9839aa64b07ff558d093926a5e31a Mon Sep 17 00:00:00 2001 From: Filipe Pina <636320+fopina@users.noreply.github.com> Date: Sat, 16 Nov 2024 23:02:05 +0000 Subject: [PATCH 2/2] Update dojo/backends.py Co-authored-by: kiblik <5609770+kiblik@users.noreply.github.com> --- dojo/backends.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dojo/backends.py b/dojo/backends.py index adb0ab4f39c..d2ec50bb092 100644 --- a/dojo/backends.py +++ b/dojo/backends.py @@ -17,9 +17,9 @@ class Saml2Backend(_Saml2Backend): @cached_property def group_re(self): - if not settings.SAML2_ENABLED or not settings.SAML2_GROUPS_ATTRIBUTE or not settings.SAML2_GROUPS_FILTER: - return None - return re.compile(settings.SAML2_GROUPS_FILTER) + if settings.SAML2_ENABLED and settings.SAML2_GROUPS_ATTRIBUTE and settings.SAML2_GROUPS_FILTER: + return re.compile(settings.SAML2_GROUPS_FILTER) + return None def _update_user( self,