From 71803aa0ed3f0facbd63fec20a2967bc60505891 Mon Sep 17 00:00:00 2001 From: Matthias Dellweg Date: Fri, 24 Apr 2026 11:24:49 +0200 Subject: [PATCH 1/2] WIP: Start saml2 sidecar in ci --- .github/workflows/scripts/before_install.sh | 5 +++++ ci_requirements.txt | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scripts/before_install.sh b/.github/workflows/scripts/before_install.sh index 5d3ac3ec404..a7efe5927a2 100755 --- a/.github/workflows/scripts/before_install.sh +++ b/.github/workflows/scripts/before_install.sh @@ -79,6 +79,11 @@ services: image: "docker.io/pulp/pulp-fixtures:latest" env: BASE_URL: "http://pulp-fixtures:8080" + - name: "saml2-idp" + image: "ghcr.io/pfrest/mock-saml2-idp:latest" + env: + SP_ENTITY_ID: "http://pulp" + SP_ACS_LOCATION: "http://pulp/saml/acs/" VARSYAML if [ "$TEST" = "s3" ]; then diff --git a/ci_requirements.txt b/ci_requirements.txt index 8b137891791..268d042d873 100644 --- a/ci_requirements.txt +++ b/ci_requirements.txt @@ -1 +1 @@ - +pulpcore[saml2] From 7ccac8f7500417ee06663fd612b1b69f4d0f77cf Mon Sep 17 00:00:00 2001 From: Matthias Dellweg Date: Thu, 13 Aug 2026 17:34:29 +0200 Subject: [PATCH 2/2] WIP --- docs/admin/guides/_SUMMARY.md | 1 + docs/admin/guides/auth/saml2.md | 12 ++++++ docs/admin/reference/settings.md | 5 ++- pulpcore/app/settings.py | 2 +- pulpcore/saml2/backends.py | 74 ++++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 docs/admin/guides/auth/saml2.md create mode 100644 pulpcore/saml2/backends.py diff --git a/docs/admin/guides/_SUMMARY.md b/docs/admin/guides/_SUMMARY.md index fe1d8f15e76..a4e63cd8f48 100644 --- a/docs/admin/guides/_SUMMARY.md +++ b/docs/admin/guides/_SUMMARY.md @@ -1,6 +1,7 @@ * Authentication * [Using Basic Auth](auth/basic.md) * [Using external service](auth/external.md) + * [Using SAML2](auth/saml2.md) * [Using Keycloak](auth/keycloak.md) * [Using JSON Header](auth/json_header.md) * auth/*.md diff --git a/docs/admin/guides/auth/saml2.md b/docs/admin/guides/auth/saml2.md new file mode 100644 index 00000000000..f13aa42e29b --- /dev/null +++ b/docs/admin/guides/auth/saml2.md @@ -0,0 +1,12 @@ +# SAML2 + +!!! warning + This is in feature-preview. + +The saml2 authentication provider can pass the `pulp_roles` attribute alongside the authentication credentials. +In order to attach e.g. the `file_uploader` role scoped to the `default` domain to the user, the following attribute is needed: + +`{"pulp_roles": ["file.file_uploader/default/"]}` + +[djangosaml2]: https://djangosaml2.readthedocs.io +[pysaml2]: https://pysaml2.readthedocs.io diff --git a/docs/admin/reference/settings.md b/docs/admin/reference/settings.md index 0d2a660bd3b..e8e9b820e5a 100644 --- a/docs/admin/reference/settings.md +++ b/docs/admin/reference/settings.md @@ -36,7 +36,7 @@ See the [Django authentication documentation] for more information. When installed with the `[saml2]` option, and the `SAML_CONFIG` is set, SSO authentification according to the SAML2 protocols is available. -See [django] and [pysaml2] for details. +See [SAML2], [djangosaml2] and [pysaml2] for details. !!! warning This is in feature-preview. @@ -635,6 +635,7 @@ Defaults to `pulpcore.tasking.status`. [on-demand and streaming limitations]: site:pulpcore/docs/user/learn/on-demand-downloading/#on-demand-and-streamed-limitations [pysaml2]: https://pysaml2.readthedocs.io [recommended by aiohttp]: https://docs.aiohttp.org/en/stable/third_party.html#approved-third-party-libraries -[task diagnostics documentation]: site:pulpcore/docs/dev/learn/tasks/diagnostics.md +[SAML2]: site:pulpcore/docs/admin/guides/auth/saml2/ +[task diagnostics documentation]: site:pulpcore/docs/dev/learn/tasks/diagnostics/ [uvloop]: https://github.com/MagicStack/uvloop [Webserver Auth with Reverse Proxy]: site:pulpcore/docs/admin/guides/auth/external/#webserver-auth-with-reverse-proxy diff --git a/pulpcore/app/settings.py b/pulpcore/app/settings.py index d34aebf8a17..8bdc63d3f95 100644 --- a/pulpcore/app/settings.py +++ b/pulpcore/app/settings.py @@ -610,7 +610,7 @@ def saml2_settings_hook(settings): if "SAML_CONFIG" in settings: data["INSTALLED_APPS"] = ["djangosaml2"] data["MIDDLEWARE"] = ["djangosaml2.middleware.SamlSessionMiddleware"] - data["AUTHENTICATION_BACKENDS"] = ["djangosaml2.backends.Saml2Backend"] + data["AUTHENTICATION_BACKENDS"] = ["pulpcore.saml2.backends.PulpSaml2Backend"] if "LOGIN_URL" not in settings: data["LOGIN_URL"] = "/saml2/login/" if "SESSION_COOKIE_SECURE" not in settings: diff --git a/pulpcore/saml2/backends.py b/pulpcore/saml2/backends.py new file mode 100644 index 00000000000..cbb82884ac6 --- /dev/null +++ b/pulpcore/saml2/backends.py @@ -0,0 +1,74 @@ +import sys +from logging import getLogger +from django.contrib.contenttypes.models import ContentType + +from djangosaml2.backends import Saml2Backend + +from pulpcore.app.models import Domain +from pulpcore.app.models.role import Role +from pulpcore.app.util import resolve_prn + + +_logger = getLogger(__name__) + + +def _parse_role_assignment(role_assignment): + try: + _logger.debug("Considering role-assignment '%s'.", role_assignment) + # TODO Is '/' a good choice here? + role_name, domain_name, obj_prn = role_assignment.split("/") # ':' is used in PRNs + role = Role.objects.get(name=role_name) + domain = None if domain_name == "" else Domain.objects.get(name=domain_name) + obj = None if obj_prn == "" else resolve_prn(obj_prn) + except Exception as e: + _logger.warning( + "Could not sync role-assignment '%s' from saml2 attributes.", + role_assignment, + exc_info=sys.exc_info(), + ) + return None + return role, domain, obj + + +class PulpSaml2Backend(Saml2Backend): + def _update_user( + self, user, attributes: dict, attribute_mapping: dict, force_save: bool = False + ): + if "pulp_roles" in attributes: + _logger.debug("Sync role assignments for user '%s'.", user.username) + role_assignments = ( + item + for item in map(_parse_role_assignment, attributes["pulp_roles"]) + if item is not None + ) + + if user.pk is not None: + # Adjust roles for existing user. + assignment_pks = [] + for role, domain, obj in role_assignments: + if obj is None: + content_type = None + obj_pk = None + else: + raise NotImplemented("SAML2 roles with objects") + content_type = ContentType.objects.get_for_model(obj, for_concrete_model=False) + obj_pk = obj.pk + user_role = user.object_roles.filter( + role=role, domain=domain, content_type=content_type, object_id=obj_pk + ).first() + if user_role is None: + user_role = user.object_roles.create( + role=role, domain=domain, content_object=obj + ) + _logger.debug("Created.") + else: + _logger.debug("Found.") + assignment_pks.append(user_role.pk) + user.object_roles.exclude(pk__in=assignment_pks).delete() + else: + user.save() + _logger.debug("New user object; create all role assignments.") + for role, domain, obj in role_assignments: + user.object_roles.create(role=role, domain=domain, content_object=obj) + + return super()._update_user(user, attributes, attribute_mapping, force_save)