From 8e58fee49cc43125a93fe7e14fc871b4f8b12a69 Mon Sep 17 00:00:00 2001 From: Fabricio Aguiar Date: Fri, 18 Jul 2025 14:49:39 +0100 Subject: [PATCH] feat: Add migrator for Google OAuth2 authenticator (#7018) Signed-off-by: Fabricio Aguiar --- .../commands/import_auth_config_to_gateway.py | 6 + .../tests/unit/test_google_oauth2_migrator.py | 104 ++++++++++++++++++ awx/sso/utils/__init__.py | 17 +++ awx/sso/utils/google_oauth2_migrator.py | 85 ++++++++++---- 4 files changed, 191 insertions(+), 21 deletions(-) create mode 100644 awx/sso/tests/unit/test_google_oauth2_migrator.py 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 51d4838ea5..ca93a98534 100644 --- a/awx/main/management/commands/import_auth_config_to_gateway.py +++ b/awx/main/management/commands/import_auth_config_to_gateway.py @@ -9,6 +9,7 @@ from awx.sso.utils.oidc_migrator import OIDCMigrator from awx.sso.utils.saml_migrator import SAMLMigrator from awx.sso.utils.radius_migrator import RADIUSMigrator from awx.sso.utils.tacacs_migrator import TACACSMigrator +from awx.sso.utils.google_oauth2_migrator import GoogleOAuth2Migrator from awx.main.utils.gateway_client import GatewayClient, GatewayAPIError @@ -22,6 +23,7 @@ class Command(BaseCommand): parser.add_argument('--skip-saml', action='store_true', help='Skip importing SAML authenticator') parser.add_argument('--skip-radius', action='store_true', help='Skip importing RADIUS authenticator') parser.add_argument('--skip-tacacs', action='store_true', help='Skip importing TACACS+ authenticator') + parser.add_argument('--skip-google', action='store_true', help='Skip importing Google OAuth2 authenticator') parser.add_argument('--force', action='store_true', help='Force migration even if configurations already exist') def handle(self, *args, **options): @@ -37,6 +39,7 @@ class Command(BaseCommand): skip_saml = options['skip_saml'] skip_radius = options['skip_radius'] skip_tacacs = options['skip_tacacs'] + skip_google = options['skip_google'] force = options['force'] # If the management command isn't called with all parameters needed to talk to Gateway, consider @@ -83,6 +86,9 @@ class Command(BaseCommand): if not skip_tacacs: migrators.append(TACACSMigrator(gateway_client, self, force=force)) + if not skip_google: + migrators.append(GoogleOAuth2Migrator(gateway_client, self, force=force)) + # Run migrations total_results = { 'created': 0, diff --git a/awx/sso/tests/unit/test_google_oauth2_migrator.py b/awx/sso/tests/unit/test_google_oauth2_migrator.py new file mode 100644 index 0000000000..c098afde99 --- /dev/null +++ b/awx/sso/tests/unit/test_google_oauth2_migrator.py @@ -0,0 +1,104 @@ +import pytest +from unittest.mock import MagicMock +from awx.sso.utils.google_oauth2_migrator import GoogleOAuth2Migrator + + +@pytest.fixture +def test_google_config(settings): + settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = "test_key" + settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = "test_secret" + settings.SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL = "https://tower.example.com/sso/complete/google-oauth2/" + settings.SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP = {"My Org": {"users": True}} + settings.SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP = {"My Team": {"organization": "My Org", "users": True}} + settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ["profile", "email"] + + +@pytest.mark.django_db +def test_get_controller_config(test_google_config): + gateway_client = MagicMock() + command_obj = MagicMock() + obj = GoogleOAuth2Migrator(gateway_client, command_obj) + + result = obj.get_controller_config() + assert len(result) == 1 + config = result[0] + assert config['category'] == 'Google OAuth2' + settings = config['settings'] + assert settings['SOCIAL_AUTH_GOOGLE_OAUTH2_KEY'] == 'test_key' + assert settings['SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET'] == 'test_secret' + assert settings['SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL'] == "https://tower.example.com/sso/complete/google-oauth2/" + assert settings['SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE'] == ["profile", "email"] + # Assert that other settings are not present in the returned config + assert 'SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP' not in settings + assert 'SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP' not in settings + + +@pytest.mark.django_db +def test_create_gateway_authenticator(mocker, test_google_config): + mocker.patch('django.conf.settings.LOGGING', {}) + + gateway_client = MagicMock() + command_obj = MagicMock() + obj = GoogleOAuth2Migrator(gateway_client, command_obj) + mock_submit = MagicMock(return_value=True) + obj.submit_authenticator = mock_submit + + configs = obj.get_controller_config() + result = obj.create_gateway_authenticator(configs[0]) + + assert result is True + mock_submit.assert_called_once() + + # Assert payload sent to gateway + payload = mock_submit.call_args[0][0] + assert payload['name'] == 'google' + assert payload['slug'] == 'aap-google-oauth2-google-oauth2' + assert payload['type'] == 'ansible_base.authentication.authenticator_plugins.google_oauth2' + assert payload['enabled'] is True + assert payload['create_objects'] is True + assert payload['remove_users'] is False + + # Assert configuration details + configuration = payload['configuration'] + assert configuration['KEY'] == 'test_key' + assert configuration['SECRET'] == 'test_secret' + assert configuration['CALLBACK_URL'] == 'https://tower.example.com/sso/complete/google-oauth2/' + assert configuration['SCOPE'] == ['profile', 'email'] + + # Assert mappers + assert len(payload['mappers']) == 2 + assert payload['mappers'][0]['map_type'] == 'organization' + assert payload['mappers'][1]['map_type'] == 'team' + + # Assert ignore_keys + ignore_keys = mock_submit.call_args[0][1] + assert ignore_keys == ["ACCESS_TOKEN_METHOD", "REVOKE_TOKEN_METHOD"] + + +@pytest.mark.django_db +def test_create_gateway_authenticator_no_optional_values(mocker, settings): + settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = "test_key" + settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = "test_secret" + settings.SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP = {} + settings.SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP = {} + settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = None + settings.SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL = None + + mocker.patch('django.conf.settings.LOGGING', {}) + + gateway_client = MagicMock() + command_obj = MagicMock() + obj = GoogleOAuth2Migrator(gateway_client, command_obj) + mock_submit = MagicMock(return_value=True) + obj.submit_authenticator = mock_submit + + configs = obj.get_controller_config() + obj.create_gateway_authenticator(configs[0]) + + payload = mock_submit.call_args[0][0] + assert 'CALLBACK_URL' not in payload['configuration'] + assert 'SCOPE' not in payload['configuration'] + + ignore_keys = mock_submit.call_args[0][1] + assert 'CALLBACK_URL' in ignore_keys + assert 'SCOPE' in ignore_keys diff --git a/awx/sso/utils/__init__.py b/awx/sso/utils/__init__.py index e69de29bb2..4d9f494723 100644 --- a/awx/sso/utils/__init__.py +++ b/awx/sso/utils/__init__.py @@ -0,0 +1,17 @@ +from awx.sso.utils.azure_ad_migrator import AzureADMigrator +from awx.sso.utils.github_migrator import GitHubMigrator +from awx.sso.utils.google_oauth2_migrator import GoogleOAuth2Migrator +from awx.sso.utils.ldap_migrator import LDAPMigrator +from awx.sso.utils.oidc_migrator import OIDCMigrator +from awx.sso.utils.radius_migrator import RADIUSMigrator +from awx.sso.utils.saml_migrator import SAMLMigrator + +__all__ = [ + 'AzureADMigrator', + 'GitHubMigrator', + 'GoogleOAuth2Migrator', + 'LDAPMigrator', + 'OIDCMigrator', + 'RADIUSMigrator', + 'SAMLMigrator', +] diff --git a/awx/sso/utils/google_oauth2_migrator.py b/awx/sso/utils/google_oauth2_migrator.py index 8c21cd8f7f..8be1914503 100644 --- a/awx/sso/utils/google_oauth2_migrator.py +++ b/awx/sso/utils/google_oauth2_migrator.py @@ -4,6 +4,7 @@ Google OAuth2 authenticator migrator. This module handles the migration of Google OAuth2 authenticators from AWX to Gateway. """ +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 @@ -24,27 +25,69 @@ class GoogleOAuth2Migrator(BaseAuthenticatorMigrator): Returns: list: List of configured Google OAuth2 authentication providers with their settings """ - # TODO: Implement Google OAuth2 configuration retrieval - # Google OAuth2 settings typically include: - # - SOCIAL_AUTH_GOOGLE_OAUTH2_KEY - # - SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET - # - SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE - # - SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS - # - SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_EMAILS - # - SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP - # - SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP - found_configs = [] - return found_configs + from django.conf import settings + + if not getattr(settings, 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY', None): + return [] + + config_data = { + 'SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL': settings.SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL, + 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY, + 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET, + 'SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE': settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE, + } + + return [ + { + "category": self.get_authenticator_type(), + "settings": config_data, + } + ] + + def _build_mappers(self): + org_map = self.get_social_org_map('SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP') + team_map = self.get_social_team_map('SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP') + + mappers, order = org_map_to_gateway_format(org_map, 1) + team_mappers, _ = team_map_to_gateway_format(team_map, order) + + mappers.extend(team_mappers) + + return mappers def create_gateway_authenticator(self, config): """Create a Google OAuth2 authenticator in Gateway.""" - # TODO: Implement Google OAuth2 authenticator creation - # When implementing, use this pattern for slug generation: - # client_id = settings.get('SOCIAL_AUTH_GOOGLE_OAUTH2_KEY', 'google') - # authenticator_slug = self._generate_authenticator_slug('google_oauth2', category, client_id) - # Similar to GitHub OAuth2 but with Google-specific endpoints - # - Extract GOOGLE_OAUTH2_KEY and GOOGLE_OAUTH2_SECRET - # - Handle whitelisted domains/emails - # - Configure Google OAuth2 scope - self._write_output('Google OAuth2 authenticator creation not yet implemented', 'warning') - return False + category = config["category"] + config_settings = config['settings'] + + authenticator_slug = self._generate_authenticator_slug('google-oauth2', category.replace(" ", "-")) + + self._write_output(f"\n--- Processing {category} authenticator ---") + + gateway_config = { + "name": "google", + "slug": authenticator_slug, + "type": "ansible_base.authentication.authenticator_plugins.google_oauth2", + "enabled": True, + "create_objects": True, # Allow Gateway to create users/orgs/teams + "remove_users": False, # Don't remove users by default + "configuration": { + "KEY": config_settings.get('SOCIAL_AUTH_GOOGLE_OAUTH2_KEY'), + "SECRET": config_settings.get('SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET'), + "REDIRECT_STATE": True, + }, + "mappers": self._build_mappers(), + } + + ignore_keys = ["ACCESS_TOKEN_METHOD", "REVOKE_TOKEN_METHOD"] + optional = { + "CALLBACK_URL": config_settings.get('SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL'), + "SCOPE": config_settings.get('SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE'), + } + for key, value in optional.items(): + if value: + gateway_config["configuration"][key] = value + else: + ignore_keys.append(key) + + return self.submit_authenticator(gateway_config, ignore_keys, config)