[AAP-48496] SAML Migration from Controller to Gateway (#6998)

This PR migrates the SAML configuration from the Controller
to the Gateway, it intentionally skips setting the CALLBACK_URL
so that the Gateway can fill in the appropriate URL.
This commit is contained in:
Madhu Kanoor
2025-07-10 09:57:00 -04:00
committed by thedoubl3j
parent c2c0f2b828
commit 512857c2a9
4 changed files with 155 additions and 21 deletions

View File

@@ -4,6 +4,7 @@ import os
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from awx.sso.utils.github_migrator import GitHubMigrator from awx.sso.utils.github_migrator import GitHubMigrator
from awx.sso.utils.oidc_migrator import OIDCMigrator 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 from awx.main.utils.gateway_client import GatewayClient, GatewayAPIError
@@ -52,6 +53,7 @@ class Command(BaseCommand):
if not skip_oidc: if not skip_oidc:
migrators.append(GitHubMigrator(gateway_client, self)) migrators.append(GitHubMigrator(gateway_client, self))
migrators.append(OIDCMigrator(gateway_client, self)) migrators.append(OIDCMigrator(gateway_client, self))
migrators.append(SAMLMigrator(gateway_client, self))
# if not skip_ldap: # if not skip_ldap:
# migrators.append(LDAPMigrator(gateway_client, self)) # migrators.append(LDAPMigrator(gateway_client, self))

View File

@@ -32,3 +32,33 @@ def existing_tacacsplus_user():
enterprise_auth = UserEnterpriseAuth(user=user, provider='tacacs+') enterprise_auth = UserEnterpriseAuth(user=user, provider='tacacs+')
enterprise_auth.save() enterprise_auth.save()
return user 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,
}
}

View File

@@ -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-----'

View File

@@ -4,14 +4,31 @@ SAML authenticator migrator.
This module handles the migration of SAML authenticators from AWX to Gateway. 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 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): class SAMLMigrator(BaseAuthenticatorMigrator):
""" """
Handles the migration of SAML authenticators from AWX to Gateway. 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): def get_authenticator_type(self):
"""Get the human-readable authenticator type name.""" """Get the human-readable authenticator type name."""
return "SAML" return "SAML"
@@ -24,29 +41,96 @@ class SAMLMigrator(BaseAuthenticatorMigrator):
Returns: Returns:
list: List of configured SAML authentication providers with their settings 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 = [] 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 return found_configs
def create_gateway_authenticator(self, config): def create_gateway_authenticator(self, config):
"""Create a SAML authenticator in Gateway.""" """Create a SAML authenticator in Gateway."""
# TODO: Implement SAML authenticator creation category = config["category"]
# When implementing, use this pattern for slug generation: config_settings = config["settings"]
# entity_id = settings.get('SOCIAL_AUTH_SAML_SP_ENTITY_ID', 'saml') name = config_settings["name"]
# authenticator_slug = self._generate_authenticator_slug('saml', category, entity_id)
# SAML requires complex configuration including: # Generate authenticator name and slug
# - SP entity ID, certificates, metadata authenticator_name = f"AWX-{category.replace('-', '_').title()}-{name}"
# - IdP configuration and metadata authenticator_slug = self._generate_authenticator_slug("saml", category, name)
# - Attribute mapping
self._write_output('SAML authenticator creation not yet implemented', 'warning') self._write_output(f"\n--- Processing {category} authenticator ---")
return False 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)