[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
No known key found for this signature in database
GPG Key ID: E84C42ACF75B0768
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 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))

View File

@ -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,
}
}

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.
"""
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)