diff --git a/awx/main/management/commands/import_auth_config_to_gateway.py b/awx/main/management/commands/import_auth_config_to_gateway.py index babe849092..ae93058bca 100644 --- a/awx/main/management/commands/import_auth_config_to_gateway.py +++ b/awx/main/management/commands/import_auth_config_to_gateway.py @@ -4,6 +4,7 @@ import os from django.core.management.base import BaseCommand from awx.sso.utils.github_migrator import GitHubMigrator from awx.sso.utils.oidc_migrator import OIDCMigrator +from awx.sso.utils.saml_migrator import SAMLMigrator from awx.main.utils.gateway_client import GatewayClient, GatewayAPIError @@ -52,6 +53,7 @@ class Command(BaseCommand): if not skip_oidc: migrators.append(GitHubMigrator(gateway_client, self)) migrators.append(OIDCMigrator(gateway_client, self)) + migrators.append(SAMLMigrator(gateway_client, self)) # if not skip_ldap: # migrators.append(LDAPMigrator(gateway_client, self)) diff --git a/awx/sso/tests/conftest.py b/awx/sso/tests/conftest.py index f94b1c528f..5cb719808c 100644 --- a/awx/sso/tests/conftest.py +++ b/awx/sso/tests/conftest.py @@ -32,3 +32,33 @@ def existing_tacacsplus_user(): enterprise_auth = UserEnterpriseAuth(user=user, provider='tacacs+') enterprise_auth.save() return user + + +@pytest.fixture +def test_saml_config(settings): + settings.SAML_SECURITY_CONFIG = { + "wantNameId": True, + "signMetadata": False, + "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", + "nameIdEncrypted": False, + "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "authnRequestsSigned": False, + "logoutRequestSigned": False, + "wantNameIdEncrypted": False, + "logoutResponseSigned": False, + "wantAssertionsSigned": True, + "requestedAuthnContext": False, + "wantAssertionsEncrypted": False, + } + settings.SOCIAL_AUTH_SAML_ENABLED_IDPS = { + "example": { + "attr_email": "email", + "attr_first_name": "first_name", + "attr_last_name": "last_name", + "attr_user_permanent_id": "username", + "attr_username": "username", + "entity_id": "https://www.example.com/realms/sample", + "url": "https://www.example.com/realms/sample/protocol/saml", + "x509cert": "A" * 64 + "B" * 64 + "C" * 23, + } + } diff --git a/awx/sso/tests/unit/test_saml_migrator.py b/awx/sso/tests/unit/test_saml_migrator.py new file mode 100644 index 0000000000..4b8ab2dc12 --- /dev/null +++ b/awx/sso/tests/unit/test_saml_migrator.py @@ -0,0 +1,18 @@ +import pytest +from unittest.mock import MagicMock +from awx.sso.utils.saml_migrator import SAMLMigrator + + +@pytest.mark.django_db +def test_get_controller_config(test_saml_config): + gateway_client = MagicMock() + command_obj = MagicMock() + obj = SAMLMigrator(gateway_client, command_obj) + + result = obj.get_controller_config() + lines = result[0]['settings']['configuration']['IDP_X509_CERT'].splitlines() + assert lines[0] == '-----BEGIN CERTIFICATE-----' + assert lines[1] == "A" * 64 + assert lines[2] == "B" * 64 + assert lines[3] == "C" * 23 + assert lines[-1] == '-----END CERTIFICATE-----' diff --git a/awx/sso/utils/saml_migrator.py b/awx/sso/utils/saml_migrator.py index 31ab06bdd9..506b0c3736 100644 --- a/awx/sso/utils/saml_migrator.py +++ b/awx/sso/utils/saml_migrator.py @@ -4,14 +4,31 @@ SAML authenticator migrator. This module handles the migration of SAML authenticators from AWX to Gateway. """ +from django.conf import settings + +from awx.main.utils.gateway_mapping import org_map_to_gateway_format, team_map_to_gateway_format from awx.sso.utils.base_migrator import BaseAuthenticatorMigrator +def _split_chunks(data: str, length: int = 64) -> list[str]: + return [data[i : i + length] for i in range(0, len(data), length)] + + +def _to_pem_cert(data: str) -> list[str]: + items = ["-----BEGIN CERTIFICATE-----"] + items += _split_chunks(data) + items.append("-----END CERTIFICATE-----") + return items + + class SAMLMigrator(BaseAuthenticatorMigrator): """ Handles the migration of SAML authenticators from AWX to Gateway. """ + CATEGORY = "SAML" + AUTH_TYPE = "ansible_base.authentication.authenticator_plugins.saml" + def get_authenticator_type(self): """Get the human-readable authenticator type name.""" return "SAML" @@ -24,29 +41,96 @@ class SAMLMigrator(BaseAuthenticatorMigrator): Returns: list: List of configured SAML authentication providers with their settings """ - # TODO: Implement SAML configuration retrieval - # SAML settings typically include: - # - SOCIAL_AUTH_SAML_SP_ENTITY_ID - # - SOCIAL_AUTH_SAML_SP_PUBLIC_CERT - # - SOCIAL_AUTH_SAML_SP_PRIVATE_KEY - # - SOCIAL_AUTH_SAML_ORG_INFO - # - SOCIAL_AUTH_SAML_TECHNICAL_CONTACT - # - SOCIAL_AUTH_SAML_SUPPORT_CONTACT - # - SOCIAL_AUTH_SAML_ENABLED_IDPS - # - SOCIAL_AUTH_SAML_ORGANIZATION_MAP - # - SOCIAL_AUTH_SAML_TEAM_MAP found_configs = [] + + enabled = True + remove_users = True + create_objects = getattr(settings, "SAML_AUTO_CREATE_OBJECTS", True) + idps = getattr(settings, "SOCIAL_AUTH_SAML_ENABLED_IDPS", {}) + security_config = getattr(settings, "SOCIAL_AUTH_SAML_SECURITY_CONFIG", {}) + + org_map_value = getattr(settings, "SOCIAL_AUTH_SAML_ORGANIZATION_MAP", None) + team_map_value = getattr(settings, "SOCIAL_AUTH_SAML_TEAM_MAP", None) + extra_data = getattr(settings, "SOCIAL_AUTH_SAML_EXTRA_DATA", None) + support_contact = getattr(settings, "SOCIAL_AUTH_SAML_SUPPORT_CONTACT", {}) + technical_contact = getattr(settings, "SOCIAL_AUTH_SAML_TECHNICAL_CONTACT", {}) + org_info = getattr(settings, "SOCIAL_AUTH_SAML_ORG_INFO", {}) + + sp_private_key = getattr(settings, "SOCIAL_AUTH_SAML_SP_PRIVATE_KEY", None) + sp_public_cert = getattr(settings, "SOCIAL_AUTH_SAML_SP_PUBLIC_CERT", None) + sp_entity_id = getattr(settings, "SOCIAL_AUTH_SAML_SP_ENTITY_ID", None) + sp_extra = getattr(settings, "SOCIAL_AUTH_SAML_SP_EXTRA", {}) + + org_mappers, next_order = org_map_to_gateway_format(org_map_value, start_order=1) + team_mappers, _ = team_map_to_gateway_format(team_map_value, start_order=next_order) + + for name, value in idps.items(): + config_data = { + "name": name, + "type": self.AUTH_TYPE, + "enabled": enabled, + "create_objects": create_objects, + "remove_users": remove_users, + "configuration": { + "IDP_URL": value.get("url"), + "IDP_X509_CERT": "\n".join(_to_pem_cert(value.get("x509cert"))), + "IDP_ENTITY_ID": value.get("entity_id"), + "IDP_ATTR_EMAIL": value.get("attr_email"), + "IDP_ATTR_USERNAME": value.get("attr_username"), + "IDP_ATTR_FIRST_NAME": value.get("attr_first_name"), + "IDP_ATTR_LAST_NAME": value.get("attr_last_name"), + "IDP_ATTR_USER_PERMANENT_ID": value.get("attr_user_permanent_id"), + "IDP_GROUPS": value.get("attr_groups"), + "SP_ENTITY_ID": sp_entity_id, + "SP_PUBLIC_CERT": sp_public_cert, + "SP_PRIVATE_KEY": sp_private_key, + "ORG_INFO": org_info, + "TECHNICAL_CONTACT": technical_contact, + "SUPPORT_CONTACT": support_contact, + "SECURITY_CONFIG": security_config, + "SP_EXTRA": sp_extra, + "EXTRA_DATA": extra_data, + }, + } + + found_configs.append( + { + "category": self.CATEGORY, + "settings": config_data, + "org_mappers": org_mappers, + "team_mappers": team_mappers, + } + ) return found_configs def create_gateway_authenticator(self, config): """Create a SAML authenticator in Gateway.""" - # TODO: Implement SAML authenticator creation - # When implementing, use this pattern for slug generation: - # entity_id = settings.get('SOCIAL_AUTH_SAML_SP_ENTITY_ID', 'saml') - # authenticator_slug = self._generate_authenticator_slug('saml', category, entity_id) - # SAML requires complex configuration including: - # - SP entity ID, certificates, metadata - # - IdP configuration and metadata - # - Attribute mapping - self._write_output('SAML authenticator creation not yet implemented', 'warning') - return False + category = config["category"] + config_settings = config["settings"] + name = config_settings["name"] + + # Generate authenticator name and slug + authenticator_name = f"AWX-{category.replace('-', '_').title()}-{name}" + authenticator_slug = self._generate_authenticator_slug("saml", category, name) + + self._write_output(f"\n--- Processing {category} authenticator ---") + self._write_output(f"Name: {authenticator_name}") + self._write_output(f"Slug: {authenticator_slug}") + self._write_output(f"Type: {config_settings['type']}") + + # Build Gateway authenticator configuration + gateway_config = { + "name": authenticator_name, + "slug": authenticator_slug, + "type": config_settings["type"], + "enabled": True, + "create_objects": True, # Allow Gateway to create users/orgs/teams + "remove_users": False, # Don't remove users by default + "configuration": config_settings["configuration"], + } + + # CALLBACK_URL - automatically created by Gateway + ignore_keys = ["CALLBACK_URL"] + + # Submit the authenticator (create or update as needed) + return self.submit_authenticator(gateway_config, ignore_keys, config)