mirror of
https://github.com/ansible/awx.git
synced 2026-03-05 10:41:05 -03:30
Aap 49709 - settings migration (#7023)
* migrate settings using the existing authenticator framework * add method to get settings value to gateway client * add transformer functions for settings * Switched back to PUT for settings updates * Started wiring in testing changes * Added settings_* aggregation results. Added skip-github option. Added tests. Assisted-by: Cursor * Added --skip-all-authenticators command line argument. Added GoogleOAuth testing. Added tests for skipping all authenticators. Assisted-by: Cursor * wip: migrate other missing settings * update login_redirect_override in google_oauth2 * impement login redirect for azuread * implement login redirect for github * implement login redirect for saml * set LOGIN_REDIRECT_OVERRIDE even if no authenticator matched * extract logic for login redirect override to base class * use urlparse to compare valid redirect urls * Preserve the original query parameters * Fix flake8 issues * Preserve the query parameter in sso_login_url Gateway sets the sso_login_url to /api/gateway/social/login/aap-saml-keycloak/?idp=IdP The idp needs to be preserved when creating the redirect * Update awx/main/utils/gateway_client.py Co-authored-by: Chris Meyers <chrismeyersfsu@users.noreply.github.com> * Update awx/main/management/commands/import_auth_config_to_gateway.py Co-authored-by: Chris Meyers <chrismeyersfsu@users.noreply.github.com> * list of settings updated * Update awx/main/utils/gateway_client.py Co-authored-by: Chris Meyers <chrismeyersfsu@users.noreply.github.com> * Update awx/sso/utils/base_migrator.py Co-authored-by: Chris Meyers <chrismeyersfsu@users.noreply.github.com> * fix tests --------- Co-authored-by: Andrew Potozniak <potozniak@redhat.com> Co-authored-by: Madhu Kanoor <mkanoor@redhat.com> Co-authored-by: Chris Meyers <chrismeyersfsu@users.noreply.github.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from awx.sso.utils.ldap_migrator import LDAPMigrator
|
|||||||
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.sso.utils.saml_migrator import SAMLMigrator
|
||||||
from awx.sso.utils.radius_migrator import RADIUSMigrator
|
from awx.sso.utils.radius_migrator import RADIUSMigrator
|
||||||
|
from awx.sso.utils.settings_migrator import SettingsMigrator
|
||||||
from awx.sso.utils.tacacs_migrator import TACACSMigrator
|
from awx.sso.utils.tacacs_migrator import TACACSMigrator
|
||||||
from awx.sso.utils.google_oauth2_migrator import GoogleOAuth2Migrator
|
from awx.sso.utils.google_oauth2_migrator import GoogleOAuth2Migrator
|
||||||
from awx.main.utils.gateway_client import GatewayClient, GatewayAPIError
|
from awx.main.utils.gateway_client import GatewayClient, GatewayAPIError
|
||||||
@@ -21,14 +22,25 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('--basic-auth', action='store_true', help='Use HTTP Basic Authentication between Controller and Gateway')
|
parser.add_argument('--basic-auth', action='store_true', help='Use HTTP Basic Authentication between Controller and Gateway')
|
||||||
parser.add_argument('--skip-oidc', action='store_true', help='Skip importing GitHub and generic OIDC authenticators')
|
parser.add_argument(
|
||||||
|
'--skip-all-authenticators',
|
||||||
|
action='store_true',
|
||||||
|
help='Skip importing all authenticators [GitHub, OIDC, SAML, Azure AD, LDAP, RADIUS, TACACS+, Google OAuth2]',
|
||||||
|
)
|
||||||
|
parser.add_argument('--skip-oidc', action='store_true', help='Skip importing generic OIDC authenticators')
|
||||||
|
parser.add_argument('--skip-github', action='store_true', help='Skip importing GitHub authenticator')
|
||||||
parser.add_argument('--skip-ldap', action='store_true', help='Skip importing LDAP authenticators')
|
parser.add_argument('--skip-ldap', action='store_true', help='Skip importing LDAP authenticators')
|
||||||
parser.add_argument('--skip-ad', action='store_true', help='Skip importing Azure AD authenticator')
|
parser.add_argument('--skip-ad', action='store_true', help='Skip importing Azure AD authenticator')
|
||||||
parser.add_argument('--skip-saml', action='store_true', help='Skip importing SAML authenticator')
|
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-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-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('--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')
|
parser.add_argument('--skip-settings', action='store_true', help='Skip importing settings')
|
||||||
|
parser.add_argument(
|
||||||
|
'--force',
|
||||||
|
action='store_true',
|
||||||
|
help='Force migration even if configurations already exist. Does not apply to skipped authenticators nor skipped settings.',
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
# Read Gateway connection parameters from environment variables
|
# Read Gateway connection parameters from environment variables
|
||||||
@@ -37,13 +49,16 @@ class Command(BaseCommand):
|
|||||||
gateway_password = os.getenv('GATEWAY_PASSWORD')
|
gateway_password = os.getenv('GATEWAY_PASSWORD')
|
||||||
gateway_skip_verify = os.getenv('GATEWAY_SKIP_VERIFY', '').lower() in ('true', '1', 'yes', 'on')
|
gateway_skip_verify = os.getenv('GATEWAY_SKIP_VERIFY', '').lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
|
skip_all_authenticators = options['skip_all_authenticators']
|
||||||
skip_oidc = options['skip_oidc']
|
skip_oidc = options['skip_oidc']
|
||||||
|
skip_github = options['skip_github']
|
||||||
skip_ldap = options['skip_ldap']
|
skip_ldap = options['skip_ldap']
|
||||||
skip_ad = options['skip_ad']
|
skip_ad = options['skip_ad']
|
||||||
skip_saml = options['skip_saml']
|
skip_saml = options['skip_saml']
|
||||||
skip_radius = options['skip_radius']
|
skip_radius = options['skip_radius']
|
||||||
skip_tacacs = options['skip_tacacs']
|
skip_tacacs = options['skip_tacacs']
|
||||||
skip_google = options['skip_google']
|
skip_google = options['skip_google']
|
||||||
|
skip_settings = options['skip_settings']
|
||||||
force = options['force']
|
force = options['force']
|
||||||
basic_auth = options['basic_auth']
|
basic_auth = options['basic_auth']
|
||||||
|
|
||||||
@@ -107,27 +122,38 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Initialize migrators
|
# Initialize migrators
|
||||||
migrators = []
|
migrators = []
|
||||||
if not skip_oidc:
|
if not skip_all_authenticators:
|
||||||
migrators.append(GitHubMigrator(gateway_client, self, force=force))
|
if not skip_oidc:
|
||||||
migrators.append(OIDCMigrator(gateway_client, self, force=force))
|
migrators.append(OIDCMigrator(gateway_client, self, force=force))
|
||||||
|
|
||||||
if not skip_saml:
|
if not skip_github:
|
||||||
migrators.append(SAMLMigrator(gateway_client, self, force=force))
|
migrators.append(GitHubMigrator(gateway_client, self, force=force))
|
||||||
|
|
||||||
if not skip_ad:
|
if not skip_saml:
|
||||||
migrators.append(AzureADMigrator(gateway_client, self, force=force))
|
migrators.append(SAMLMigrator(gateway_client, self, force=force))
|
||||||
|
|
||||||
if not skip_ldap:
|
if not skip_ad:
|
||||||
migrators.append(LDAPMigrator(gateway_client, self, force=force))
|
migrators.append(AzureADMigrator(gateway_client, self, force=force))
|
||||||
|
|
||||||
if not skip_radius:
|
if not skip_ldap:
|
||||||
migrators.append(RADIUSMigrator(gateway_client, self, force=force))
|
migrators.append(LDAPMigrator(gateway_client, self, force=force))
|
||||||
|
|
||||||
if not skip_tacacs:
|
if not skip_radius:
|
||||||
migrators.append(TACACSMigrator(gateway_client, self, force=force))
|
migrators.append(RADIUSMigrator(gateway_client, self, force=force))
|
||||||
|
|
||||||
if not skip_google:
|
if not skip_tacacs:
|
||||||
migrators.append(GoogleOAuth2Migrator(gateway_client, self, force=force))
|
migrators.append(TACACSMigrator(gateway_client, self, force=force))
|
||||||
|
|
||||||
|
if not skip_google:
|
||||||
|
migrators.append(GoogleOAuth2Migrator(gateway_client, self, force=force))
|
||||||
|
|
||||||
|
if not migrators:
|
||||||
|
self.stdout.write(self.style.WARNING('No authentication configurations found to migrate.'))
|
||||||
|
|
||||||
|
if not skip_settings:
|
||||||
|
migrators.append(SettingsMigrator(gateway_client, self, force=force))
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.WARNING('Settings migration will not execute.'))
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
total_results = {
|
total_results = {
|
||||||
@@ -138,10 +164,14 @@ class Command(BaseCommand):
|
|||||||
'mappers_created': 0,
|
'mappers_created': 0,
|
||||||
'mappers_updated': 0,
|
'mappers_updated': 0,
|
||||||
'mappers_failed': 0,
|
'mappers_failed': 0,
|
||||||
|
'settings_created': 0,
|
||||||
|
'settings_updated': 0,
|
||||||
|
'settings_unchanged': 0,
|
||||||
|
'settings_failed': 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not migrators:
|
if not migrators:
|
||||||
self.stdout.write(self.style.WARNING('No authentication configurations found to migrate.'))
|
self.stdout.write(self.style.WARNING('NO MIGRATIONS WILL EXECUTE.'))
|
||||||
else:
|
else:
|
||||||
for migrator in migrators:
|
for migrator in migrators:
|
||||||
self.stdout.write(self.style.SUCCESS(f'\n=== Migrating {migrator.get_authenticator_type()} Configurations ==='))
|
self.stdout.write(self.style.SUCCESS(f'\n=== Migrating {migrator.get_authenticator_type()} Configurations ==='))
|
||||||
@@ -161,6 +191,10 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f'Total mappers created: {total_results["mappers_created"]}')
|
self.stdout.write(f'Total mappers created: {total_results["mappers_created"]}')
|
||||||
self.stdout.write(f'Total mappers updated: {total_results["mappers_updated"]}')
|
self.stdout.write(f'Total mappers updated: {total_results["mappers_updated"]}')
|
||||||
self.stdout.write(f'Total mappers failed: {total_results["mappers_failed"]}')
|
self.stdout.write(f'Total mappers failed: {total_results["mappers_failed"]}')
|
||||||
|
self.stdout.write(f'Total settings created: {total_results["settings_created"]}')
|
||||||
|
self.stdout.write(f'Total settings updated: {total_results["settings_updated"]}')
|
||||||
|
self.stdout.write(f'Total settings unchanged: {total_results["settings_unchanged"]}')
|
||||||
|
self.stdout.write(f'Total settings failed: {total_results["settings_failed"]}')
|
||||||
|
|
||||||
except GatewayAPIError as e:
|
except GatewayAPIError as e:
|
||||||
self.stdout.write(self.style.ERROR(f'Gateway API Error: {e.message}'))
|
self.stdout.write(self.style.ERROR(f'Gateway API Error: {e.message}'))
|
||||||
@@ -176,10 +210,18 @@ class Command(BaseCommand):
|
|||||||
def _print_export_summary(self, config_type, result):
|
def _print_export_summary(self, config_type, result):
|
||||||
"""Print a summary of the export results."""
|
"""Print a summary of the export results."""
|
||||||
self.stdout.write(f'\n--- {config_type} Export Summary ---')
|
self.stdout.write(f'\n--- {config_type} Export Summary ---')
|
||||||
self.stdout.write(f'Authenticators created: {result.get("created", 0)}')
|
|
||||||
self.stdout.write(f'Authenticators updated: {result.get("updated", 0)}')
|
if config_type in ['GitHub', 'OIDC', 'SAML', 'Azure AD', 'LDAP', 'RADIUS', 'TACACS+', 'Google OAuth2']:
|
||||||
self.stdout.write(f'Authenticators unchanged: {result.get("unchanged", 0)}')
|
self.stdout.write(f'Authenticators created: {result.get("created", 0)}')
|
||||||
self.stdout.write(f'Authenticators failed: {result.get("failed", 0)}')
|
self.stdout.write(f'Authenticators updated: {result.get("updated", 0)}')
|
||||||
self.stdout.write(f'Mappers created: {result.get("mappers_created", 0)}')
|
self.stdout.write(f'Authenticators unchanged: {result.get("unchanged", 0)}')
|
||||||
self.stdout.write(f'Mappers updated: {result.get("mappers_updated", 0)}')
|
self.stdout.write(f'Authenticators failed: {result.get("failed", 0)}')
|
||||||
self.stdout.write(f'Mappers failed: {result.get("mappers_failed", 0)}')
|
self.stdout.write(f'Mappers created: {result.get("mappers_created", 0)}')
|
||||||
|
self.stdout.write(f'Mappers updated: {result.get("mappers_updated", 0)}')
|
||||||
|
self.stdout.write(f'Mappers failed: {result.get("mappers_failed", 0)}')
|
||||||
|
|
||||||
|
if config_type == 'Settings':
|
||||||
|
self.stdout.write(f'Settings created: {result.get("settings_created", 0)}')
|
||||||
|
self.stdout.write(f'Settings updated: {result.get("settings_updated", 0)}')
|
||||||
|
self.stdout.write(f'Settings unchanged: {result.get("settings_unchanged", 0)}')
|
||||||
|
self.stdout.write(f'Settings failed: {result.get("settings_failed", 0)}')
|
||||||
|
|||||||
@@ -14,26 +14,32 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
def options_basic_auth_full_send(self):
|
def options_basic_auth_full_send(self):
|
||||||
return {
|
return {
|
||||||
'basic_auth': True,
|
'basic_auth': True,
|
||||||
|
'skip_all_authenticators': False,
|
||||||
'skip_oidc': False,
|
'skip_oidc': False,
|
||||||
|
'skip_github': False,
|
||||||
'skip_ldap': False,
|
'skip_ldap': False,
|
||||||
'skip_ad': False,
|
'skip_ad': False,
|
||||||
'skip_saml': False,
|
'skip_saml': False,
|
||||||
'skip_radius': False,
|
'skip_radius': False,
|
||||||
'skip_tacacs': False,
|
'skip_tacacs': False,
|
||||||
'skip_google': False,
|
'skip_google': False,
|
||||||
|
'skip_settings': False,
|
||||||
'force': False,
|
'force': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
def options_basic_auth_skip_all(self):
|
def options_basic_auth_skip_all_individual(self):
|
||||||
return {
|
return {
|
||||||
'basic_auth': True,
|
'basic_auth': True,
|
||||||
|
'skip_all_authenticators': False,
|
||||||
'skip_oidc': True,
|
'skip_oidc': True,
|
||||||
|
'skip_github': True,
|
||||||
'skip_ldap': True,
|
'skip_ldap': True,
|
||||||
'skip_ad': True,
|
'skip_ad': True,
|
||||||
'skip_saml': True,
|
'skip_saml': True,
|
||||||
'skip_radius': True,
|
'skip_radius': True,
|
||||||
'skip_tacacs': True,
|
'skip_tacacs': True,
|
||||||
'skip_google': True,
|
'skip_google': True,
|
||||||
|
'skip_settings': True,
|
||||||
'force': False,
|
'force': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +49,7 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
def options_svc_token_skip_all(self):
|
def options_svc_token_skip_all(self):
|
||||||
options = self.options_basic_auth_skip_all()
|
options = self.options_basic_auth_skip_all_individual()
|
||||||
options['basic_auth'] = False
|
options['basic_auth'] = False
|
||||||
return options
|
return options
|
||||||
|
|
||||||
@@ -58,6 +64,10 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
mappers_created=0,
|
mappers_created=0,
|
||||||
mappers_updated=0,
|
mappers_updated=0,
|
||||||
mappers_failed=0,
|
mappers_failed=0,
|
||||||
|
settings_created=0,
|
||||||
|
settings_updated=0,
|
||||||
|
settings_unchanged=0,
|
||||||
|
settings_failed=0,
|
||||||
):
|
):
|
||||||
"""Helper method to create a mock migrator with specified return values."""
|
"""Helper method to create a mock migrator with specified return values."""
|
||||||
mock_migrator = Mock()
|
mock_migrator = Mock()
|
||||||
@@ -81,14 +91,25 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
|
|
||||||
expected_calls = [
|
expected_calls = [
|
||||||
call('--basic-auth', action='store_true', help='Use HTTP Basic Authentication between Controller and Gateway'),
|
call('--basic-auth', action='store_true', help='Use HTTP Basic Authentication between Controller and Gateway'),
|
||||||
call('--skip-oidc', action='store_true', help='Skip importing GitHub and generic OIDC authenticators'),
|
call(
|
||||||
|
'--skip-all-authenticators',
|
||||||
|
action='store_true',
|
||||||
|
help='Skip importing all authenticators [GitHub, OIDC, SAML, Azure AD, LDAP, RADIUS, TACACS+, Google OAuth2]',
|
||||||
|
),
|
||||||
|
call('--skip-oidc', action='store_true', help='Skip importing generic OIDC authenticators'),
|
||||||
|
call('--skip-github', action='store_true', help='Skip importing GitHub authenticator'),
|
||||||
call('--skip-ldap', action='store_true', help='Skip importing LDAP authenticators'),
|
call('--skip-ldap', action='store_true', help='Skip importing LDAP authenticators'),
|
||||||
call('--skip-ad', action='store_true', help='Skip importing Azure AD authenticator'),
|
call('--skip-ad', action='store_true', help='Skip importing Azure AD authenticator'),
|
||||||
call('--skip-saml', action='store_true', help='Skip importing SAML authenticator'),
|
call('--skip-saml', action='store_true', help='Skip importing SAML authenticator'),
|
||||||
call('--skip-radius', action='store_true', help='Skip importing RADIUS authenticator'),
|
call('--skip-radius', action='store_true', help='Skip importing RADIUS authenticator'),
|
||||||
call('--skip-tacacs', action='store_true', help='Skip importing TACACS+ authenticator'),
|
call('--skip-tacacs', action='store_true', help='Skip importing TACACS+ authenticator'),
|
||||||
call('--skip-google', action='store_true', help='Skip importing Google OAuth2 authenticator'),
|
call('--skip-google', action='store_true', help='Skip importing Google OAuth2 authenticator'),
|
||||||
call('--force', action='store_true', help='Force migration even if configurations already exist'),
|
call('--skip-settings', action='store_true', help='Skip importing settings'),
|
||||||
|
call(
|
||||||
|
'--force',
|
||||||
|
action='store_true',
|
||||||
|
help='Force migration even if configurations already exist. Does not apply to skipped authenticators nor skipped settings.',
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
parser.add_argument.assert_has_calls(expected_calls, any_order=True)
|
parser.add_argument.assert_has_calls(expected_calls, any_order=True)
|
||||||
@@ -113,6 +134,7 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
os.environ,
|
os.environ,
|
||||||
{'GATEWAY_BASE_URL': 'https://gateway.example.com', 'GATEWAY_USER': 'testuser', 'GATEWAY_PASSWORD': 'testpass', 'GATEWAY_SKIP_VERIFY': 'true'},
|
{'GATEWAY_BASE_URL': 'https://gateway.example.com', 'GATEWAY_USER': 'testuser', 'GATEWAY_PASSWORD': 'testpass', 'GATEWAY_SKIP_VERIFY': 'true'},
|
||||||
)
|
)
|
||||||
|
@patch('awx.main.management.commands.import_auth_config_to_gateway.SettingsMigrator')
|
||||||
@patch.multiple(
|
@patch.multiple(
|
||||||
'awx.main.management.commands.import_auth_config_to_gateway',
|
'awx.main.management.commands.import_auth_config_to_gateway',
|
||||||
GitHubMigrator=DEFAULT,
|
GitHubMigrator=DEFAULT,
|
||||||
@@ -122,10 +144,11 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
LDAPMigrator=DEFAULT,
|
LDAPMigrator=DEFAULT,
|
||||||
RADIUSMigrator=DEFAULT,
|
RADIUSMigrator=DEFAULT,
|
||||||
TACACSMigrator=DEFAULT,
|
TACACSMigrator=DEFAULT,
|
||||||
|
GoogleOAuth2Migrator=DEFAULT,
|
||||||
)
|
)
|
||||||
@patch('awx.main.management.commands.import_auth_config_to_gateway.GatewayClient')
|
@patch('awx.main.management.commands.import_auth_config_to_gateway.GatewayClient')
|
||||||
@patch('sys.stdout', new_callable=StringIO)
|
@patch('sys.stdout', new_callable=StringIO)
|
||||||
def test_handle_basic_auth_success(self, mock_stdout, mock_gateway_client, **mock_migrators):
|
def test_handle_basic_auth_success(self, mock_stdout, mock_gateway_client, mock_settings_migrator, **mock_migrators):
|
||||||
"""Test successful execution with basic auth."""
|
"""Test successful execution with basic auth."""
|
||||||
# Mock gateway client context manager
|
# Mock gateway client context manager
|
||||||
mock_client_instance = Mock()
|
mock_client_instance = Mock()
|
||||||
@@ -135,6 +158,8 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
for mock_migrator_class in mock_migrators.values():
|
for mock_migrator_class in mock_migrators.values():
|
||||||
self.create_mock_migrator(mock_migrator_class, created=1, mappers_created=2)
|
self.create_mock_migrator(mock_migrator_class, created=1, mappers_created=2)
|
||||||
|
|
||||||
|
self.create_mock_migrator(mock_settings_migrator, settings_created=1, settings_updated=0, settings_unchanged=2, settings_failed=0)
|
||||||
|
|
||||||
with patch.object(self.command, 'stdout', mock_stdout):
|
with patch.object(self.command, 'stdout', mock_stdout):
|
||||||
self.command.handle(**self.options_basic_auth_full_send())
|
self.command.handle(**self.options_basic_auth_full_send())
|
||||||
|
|
||||||
@@ -147,12 +172,17 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
for mock_migrator in mock_migrators.values():
|
for mock_migrator in mock_migrators.values():
|
||||||
mock_migrator.assert_called_once_with(mock_client_instance, self.command, force=False)
|
mock_migrator.assert_called_once_with(mock_client_instance, self.command, force=False)
|
||||||
|
|
||||||
|
mock_settings_migrator.assert_called_once_with(mock_client_instance, self.command, force=False)
|
||||||
|
|
||||||
# Verify output contains success messages
|
# Verify output contains success messages
|
||||||
output = mock_stdout.getvalue()
|
output = mock_stdout.getvalue()
|
||||||
|
|
||||||
self.assertIn('HTTP Basic Auth: true', output)
|
self.assertIn('HTTP Basic Auth: true', output)
|
||||||
self.assertIn('Successfully connected to Gateway', output)
|
self.assertIn('Successfully connected to Gateway', output)
|
||||||
self.assertIn('Migration Summary', output)
|
self.assertIn('Migration Summary', output)
|
||||||
|
self.assertIn('authenticators', output)
|
||||||
|
self.assertIn('mappers', output)
|
||||||
|
self.assertIn('settings', output)
|
||||||
|
|
||||||
@patch('awx.main.management.commands.import_auth_config_to_gateway.create_api_client')
|
@patch('awx.main.management.commands.import_auth_config_to_gateway.create_api_client')
|
||||||
@patch('awx.main.management.commands.import_auth_config_to_gateway.GatewayClientSVCToken')
|
@patch('awx.main.management.commands.import_auth_config_to_gateway.GatewayClientSVCToken')
|
||||||
@@ -211,10 +241,12 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
LDAPMigrator=DEFAULT,
|
LDAPMigrator=DEFAULT,
|
||||||
RADIUSMigrator=DEFAULT,
|
RADIUSMigrator=DEFAULT,
|
||||||
TACACSMigrator=DEFAULT,
|
TACACSMigrator=DEFAULT,
|
||||||
|
GoogleOAuth2Migrator=DEFAULT,
|
||||||
|
SettingsMigrator=DEFAULT,
|
||||||
)
|
)
|
||||||
@patch('awx.main.management.commands.import_auth_config_to_gateway.GatewayClient')
|
@patch('awx.main.management.commands.import_auth_config_to_gateway.GatewayClient')
|
||||||
@patch('sys.stdout', new_callable=StringIO)
|
@patch('sys.stdout', new_callable=StringIO)
|
||||||
def test_skip_flags_prevent_migrator_creation(self, mock_stdout, mock_gateway_client, **mock_migrators):
|
def test_skip_flags_prevent_authenticator_individual_and_settings_migration(self, mock_stdout, mock_gateway_client, **mock_migrators):
|
||||||
"""Test that skip flags prevent corresponding migrators from being created."""
|
"""Test that skip flags prevent corresponding migrators from being created."""
|
||||||
|
|
||||||
# Mock gateway client context manager
|
# Mock gateway client context manager
|
||||||
@@ -223,7 +255,7 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
mock_gateway_client.return_value.__exit__.return_value = None
|
mock_gateway_client.return_value.__exit__.return_value = None
|
||||||
|
|
||||||
with patch.object(self.command, 'stdout', mock_stdout):
|
with patch.object(self.command, 'stdout', mock_stdout):
|
||||||
self.command.handle(**self.options_basic_auth_skip_all())
|
self.command.handle(**self.options_basic_auth_skip_all_individual())
|
||||||
|
|
||||||
# Verify no migrators were created
|
# Verify no migrators were created
|
||||||
for mock_migrator in mock_migrators.values():
|
for mock_migrator in mock_migrators.values():
|
||||||
@@ -232,6 +264,46 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
# Verify warning message about no configurations
|
# Verify warning message about no configurations
|
||||||
output = mock_stdout.getvalue()
|
output = mock_stdout.getvalue()
|
||||||
self.assertIn('No authentication configurations found to migrate.', output)
|
self.assertIn('No authentication configurations found to migrate.', output)
|
||||||
|
self.assertIn('Settings migration will not execute.', output)
|
||||||
|
self.assertIn('NO MIGRATIONS WILL EXECUTE.', output)
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {'GATEWAY_BASE_URL': 'https://gateway.example.com', 'GATEWAY_USER': 'testuser', 'GATEWAY_PASSWORD': 'testpass'})
|
||||||
|
@patch.multiple(
|
||||||
|
'awx.main.management.commands.import_auth_config_to_gateway',
|
||||||
|
GitHubMigrator=DEFAULT,
|
||||||
|
OIDCMigrator=DEFAULT,
|
||||||
|
SAMLMigrator=DEFAULT,
|
||||||
|
AzureADMigrator=DEFAULT,
|
||||||
|
LDAPMigrator=DEFAULT,
|
||||||
|
RADIUSMigrator=DEFAULT,
|
||||||
|
TACACSMigrator=DEFAULT,
|
||||||
|
GoogleOAuth2Migrator=DEFAULT,
|
||||||
|
)
|
||||||
|
@patch('awx.main.management.commands.import_auth_config_to_gateway.GatewayClient')
|
||||||
|
@patch('sys.stdout', new_callable=StringIO)
|
||||||
|
def test_skip_flags_prevent_authenticator_migration(self, mock_stdout, mock_gateway_client, **mock_migrators):
|
||||||
|
"""Test that skip flags prevent corresponding migrators from being created."""
|
||||||
|
|
||||||
|
# Mock gateway client context manager
|
||||||
|
mock_client_instance = Mock()
|
||||||
|
mock_gateway_client.return_value.__enter__.return_value = mock_client_instance
|
||||||
|
mock_gateway_client.return_value.__exit__.return_value = None
|
||||||
|
|
||||||
|
options = self.options_basic_auth_full_send()
|
||||||
|
options['skip_all_authenticators'] = True
|
||||||
|
|
||||||
|
with patch.object(self.command, 'stdout', mock_stdout):
|
||||||
|
self.command.handle(**options)
|
||||||
|
|
||||||
|
# Verify no migrators were created
|
||||||
|
for mock_migrator in mock_migrators.values():
|
||||||
|
mock_migrator.assert_not_called()
|
||||||
|
|
||||||
|
# Verify warning message about no configurations
|
||||||
|
output = mock_stdout.getvalue()
|
||||||
|
self.assertIn('No authentication configurations found to migrate.', output)
|
||||||
|
self.assertNotIn('Settings migration will not execute.', output)
|
||||||
|
self.assertNotIn('NO MIGRATIONS WILL EXECUTE.', output)
|
||||||
|
|
||||||
@patch.dict(os.environ, {'GATEWAY_BASE_URL': 'https://gateway.example.com', 'GATEWAY_USER': 'testuser', 'GATEWAY_PASSWORD': 'testpass'})
|
@patch.dict(os.environ, {'GATEWAY_BASE_URL': 'https://gateway.example.com', 'GATEWAY_USER': 'testuser', 'GATEWAY_PASSWORD': 'testpass'})
|
||||||
@patch('awx.main.management.commands.import_auth_config_to_gateway.GatewayClient')
|
@patch('awx.main.management.commands.import_auth_config_to_gateway.GatewayClient')
|
||||||
@@ -268,8 +340,9 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
@patch.dict(os.environ, {'GATEWAY_BASE_URL': 'https://gateway.example.com', 'GATEWAY_USER': 'testuser', 'GATEWAY_PASSWORD': 'testpass'})
|
@patch.dict(os.environ, {'GATEWAY_BASE_URL': 'https://gateway.example.com', 'GATEWAY_USER': 'testuser', 'GATEWAY_PASSWORD': 'testpass'})
|
||||||
@patch('awx.main.management.commands.import_auth_config_to_gateway.GatewayClient')
|
@patch('awx.main.management.commands.import_auth_config_to_gateway.GatewayClient')
|
||||||
@patch('awx.main.management.commands.import_auth_config_to_gateway.GitHubMigrator')
|
@patch('awx.main.management.commands.import_auth_config_to_gateway.GitHubMigrator')
|
||||||
|
@patch('awx.main.management.commands.import_auth_config_to_gateway.SettingsMigrator')
|
||||||
@patch('sys.stdout', new_callable=StringIO)
|
@patch('sys.stdout', new_callable=StringIO)
|
||||||
def test_force_flag_passed_to_migrators(self, mock_stdout, mock_github, mock_gateway_client):
|
def test_force_flag_passed_to_migrators(self, mock_stdout, mock_github, mock_settings_migrator, mock_gateway_client):
|
||||||
"""Test that force flag is properly passed to migrators."""
|
"""Test that force flag is properly passed to migrators."""
|
||||||
# Mock gateway client context manager
|
# Mock gateway client context manager
|
||||||
mock_client_instance = Mock()
|
mock_client_instance = Mock()
|
||||||
@@ -278,10 +351,14 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
|
|
||||||
# Mock migrator
|
# Mock migrator
|
||||||
self.create_mock_migrator(mock_github, authenticator_type="GitHub", created=0, mappers_created=2)
|
self.create_mock_migrator(mock_github, authenticator_type="GitHub", created=0, mappers_created=2)
|
||||||
|
self.create_mock_migrator(
|
||||||
|
mock_settings_migrator, authenticator_type="Settings", settings_created=0, settings_updated=2, settings_unchanged=0, settings_failed=0
|
||||||
|
)
|
||||||
|
|
||||||
options = self.options_basic_auth_skip_all()
|
options = self.options_basic_auth_skip_all_individual()
|
||||||
options['force'] = True
|
options['force'] = True
|
||||||
options['skip_oidc'] = False
|
options['skip_github'] = False
|
||||||
|
options['skip_settings'] = False
|
||||||
|
|
||||||
with patch.object(self.command, 'stdout', mock_stdout):
|
with patch.object(self.command, 'stdout', mock_stdout):
|
||||||
self.command.handle(**options)
|
self.command.handle(**options)
|
||||||
@@ -289,6 +366,9 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
# Verify migrator was created with force=True
|
# Verify migrator was created with force=True
|
||||||
mock_github.assert_called_once_with(mock_client_instance, self.command, force=True)
|
mock_github.assert_called_once_with(mock_client_instance, self.command, force=True)
|
||||||
|
|
||||||
|
# Verify settings migrator was created with force=True
|
||||||
|
mock_settings_migrator.assert_called_once_with(mock_client_instance, self.command, force=True)
|
||||||
|
|
||||||
@patch('sys.stdout', new_callable=StringIO)
|
@patch('sys.stdout', new_callable=StringIO)
|
||||||
def test_print_export_summary(self, mock_stdout):
|
def test_print_export_summary(self, mock_stdout):
|
||||||
"""Test the _print_export_summary method."""
|
"""Test the _print_export_summary method."""
|
||||||
@@ -315,6 +395,26 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
self.assertIn('Mappers updated: 2', output)
|
self.assertIn('Mappers updated: 2', output)
|
||||||
self.assertIn('Mappers failed: 1', output)
|
self.assertIn('Mappers failed: 1', output)
|
||||||
|
|
||||||
|
@patch('sys.stdout', new_callable=StringIO)
|
||||||
|
def test_print_export_summary_settings(self, mock_stdout):
|
||||||
|
"""Test the _print_export_summary method."""
|
||||||
|
result = {
|
||||||
|
'settings_created': 2,
|
||||||
|
'settings_updated': 1,
|
||||||
|
'settings_unchanged': 3,
|
||||||
|
'settings_failed': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(self.command, 'stdout', mock_stdout):
|
||||||
|
self.command._print_export_summary('Settings', result)
|
||||||
|
|
||||||
|
output = mock_stdout.getvalue()
|
||||||
|
self.assertIn('--- Settings Export Summary ---', output)
|
||||||
|
self.assertIn('Settings created: 2', output)
|
||||||
|
self.assertIn('Settings updated: 1', output)
|
||||||
|
self.assertIn('Settings unchanged: 3', output)
|
||||||
|
self.assertIn('Settings failed: 0', output)
|
||||||
|
|
||||||
@patch('sys.stdout', new_callable=StringIO)
|
@patch('sys.stdout', new_callable=StringIO)
|
||||||
def test_print_export_summary_missing_keys(self, mock_stdout):
|
def test_print_export_summary_missing_keys(self, mock_stdout):
|
||||||
"""Test _print_export_summary handles missing keys gracefully."""
|
"""Test _print_export_summary handles missing keys gracefully."""
|
||||||
@@ -350,7 +450,7 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
self.create_mock_migrator(mock_github, authenticator_type="GitHub", created=1, mappers_created=2)
|
self.create_mock_migrator(mock_github, authenticator_type="GitHub", created=1, mappers_created=2)
|
||||||
self.create_mock_migrator(mock_oidc, authenticator_type="OIDC", created=0, updated=1, unchanged=1, mappers_created=1, mappers_updated=1)
|
self.create_mock_migrator(mock_oidc, authenticator_type="OIDC", created=0, updated=1, unchanged=1, mappers_created=1, mappers_updated=1)
|
||||||
|
|
||||||
options = self.options_basic_auth_skip_all()
|
options = self.options_basic_auth_skip_all_individual()
|
||||||
options['skip_oidc'] = False
|
options['skip_oidc'] = False
|
||||||
options['skip_github'] = False
|
options['skip_github'] = False
|
||||||
|
|
||||||
@@ -401,7 +501,7 @@ class TestImportAuthConfigToGatewayCommand(TestCase):
|
|||||||
mock_gateway_client.return_value.__exit__.return_value = None
|
mock_gateway_client.return_value.__exit__.return_value = None
|
||||||
|
|
||||||
with patch.object(self.command, 'stdout', mock_stdout):
|
with patch.object(self.command, 'stdout', mock_stdout):
|
||||||
self.command.handle(**self.options_basic_auth_skip_all())
|
self.command.handle(**self.options_basic_auth_skip_all_individual())
|
||||||
|
|
||||||
# Verify gateway client was called with correct skip_verify value
|
# Verify gateway client was called with correct skip_verify value
|
||||||
mock_gateway_client.assert_called_once_with(
|
mock_gateway_client.assert_called_once_with(
|
||||||
|
|||||||
@@ -869,3 +869,371 @@ class TestSocialAuthMapFunctions:
|
|||||||
result = self.migrator.get_social_team_map(setting_name)
|
result = self.migrator.get_social_team_map(setting_name)
|
||||||
|
|
||||||
assert result == {'test_team': {'organization': 'test_org'}}
|
assert result == {'test_team': {'organization': 'test_org'}}
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleLoginOverride:
|
||||||
|
"""Tests for handle_login_override method."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Set up test fixtures."""
|
||||||
|
self.gateway_client = Mock()
|
||||||
|
self.command = Mock()
|
||||||
|
self.migrator = BaseAuthenticatorMigrator(self.gateway_client, self.command)
|
||||||
|
|
||||||
|
# Reset the class-level flag before each test
|
||||||
|
BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator = False
|
||||||
|
|
||||||
|
def test_handle_login_override_no_login_redirect_override(self):
|
||||||
|
"""Test that method returns early when no login_redirect_override is provided."""
|
||||||
|
config = {}
|
||||||
|
valid_login_urls = ['/sso/login/github']
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should not call any gateway client methods
|
||||||
|
self.gateway_client.get_base_url.assert_not_called()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_not_called()
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is False
|
||||||
|
|
||||||
|
def test_handle_login_override_empty_login_redirect_override(self):
|
||||||
|
"""Test that method returns early when login_redirect_override is empty."""
|
||||||
|
config = {'login_redirect_override': ''}
|
||||||
|
valid_login_urls = ['/sso/login/github']
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should not call any gateway client methods
|
||||||
|
self.gateway_client.get_base_url.assert_not_called()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_not_called()
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is False
|
||||||
|
|
||||||
|
def test_handle_login_override_no_url_match(self):
|
||||||
|
"""Test that method returns early when login_redirect_override doesn't match valid URLs."""
|
||||||
|
config = {'login_redirect_override': 'https://localhost:3000/sso/login/saml'}
|
||||||
|
valid_login_urls = ['/sso/login/github', '/sso/login/azuread-oauth2']
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should not call any gateway client methods
|
||||||
|
self.gateway_client.get_base_url.assert_not_called()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_not_called()
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is False
|
||||||
|
|
||||||
|
def test_handle_login_override_no_gateway_authenticator(self):
|
||||||
|
"""Test that method returns early when gateway_authenticator is missing."""
|
||||||
|
config = {'login_redirect_override': 'https://localhost:3000/sso/login/github'}
|
||||||
|
valid_login_urls = ['/sso/login/github']
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should not call any gateway client methods
|
||||||
|
self.gateway_client.get_base_url.assert_not_called()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_not_called()
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is False
|
||||||
|
|
||||||
|
def test_handle_login_override_empty_gateway_authenticator(self):
|
||||||
|
"""Test that method returns early when gateway_authenticator is empty."""
|
||||||
|
config = {'login_redirect_override': 'https://localhost:3000/sso/login/github', 'gateway_authenticator': {}}
|
||||||
|
valid_login_urls = ['/sso/login/github']
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should not call any gateway client methods
|
||||||
|
self.gateway_client.get_base_url.assert_not_called()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_not_called()
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is False
|
||||||
|
|
||||||
|
def test_handle_login_override_no_sso_login_url(self):
|
||||||
|
"""Test that method returns early when sso_login_url is missing."""
|
||||||
|
config = {'login_redirect_override': 'https://localhost:3000/sso/login/github', 'gateway_authenticator': {'id': 123}}
|
||||||
|
valid_login_urls = ['/sso/login/github']
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should not call any gateway client methods
|
||||||
|
self.gateway_client.get_base_url.assert_not_called()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_not_called()
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is False
|
||||||
|
|
||||||
|
def test_handle_login_override_empty_sso_login_url(self):
|
||||||
|
"""Test that method returns early when sso_login_url is empty."""
|
||||||
|
config = {'login_redirect_override': 'https://localhost:3000/sso/login/github', 'gateway_authenticator': {'id': 123, 'sso_login_url': ''}}
|
||||||
|
valid_login_urls = ['/sso/login/github']
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should not call any gateway client methods
|
||||||
|
self.gateway_client.get_base_url.assert_not_called()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_not_called()
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is False
|
||||||
|
|
||||||
|
def test_handle_login_override_successful_update(self):
|
||||||
|
"""Test successful LOGIN_REDIRECT_OVERRIDE update."""
|
||||||
|
config = {
|
||||||
|
'login_redirect_override': 'https://localhost:3000/sso/login/github',
|
||||||
|
'gateway_authenticator': {'id': 123, 'sso_login_url': '/sso/auth/login/123/'},
|
||||||
|
}
|
||||||
|
valid_login_urls = ['/sso/login/github']
|
||||||
|
|
||||||
|
# Mock gateway client methods
|
||||||
|
self.gateway_client.get_base_url.return_value = 'https://gateway.example.com'
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Verify gateway client methods were called correctly
|
||||||
|
self.gateway_client.get_base_url.assert_called_once()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_called_once_with('LOGIN_REDIRECT_OVERRIDE', 'https://gateway.example.com/sso/auth/login/123/')
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is True
|
||||||
|
|
||||||
|
def test_handle_login_override_multiple_valid_urls_first_matches(self):
|
||||||
|
"""Test that first matching URL in valid_login_urls is used."""
|
||||||
|
config = {
|
||||||
|
'login_redirect_override': 'https://localhost:3000/sso/login/github-org',
|
||||||
|
'gateway_authenticator': {'id': 123, 'sso_login_url': '/sso/auth/login/123/'},
|
||||||
|
}
|
||||||
|
valid_login_urls = ['/sso/login/github-org', '/sso/login/github-team', '/sso/login/github']
|
||||||
|
|
||||||
|
# Mock gateway client methods
|
||||||
|
self.gateway_client.get_base_url.return_value = 'https://gateway.example.com'
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should still work since first URL matches
|
||||||
|
self.gateway_client.update_gateway_setting.assert_called_once_with('LOGIN_REDIRECT_OVERRIDE', 'https://gateway.example.com/sso/auth/login/123/')
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is True
|
||||||
|
|
||||||
|
def test_handle_login_override_multiple_valid_urls_last_matches(self):
|
||||||
|
"""Test that last matching URL in valid_login_urls is used."""
|
||||||
|
config = {
|
||||||
|
'login_redirect_override': 'https://localhost:3000/sso/login/github',
|
||||||
|
'gateway_authenticator': {'id': 123, 'sso_login_url': '/sso/auth/login/123/'},
|
||||||
|
}
|
||||||
|
valid_login_urls = ['/sso/login/github-org', '/sso/login/github-team', '/sso/login/github']
|
||||||
|
|
||||||
|
# Mock gateway client methods
|
||||||
|
self.gateway_client.get_base_url.return_value = 'https://gateway.example.com'
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should work since last URL matches
|
||||||
|
self.gateway_client.update_gateway_setting.assert_called_once_with('LOGIN_REDIRECT_OVERRIDE', 'https://gateway.example.com/sso/auth/login/123/')
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is True
|
||||||
|
|
||||||
|
def test_handle_login_override_partial_url_match(self):
|
||||||
|
"""Test that partial URL matching works (using 'in' operator)."""
|
||||||
|
config = {
|
||||||
|
'login_redirect_override': 'https://controller.example.com/sso/login/azuread-oauth2/?next=%2Fdashboard',
|
||||||
|
'gateway_authenticator': {'id': 456, 'sso_login_url': '/auth/login/azuread/456/'},
|
||||||
|
}
|
||||||
|
valid_login_urls = ['/sso/login/azuread-oauth2']
|
||||||
|
|
||||||
|
# Mock gateway client methods
|
||||||
|
self.gateway_client.get_base_url.return_value = 'https://gateway.example.com:8080'
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should work since valid URL is contained in login_redirect_override
|
||||||
|
self.gateway_client.update_gateway_setting.assert_called_once_with(
|
||||||
|
'LOGIN_REDIRECT_OVERRIDE', 'https://gateway.example.com:8080/auth/login/azuread/456/?next=%2Fdashboard'
|
||||||
|
)
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is True
|
||||||
|
|
||||||
|
def test_handle_login_override_saml_with_parameters(self):
|
||||||
|
"""Test LOGIN_REDIRECT_OVERRIDE with SAML IDP parameters."""
|
||||||
|
config = {
|
||||||
|
'login_redirect_override': 'https://localhost:3000/sso/login/saml/?idp=mycompany',
|
||||||
|
'gateway_authenticator': {'id': 789, 'sso_login_url': '/auth/login/saml/789/'},
|
||||||
|
}
|
||||||
|
valid_login_urls = ['/sso/login/saml/?idp=mycompany']
|
||||||
|
|
||||||
|
# Mock gateway client methods
|
||||||
|
self.gateway_client.get_base_url.return_value = 'https://gateway.local'
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should work with SAML parameter URLs
|
||||||
|
self.gateway_client.update_gateway_setting.assert_called_once_with(
|
||||||
|
'LOGIN_REDIRECT_OVERRIDE', 'https://gateway.local/auth/login/saml/789/?idp=mycompany'
|
||||||
|
)
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is True
|
||||||
|
|
||||||
|
def test_handle_login_override_github_with_trailing_slash(self):
|
||||||
|
"""Test LOGIN_REDIRECT_OVERRIDE with trailing slash."""
|
||||||
|
config = {
|
||||||
|
'login_redirect_override': 'https://localhost:3000/sso/login/github-enterprise/',
|
||||||
|
'gateway_authenticator': {'id': 999, 'sso_login_url': '/auth/login/github/999/'},
|
||||||
|
}
|
||||||
|
valid_login_urls = ['/sso/login/github-enterprise', '/sso/login/github-enterprise/']
|
||||||
|
|
||||||
|
# Mock gateway client methods
|
||||||
|
self.gateway_client.get_base_url.return_value = 'https://gateway.internal'
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should work with trailing slash URLs
|
||||||
|
self.gateway_client.update_gateway_setting.assert_called_once_with('LOGIN_REDIRECT_OVERRIDE', 'https://gateway.internal/auth/login/github/999/')
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is True
|
||||||
|
|
||||||
|
def test_handle_login_override_empty_valid_urls_list(self):
|
||||||
|
"""Test that method returns early when valid_login_urls is empty."""
|
||||||
|
config = {
|
||||||
|
'login_redirect_override': 'https://localhost:3000/sso/login/github',
|
||||||
|
'gateway_authenticator': {'id': 123, 'sso_login_url': '/sso/auth/login/123/'},
|
||||||
|
}
|
||||||
|
valid_login_urls = []
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should not call any gateway client methods
|
||||||
|
self.gateway_client.get_base_url.assert_not_called()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_not_called()
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is False
|
||||||
|
|
||||||
|
def test_handle_login_override_preserves_existing_flag_state(self):
|
||||||
|
"""Test that method preserves flag state if it was already set."""
|
||||||
|
# Set flag to True initially
|
||||||
|
BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator = True
|
||||||
|
|
||||||
|
config = {
|
||||||
|
'login_redirect_override': 'https://localhost:3000/sso/login/github',
|
||||||
|
'gateway_authenticator': {'id': 123, 'sso_login_url': '/sso/auth/login/123/'},
|
||||||
|
}
|
||||||
|
valid_login_urls = ['/sso/login/github']
|
||||||
|
|
||||||
|
# Mock gateway client methods
|
||||||
|
self.gateway_client.get_base_url.return_value = 'https://gateway.example.com'
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Flag should still be True
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is True
|
||||||
|
|
||||||
|
def test_handle_login_override_writes_output_message(self):
|
||||||
|
"""Test that method writes output message when updating."""
|
||||||
|
config = {
|
||||||
|
'login_redirect_override': 'https://localhost:3000/sso/login/google-oauth2',
|
||||||
|
'gateway_authenticator': {'id': 555, 'sso_login_url': '/auth/login/google/555/'},
|
||||||
|
}
|
||||||
|
valid_login_urls = ['/sso/login/google-oauth2']
|
||||||
|
|
||||||
|
# Mock gateway client methods
|
||||||
|
self.gateway_client.get_base_url.return_value = 'https://gateway.test'
|
||||||
|
|
||||||
|
# Mock _write_output method
|
||||||
|
with patch.object(self.migrator, '_write_output') as mock_write_output:
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Verify output message was written
|
||||||
|
mock_write_output.assert_called_once_with('Updating LOGIN_REDIRECT_OVERRIDE to: https://gateway.test/auth/login/google/555/')
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"login_redirect_override,valid_urls,expected_match",
|
||||||
|
[
|
||||||
|
# Test Azure AD variations
|
||||||
|
('https://localhost:3000/sso/login/azuread-oauth2', ['/sso/login/azuread-oauth2'], True),
|
||||||
|
('https://localhost:3000/sso/login/azuread-oauth2/', ['/sso/login/azuread-oauth2'], True),
|
||||||
|
('https://controller.example.com/sso/login/azuread-oauth2?next=/home', ['/sso/login/azuread-oauth2'], True),
|
||||||
|
# Test Google OAuth2 variations
|
||||||
|
('https://localhost:3000/sso/login/google-oauth2', ['/sso/login/google-oauth2'], True),
|
||||||
|
('https://localhost:3000/sso/login/google-oauth2/', ['/sso/login/google-oauth2'], True),
|
||||||
|
# Test GitHub variations
|
||||||
|
('https://localhost:3000/sso/login/github', ['/sso/login/github'], True),
|
||||||
|
('https://localhost:3000/sso/login/github-org', ['/sso/login/github-org'], True),
|
||||||
|
('https://localhost:3000/sso/login/github-team', ['/sso/login/github-team'], True),
|
||||||
|
('https://localhost:3000/sso/login/github-enterprise', ['/sso/login/github-enterprise'], True),
|
||||||
|
# Test SAML variations
|
||||||
|
('https://localhost:3000/sso/login/saml/?idp=company', ['/sso/login/saml/?idp=company'], True),
|
||||||
|
('https://localhost:3000/sso/login/saml/?idp=test-org', ['/sso/login/saml/?idp=test-org'], True),
|
||||||
|
# Test non-matching cases
|
||||||
|
('https://localhost:3000/sso/login/ldap', ['/sso/login/github'], False),
|
||||||
|
('https://localhost:3000/sso/login/azuread-oauth2', ['/sso/login/google-oauth2'], False),
|
||||||
|
('https://localhost:3000/sso/login/saml/?idp=wrong', ['/sso/login/saml/?idp=company'], False),
|
||||||
|
# Test multiple valid URLs
|
||||||
|
('https://localhost:3000/sso/login/github-org', ['/sso/login/github', '/sso/login/github-org'], True),
|
||||||
|
('https://localhost:3000/sso/login/github', ['/sso/login/github-org', '/sso/login/github'], True),
|
||||||
|
# Test improved URL parsing scenarios - better boundary detection
|
||||||
|
('https://localhost:3000/sso/login/github-enterprise', ['/sso/login/github'], False), # Should NOT match due to better parsing
|
||||||
|
('https://localhost:3000/sso/login/saml/?idp=company&next=/home', ['/sso/login/saml/?idp=company'], True),
|
||||||
|
('https://localhost:3000/sso/login/saml/?idp=company', ['/sso/login/saml/?idp=different'], False),
|
||||||
|
('https://controller.example.com:8080/sso/login/azuread-oauth2/?next=/dashboard', ['/sso/login/azuread-oauth2'], True),
|
||||||
|
('http://localhost/sso/login/github?state=abc123', ['/sso/login/github'], True),
|
||||||
|
# Test boundary detection edge cases
|
||||||
|
('https://localhost:3000/sso/login/github/', ['/sso/login/github'], True), # Trailing slash should match
|
||||||
|
('https://localhost:3000/sso/login/github#section', ['/sso/login/github'], True), # Fragment should match
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_handle_login_override_url_matching_variations(self, login_redirect_override, valid_urls, expected_match):
|
||||||
|
"""Test various URL matching scenarios parametrically."""
|
||||||
|
config = {'login_redirect_override': login_redirect_override, 'gateway_authenticator': {'id': 123, 'sso_login_url': '/auth/login/test/123/'}}
|
||||||
|
|
||||||
|
# Mock gateway client methods
|
||||||
|
self.gateway_client.get_base_url.return_value = 'https://gateway.test'
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_urls)
|
||||||
|
|
||||||
|
if expected_match:
|
||||||
|
# Should call gateway methods when URL matches
|
||||||
|
self.gateway_client.get_base_url.assert_called_once()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_called_once()
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is True
|
||||||
|
else:
|
||||||
|
# Should not call gateway methods when URL doesn't match
|
||||||
|
self.gateway_client.get_base_url.assert_not_called()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_not_called()
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is False
|
||||||
|
|
||||||
|
def test_handle_login_override_improved_url_parsing(self):
|
||||||
|
"""Test that improved URL parsing with proper path boundary detection prevents false positive matches."""
|
||||||
|
# This test demonstrates the improvement over simple string matching
|
||||||
|
config = {
|
||||||
|
'login_redirect_override': 'https://localhost:3000/sso/login/github-enterprise',
|
||||||
|
'gateway_authenticator': {'id': 123, 'sso_login_url': '/auth/login/test/123/'},
|
||||||
|
}
|
||||||
|
|
||||||
|
# With the old simple string matching, this would incorrectly match
|
||||||
|
# because '/sso/login/github' is contained in '/sso/login/github-enterprise'
|
||||||
|
# But with proper URL parsing, it should NOT match
|
||||||
|
valid_login_urls = ['/sso/login/github']
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should NOT match due to improved parsing
|
||||||
|
self.gateway_client.get_base_url.assert_not_called()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_not_called()
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is False
|
||||||
|
|
||||||
|
def test_handle_login_override_query_parameter_handling(self):
|
||||||
|
"""Test that query parameters are properly handled in URL matching."""
|
||||||
|
config = {
|
||||||
|
'login_redirect_override': 'https://localhost:3000/sso/login/saml/?idp=mycompany&next=%2Fdashboard',
|
||||||
|
'gateway_authenticator': {'id': 456, 'sso_login_url': '/auth/login/saml/456/?idp=IdP'},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should match the SAML URL with the correct IDP parameter (boundary-aware matching)
|
||||||
|
valid_login_urls = ['/sso/login/saml/?idp=mycompany']
|
||||||
|
|
||||||
|
self.gateway_client.get_base_url.return_value = 'https://gateway.test'
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should match because the query parameter is properly contained with boundaries
|
||||||
|
self.gateway_client.get_base_url.assert_called_once()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_called_once_with(
|
||||||
|
'LOGIN_REDIRECT_OVERRIDE', 'https://gateway.test/auth/login/saml/456/?idp=IdP&next=%2Fdashboard'
|
||||||
|
)
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is True
|
||||||
|
|
||||||
|
def test_handle_login_override_different_query_parameters(self):
|
||||||
|
"""Test that different query parameters don't match."""
|
||||||
|
config = {
|
||||||
|
'login_redirect_override': 'https://localhost:3000/sso/login/saml/?idp=company-a',
|
||||||
|
'gateway_authenticator': {'id': 456, 'sso_login_url': '/auth/login/saml/456/'},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should NOT match SAML URL with different IDP parameter
|
||||||
|
valid_login_urls = ['/sso/login/saml/?idp=company-b']
|
||||||
|
|
||||||
|
self.migrator.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
# Should NOT match because the query parameters are different
|
||||||
|
self.gateway_client.get_base_url.assert_not_called()
|
||||||
|
self.gateway_client.update_gateway_setting.assert_not_called()
|
||||||
|
assert BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator is False
|
||||||
|
|||||||
@@ -397,6 +397,93 @@ class GatewayClient:
|
|||||||
|
|
||||||
return self.create_authenticator(config)
|
return self.create_authenticator(config)
|
||||||
|
|
||||||
|
def update_gateway_setting(self, setting_name: str, setting_value: Any) -> Dict[str, Any]:
|
||||||
|
"""Update a Gateway setting via the settings API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setting_name: Name of the setting to update
|
||||||
|
setting_value: Value to set for the setting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Upon successful update, well formed responses are returned, otherwise the original payload is returned.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
GatewayAPIError: If update fails, anything other than a 200 or 204 response code.
|
||||||
|
"""
|
||||||
|
endpoint = '/api/gateway/v1/settings/all/'
|
||||||
|
|
||||||
|
# Create the JSON payload with the setting name and value
|
||||||
|
payload = {setting_name: setting_value}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._make_request('PUT', endpoint, data=payload)
|
||||||
|
|
||||||
|
if response.status_code in [200, 204]:
|
||||||
|
logger.info(f"Successfully updated Gateway setting: {setting_name}")
|
||||||
|
# Return the response data if available, otherwise return the payload
|
||||||
|
if response.content:
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.JSONDecodeError:
|
||||||
|
return payload
|
||||||
|
return payload
|
||||||
|
else:
|
||||||
|
error_msg = f"Failed to update Gateway setting. Status: {response.status_code}"
|
||||||
|
try:
|
||||||
|
error_data = response.json()
|
||||||
|
error_msg += f", Error: {error_data}"
|
||||||
|
except requests.exceptions.JSONDecodeError:
|
||||||
|
error_msg += f", Response: {response.text}"
|
||||||
|
|
||||||
|
raise GatewayAPIError(error_msg, response.status_code, response.json() if response.content else None)
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise GatewayAPIError(f"Failed to update Gateway setting: {str(e)}")
|
||||||
|
|
||||||
|
def get_gateway_setting(self, setting_name: str) -> Any:
|
||||||
|
"""Get a Gateway setting value via the settings API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setting_name: Name of the setting to retrieve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: The value of the setting, or None if not found
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
GatewayAPIError: If request fails
|
||||||
|
"""
|
||||||
|
endpoint = '/api/gateway/v1/settings/all/'
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._make_request('GET', endpoint)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
settings_data = response.json()
|
||||||
|
logger.info("Successfully retrieved Gateway settings")
|
||||||
|
|
||||||
|
# Return the specific setting value or None if not found
|
||||||
|
return settings_data.get(setting_name)
|
||||||
|
else:
|
||||||
|
error_msg = f"Failed to get Gateway settings. Status: {response.status_code}"
|
||||||
|
try:
|
||||||
|
error_data = response.json()
|
||||||
|
error_msg += f", Error: {error_data}"
|
||||||
|
except requests.exceptions.JSONDecodeError:
|
||||||
|
error_msg += f", Response: {response.text}"
|
||||||
|
|
||||||
|
raise GatewayAPIError(error_msg, response.status_code, response.json() if response.content else None)
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise GatewayAPIError(f"Failed to get Gateway setting: {str(e)}")
|
||||||
|
|
||||||
|
def get_base_url(self) -> str:
|
||||||
|
"""Get the base URL of the Gateway instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The base URL of the Gateway instance
|
||||||
|
"""
|
||||||
|
return self.base_url
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Close the session and clean up resources."""
|
"""Close the session and clean up resources."""
|
||||||
if self.session:
|
if self.session:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class AzureADMigrator(BaseAuthenticatorMigrator):
|
|||||||
# If we have both key and secret, collect all settings
|
# If we have both key and secret, collect all settings
|
||||||
org_map_value = getattr(settings, 'SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP', None)
|
org_map_value = getattr(settings, 'SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP', None)
|
||||||
team_map_value = getattr(settings, 'SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP', None)
|
team_map_value = getattr(settings, 'SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP', None)
|
||||||
|
login_redirect_override = getattr(settings, "LOGIN_REDIRECT_OVERRIDE", None)
|
||||||
|
|
||||||
# Convert GitHub org and team mappings from AWX to the Gateway format
|
# Convert GitHub org and team mappings from AWX to the Gateway format
|
||||||
# Start with order 1 and maintain sequence across both org and team mappers
|
# Start with order 1 and maintain sequence across both org and team mappers
|
||||||
@@ -65,6 +66,7 @@ class AzureADMigrator(BaseAuthenticatorMigrator):
|
|||||||
},
|
},
|
||||||
'org_mappers': org_mappers,
|
'org_mappers': org_mappers,
|
||||||
'team_mappers': team_mappers,
|
'team_mappers': team_mappers,
|
||||||
|
'login_redirect_override': login_redirect_override,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -85,4 +87,10 @@ class AzureADMigrator(BaseAuthenticatorMigrator):
|
|||||||
ignore_keys = ["CALLBACK_URL", "GROUPS_CLAIM"]
|
ignore_keys = ["CALLBACK_URL", "GROUPS_CLAIM"]
|
||||||
|
|
||||||
# Submit the authenticator (create or update as needed)
|
# Submit the authenticator (create or update as needed)
|
||||||
return self.submit_authenticator(gateway_config, ignore_keys, config)
|
result = self.submit_authenticator(gateway_config, ignore_keys, config)
|
||||||
|
|
||||||
|
# Handle LOGIN_REDIRECT_OVERRIDE if applicable
|
||||||
|
valid_login_urls = ['/sso/login/azuread-oauth2']
|
||||||
|
self.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Base authenticator migrator class.
|
|||||||
This module defines the contract that all specific authenticator migrators must follow.
|
This module defines the contract that all specific authenticator migrators must follow.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import urlparse, parse_qs, urlencode
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from awx.main.utils.gateway_client import GatewayAPIError
|
from awx.main.utils.gateway_client import GatewayAPIError
|
||||||
|
|
||||||
@@ -14,6 +15,10 @@ class BaseAuthenticatorMigrator:
|
|||||||
Defines the contract that all specific authenticator migrators must follow.
|
Defines the contract that all specific authenticator migrators must follow.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
KEYS_TO_PRESERVE = ['idp']
|
||||||
|
# Class-level flag to track if LOGIN_REDIRECT_OVERRIDE was set by any migrator
|
||||||
|
login_redirect_override_set_by_migrator = False
|
||||||
|
|
||||||
def __init__(self, gateway_client=None, command=None, force=False):
|
def __init__(self, gateway_client=None, command=None, force=False):
|
||||||
"""
|
"""
|
||||||
Initialize the authenticator migrator.
|
Initialize the authenticator migrator.
|
||||||
@@ -88,6 +93,7 @@ class BaseAuthenticatorMigrator:
|
|||||||
mappers_updated += mapper_result['updated']
|
mappers_updated += mapper_result['updated']
|
||||||
mappers_failed += mapper_result['failed']
|
mappers_failed += mapper_result['failed']
|
||||||
|
|
||||||
|
# Authenticators don't have settings, so settings counts are always 0
|
||||||
return {
|
return {
|
||||||
'created': len(created_authenticators),
|
'created': len(created_authenticators),
|
||||||
'updated': len(updated_authenticators),
|
'updated': len(updated_authenticators),
|
||||||
@@ -96,6 +102,10 @@ class BaseAuthenticatorMigrator:
|
|||||||
'mappers_created': mappers_created,
|
'mappers_created': mappers_created,
|
||||||
'mappers_updated': mappers_updated,
|
'mappers_updated': mappers_updated,
|
||||||
'mappers_failed': mappers_failed,
|
'mappers_failed': mappers_failed,
|
||||||
|
'settings_created': 0,
|
||||||
|
'settings_updated': 0,
|
||||||
|
'settings_unchanged': 0,
|
||||||
|
'settings_failed': 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_controller_config(self):
|
def get_controller_config(self):
|
||||||
@@ -553,6 +563,101 @@ class BaseAuthenticatorMigrator:
|
|||||||
global_map = getattr(settings, 'SOCIAL_AUTH_TEAM_MAP', {})
|
global_map = getattr(settings, 'SOCIAL_AUTH_TEAM_MAP', {})
|
||||||
return global_map
|
return global_map
|
||||||
|
|
||||||
|
def handle_login_override(self, config, valid_login_urls):
|
||||||
|
"""
|
||||||
|
Handle LOGIN_REDIRECT_OVERRIDE setting for this authenticator.
|
||||||
|
|
||||||
|
This method checks if the login_redirect_override from the config matches
|
||||||
|
any of the provided valid_login_urls. If it matches, it updates the
|
||||||
|
LOGIN_REDIRECT_OVERRIDE setting in Gateway with the new authenticator's
|
||||||
|
URL and sets the class flag to indicate it was handled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Configuration dictionary containing:
|
||||||
|
- login_redirect_override: The current LOGIN_REDIRECT_OVERRIDE value
|
||||||
|
- gateway_authenticator: The created/updated authenticator info
|
||||||
|
valid_login_urls: List of URL patterns to match against
|
||||||
|
"""
|
||||||
|
login_redirect_override = config.get('login_redirect_override')
|
||||||
|
if not login_redirect_override:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if the login_redirect_override matches any of the provided valid URLs
|
||||||
|
url_matches = False
|
||||||
|
parsed_redirect = urlparse(login_redirect_override)
|
||||||
|
self.redirect_query_dict = parse_qs(parsed_redirect.query, keep_blank_values=True) if parsed_redirect.query else {}
|
||||||
|
|
||||||
|
for valid_url in valid_login_urls:
|
||||||
|
parsed_valid = urlparse(valid_url)
|
||||||
|
|
||||||
|
# Compare path: redirect path should match or contain the valid path at proper boundaries
|
||||||
|
if parsed_redirect.path == parsed_valid.path:
|
||||||
|
path_matches = True
|
||||||
|
elif parsed_redirect.path.startswith(parsed_valid.path):
|
||||||
|
# Ensure the match is at a path boundary (followed by '/' or end of string)
|
||||||
|
next_char_pos = len(parsed_valid.path)
|
||||||
|
if next_char_pos >= len(parsed_redirect.path) or parsed_redirect.path[next_char_pos] in ['/', '?']:
|
||||||
|
path_matches = True
|
||||||
|
else:
|
||||||
|
path_matches = False
|
||||||
|
else:
|
||||||
|
path_matches = False
|
||||||
|
|
||||||
|
# Compare query: if valid URL has query params, they should be present in redirect URL
|
||||||
|
query_matches = True
|
||||||
|
if parsed_valid.query:
|
||||||
|
# Parse query parameters for both URLs
|
||||||
|
valid_params = parse_qs(parsed_valid.query, keep_blank_values=True)
|
||||||
|
|
||||||
|
# All valid URL query params must be present in redirect URL with same values
|
||||||
|
query_matches = all(param in self.redirect_query_dict and self.redirect_query_dict[param] == values for param, values in valid_params.items())
|
||||||
|
|
||||||
|
if path_matches and query_matches:
|
||||||
|
url_matches = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not url_matches:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract the created authenticator from config
|
||||||
|
gateway_authenticator = config.get('gateway_authenticator')
|
||||||
|
if not gateway_authenticator:
|
||||||
|
return
|
||||||
|
|
||||||
|
sso_login_url = gateway_authenticator.get('sso_login_url')
|
||||||
|
if not sso_login_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update LOGIN_REDIRECT_OVERRIDE with the new Gateway URL
|
||||||
|
gateway_base_url = self.gateway_client.get_base_url()
|
||||||
|
parsed_sso = urlparse(sso_login_url)
|
||||||
|
parsed_gw = urlparse(gateway_base_url)
|
||||||
|
updated_query = self._updated_query_string(parsed_sso)
|
||||||
|
complete_url = parsed_redirect._replace(scheme=parsed_gw.scheme, path=parsed_sso.path, netloc=parsed_gw.netloc, query=updated_query).geturl()
|
||||||
|
self._write_output(f'Updating LOGIN_REDIRECT_OVERRIDE to: {complete_url}')
|
||||||
|
self.gateway_client.update_gateway_setting('LOGIN_REDIRECT_OVERRIDE', complete_url)
|
||||||
|
|
||||||
|
# Set the class-level flag to indicate LOGIN_REDIRECT_OVERRIDE was handled by a migrator
|
||||||
|
BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator = True
|
||||||
|
|
||||||
|
def _updated_query_string(self, parsed_sso):
|
||||||
|
if parsed_sso.query:
|
||||||
|
parsed_sso_dict = parse_qs(parsed_sso.query, keep_blank_values=True)
|
||||||
|
else:
|
||||||
|
parsed_sso_dict = {}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for k, v in self.redirect_query_dict.items():
|
||||||
|
if k in self.KEYS_TO_PRESERVE and k in parsed_sso_dict:
|
||||||
|
v = parsed_sso_dict[k]
|
||||||
|
|
||||||
|
if isinstance(v, list) and len(v) == 1:
|
||||||
|
result[k] = v[0]
|
||||||
|
else:
|
||||||
|
result[k] = v
|
||||||
|
|
||||||
|
return urlencode(result, doseq=True) if result else ""
|
||||||
|
|
||||||
def _write_output(self, message, style=None):
|
def _write_output(self, message, style=None):
|
||||||
"""Write output message if command is available."""
|
"""Write output message if command is available."""
|
||||||
if self.command:
|
if self.command:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class GitHubMigrator(BaseAuthenticatorMigrator):
|
|||||||
list: List of configured GitHub authentication providers with their settings
|
list: List of configured GitHub authentication providers with their settings
|
||||||
"""
|
"""
|
||||||
github_categories = ['github', 'github-org', 'github-team', 'github-enterprise', 'github-enterprise-org', 'github-enterprise-team']
|
github_categories = ['github', 'github-org', 'github-team', 'github-enterprise', 'github-enterprise-org', 'github-enterprise-team']
|
||||||
|
login_redirect_override = getattr(settings, "LOGIN_REDIRECT_OVERRIDE", None)
|
||||||
|
|
||||||
found_configs = []
|
found_configs = []
|
||||||
|
|
||||||
@@ -91,7 +92,15 @@ class GitHubMigrator(BaseAuthenticatorMigrator):
|
|||||||
org_mappers, next_order = org_map_to_gateway_format(org_map_value, start_order=1)
|
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)
|
team_mappers, _ = team_map_to_gateway_format(team_map_value, start_order=next_order)
|
||||||
|
|
||||||
found_configs.append({'category': category, 'settings': config_data, 'org_mappers': org_mappers, 'team_mappers': team_mappers})
|
found_configs.append(
|
||||||
|
{
|
||||||
|
'category': category,
|
||||||
|
'settings': config_data,
|
||||||
|
'org_mappers': org_mappers,
|
||||||
|
'team_mappers': team_mappers,
|
||||||
|
'login_redirect_override': login_redirect_override,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f'Could not retrieve {category} settings: {str(e)}')
|
raise Exception(f'Could not retrieve {category} settings: {str(e)}')
|
||||||
@@ -165,7 +174,13 @@ class GitHubMigrator(BaseAuthenticatorMigrator):
|
|||||||
ignore_keys = ['CALLBACK_URL', 'SCOPE']
|
ignore_keys = ['CALLBACK_URL', 'SCOPE']
|
||||||
|
|
||||||
# Submit the authenticator (create or update as needed)
|
# Submit the authenticator (create or update as needed)
|
||||||
return self.submit_authenticator(gateway_config, ignore_keys, config)
|
result = self.submit_authenticator(gateway_config, ignore_keys, config)
|
||||||
|
|
||||||
|
# Handle LOGIN_REDIRECT_OVERRIDE if applicable
|
||||||
|
valid_login_urls = [f'/sso/login/{category}', f'/sso/login/{category}/']
|
||||||
|
self.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def _build_additional_config(self, category, settings):
|
def _build_additional_config(self, category, settings):
|
||||||
"""Build additional configuration for specific authenticator types."""
|
"""Build additional configuration for specific authenticator types."""
|
||||||
|
|||||||
@@ -37,10 +37,13 @@ class GoogleOAuth2Migrator(BaseAuthenticatorMigrator):
|
|||||||
'SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE': settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE,
|
'SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE': settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
login_redirect_override = getattr(settings, "LOGIN_REDIRECT_OVERRIDE", None)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"category": self.get_authenticator_type(),
|
"category": self.get_authenticator_type(),
|
||||||
"settings": config_data,
|
"settings": config_data,
|
||||||
|
"login_redirect_override": login_redirect_override,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -90,4 +93,10 @@ class GoogleOAuth2Migrator(BaseAuthenticatorMigrator):
|
|||||||
else:
|
else:
|
||||||
ignore_keys.append(key)
|
ignore_keys.append(key)
|
||||||
|
|
||||||
return self.submit_authenticator(gateway_config, ignore_keys, config)
|
result = self.submit_authenticator(gateway_config, ignore_keys, config)
|
||||||
|
|
||||||
|
# Handle LOGIN_REDIRECT_OVERRIDE if applicable
|
||||||
|
valid_login_urls = ['/sso/login/google-oauth2']
|
||||||
|
self.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class SAMLMigrator(BaseAuthenticatorMigrator):
|
|||||||
saml_team_attr = getattr(settings, "SOCIAL_AUTH_SAML_TEAM_ATTR", {})
|
saml_team_attr = getattr(settings, "SOCIAL_AUTH_SAML_TEAM_ATTR", {})
|
||||||
org_attr = getattr(settings, "SOCIAL_AUTH_SAML_ORGANIZATION_ATTR", {})
|
org_attr = getattr(settings, "SOCIAL_AUTH_SAML_ORGANIZATION_ATTR", {})
|
||||||
user_flags_by_attr = getattr(settings, "SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR", {})
|
user_flags_by_attr = getattr(settings, "SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR", {})
|
||||||
|
login_redirect_override = getattr(settings, "LOGIN_REDIRECT_OVERRIDE", None)
|
||||||
|
|
||||||
org_mappers, self.next_order = org_map_to_gateway_format(org_map_value, start_order=self.next_order)
|
org_mappers, self.next_order = org_map_to_gateway_format(org_map_value, start_order=self.next_order)
|
||||||
self.team_mappers, self.next_order = team_map_to_gateway_format(team_map_value, start_order=self.next_order)
|
self.team_mappers, self.next_order = team_map_to_gateway_format(team_map_value, start_order=self.next_order)
|
||||||
@@ -135,6 +136,7 @@ class SAMLMigrator(BaseAuthenticatorMigrator):
|
|||||||
"settings": config_data,
|
"settings": config_data,
|
||||||
"org_mappers": org_mappers,
|
"org_mappers": org_mappers,
|
||||||
"team_mappers": self.team_mappers,
|
"team_mappers": self.team_mappers,
|
||||||
|
"login_redirect_override": login_redirect_override,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return found_configs
|
return found_configs
|
||||||
@@ -147,7 +149,7 @@ class SAMLMigrator(BaseAuthenticatorMigrator):
|
|||||||
|
|
||||||
# Generate authenticator name and slug
|
# Generate authenticator name and slug
|
||||||
authenticator_name = f"AWX-{category.replace('-', '_').title()}-{name}"
|
authenticator_name = f"AWX-{category.replace('-', '_').title()}-{name}"
|
||||||
authenticator_slug = self._generate_authenticator_slug("saml", category)
|
authenticator_slug = self._generate_authenticator_slug("saml", name)
|
||||||
|
|
||||||
self._write_output(f"\n--- Processing {category} authenticator ---")
|
self._write_output(f"\n--- Processing {category} authenticator ---")
|
||||||
self._write_output(f"Name: {authenticator_name}")
|
self._write_output(f"Name: {authenticator_name}")
|
||||||
@@ -169,7 +171,13 @@ class SAMLMigrator(BaseAuthenticatorMigrator):
|
|||||||
ignore_keys = ["CALLBACK_URL", "SP_PRIVATE_KEY"]
|
ignore_keys = ["CALLBACK_URL", "SP_PRIVATE_KEY"]
|
||||||
|
|
||||||
# Submit the authenticator (create or update as needed)
|
# Submit the authenticator (create or update as needed)
|
||||||
return self.submit_authenticator(gateway_config, ignore_keys, config)
|
result = self.submit_authenticator(gateway_config, ignore_keys, config)
|
||||||
|
|
||||||
|
# Handle LOGIN_REDIRECT_OVERRIDE if applicable
|
||||||
|
valid_login_urls = [f'/sso/login/saml/?idp={name}', f'/sso/login/saml/?idp={name}/']
|
||||||
|
self.handle_login_override(config, valid_login_urls)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def _team_attr_to_gateway_format(self, saml_team_attr):
|
def _team_attr_to_gateway_format(self, saml_team_attr):
|
||||||
saml_attr = saml_team_attr.get("saml_attr")
|
saml_attr = saml_team_attr.get("saml_attr")
|
||||||
|
|||||||
175
awx/sso/utils/settings_migrator.py
Normal file
175
awx/sso/utils/settings_migrator.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""
|
||||||
|
Settings migrator.
|
||||||
|
|
||||||
|
This module handles the migration of AWX settings to Gateway.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from awx.sso.utils.base_migrator import BaseAuthenticatorMigrator
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsMigrator(BaseAuthenticatorMigrator):
|
||||||
|
"""
|
||||||
|
Handles the migration of AWX settings to Gateway.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# Define transformer functions for each setting
|
||||||
|
self.setting_transformers = {
|
||||||
|
'SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL': self._transform_social_auth_username_is_full_email,
|
||||||
|
'ALLOW_OAUTH2_FOR_EXTERNAL_USERS': self._transform_allow_oauth2_for_external_users,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _convert_setting_name(self, setting):
|
||||||
|
keys = {
|
||||||
|
"CUSTOM_LOGIN_INFO": "custom_login_info",
|
||||||
|
"CUSTOM_LOGO": "custom_logo",
|
||||||
|
}
|
||||||
|
return keys.get(setting, setting)
|
||||||
|
|
||||||
|
def _transform_social_auth_username_is_full_email(self, value):
|
||||||
|
# SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL is a boolean and does not need to be transformed
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _transform_allow_oauth2_for_external_users(self, value):
|
||||||
|
# ALLOW_OAUTH2_FOR_EXTERNAL_USERS is a boolean and does not need to be transformed
|
||||||
|
return value
|
||||||
|
|
||||||
|
def get_authenticator_type(self):
|
||||||
|
"""Get the human-readable authenticator type name."""
|
||||||
|
return "Settings"
|
||||||
|
|
||||||
|
def get_controller_config(self):
|
||||||
|
"""
|
||||||
|
Export relevant AWX settings that need to be migrated to Gateway.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of configured settings that need to be migrated
|
||||||
|
"""
|
||||||
|
# Define settings that should be migrated from AWX to Gateway
|
||||||
|
settings_to_migrate = ['SESSION_COOKIE_AGE', 'SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL', 'ALLOW_OAUTH2_FOR_EXTERNAL_USERS']
|
||||||
|
|
||||||
|
# Add LOGIN_REDIRECT_OVERRIDE to the list if no authenticator migrator has handled it
|
||||||
|
if not BaseAuthenticatorMigrator.login_redirect_override_set_by_migrator:
|
||||||
|
settings_to_migrate.append("LOGIN_REDIRECT_OVERRIDE")
|
||||||
|
|
||||||
|
found_configs = []
|
||||||
|
|
||||||
|
for setting_name in settings_to_migrate:
|
||||||
|
setting_value = getattr(settings, setting_name, None)
|
||||||
|
|
||||||
|
# Only include settings that have non-None and non-empty values
|
||||||
|
if setting_value is not None and setting_value != "":
|
||||||
|
# Apply transformer function if available
|
||||||
|
transformer = self.setting_transformers.get(setting_name)
|
||||||
|
if transformer:
|
||||||
|
setting_value = transformer(setting_value)
|
||||||
|
|
||||||
|
# Skip migration if transformer returned None or empty string
|
||||||
|
if setting_value is not None and setting_value != "":
|
||||||
|
found_configs.append(
|
||||||
|
{
|
||||||
|
'category': 'global-settings',
|
||||||
|
'setting_name': setting_name,
|
||||||
|
'setting_value': setting_value,
|
||||||
|
'org_mappers': [], # Settings don't have mappers
|
||||||
|
'team_mappers': [], # Settings don't have mappers
|
||||||
|
'role_mappers': [], # Settings don't have mappers
|
||||||
|
'allow_mappers': [], # Settings don't have mappers
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._write_output(f'\nIgnoring {setting_name} because it is None or empty after transformation')
|
||||||
|
else:
|
||||||
|
self._write_output(f'\nIgnoring {setting_name} because it is None or empty')
|
||||||
|
|
||||||
|
return found_configs
|
||||||
|
|
||||||
|
def create_gateway_authenticator(self, config):
|
||||||
|
"""
|
||||||
|
Migrate AWX settings to Gateway.
|
||||||
|
|
||||||
|
Note: This doesn't create authenticators, but updates Gateway settings.
|
||||||
|
"""
|
||||||
|
setting_name = config['setting_name']
|
||||||
|
setting_value = config['setting_value']
|
||||||
|
|
||||||
|
self._write_output(f'\n--- Migrating setting: {setting_name} ---')
|
||||||
|
self._write_output(f'New value: {setting_value}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
gateway_setting_name = self._convert_setting_name(setting_name)
|
||||||
|
|
||||||
|
# Use the new update_gateway_setting method
|
||||||
|
self.gateway_client.update_gateway_setting(gateway_setting_name, setting_value)
|
||||||
|
|
||||||
|
self._write_output(f'✓ Successfully migrated setting: {setting_name}', 'success')
|
||||||
|
|
||||||
|
# Return success result in the expected format
|
||||||
|
return {'success': True, 'action': 'updated', 'error': None}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._write_output(f'✗ Failed to migrate setting {setting_name}: {str(e)}', 'error')
|
||||||
|
return {'success': False, 'action': 'failed', 'error': str(e)}
|
||||||
|
|
||||||
|
def migrate(self):
|
||||||
|
"""
|
||||||
|
Main entry point - orchestrates the settings migration process.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Summary of migration results
|
||||||
|
"""
|
||||||
|
# Get settings from AWX/Controller
|
||||||
|
configs = self.get_controller_config()
|
||||||
|
|
||||||
|
if not configs:
|
||||||
|
self._write_output('No settings found to migrate.', 'warning')
|
||||||
|
return {
|
||||||
|
'created': 0,
|
||||||
|
'updated': 0,
|
||||||
|
'unchanged': 0,
|
||||||
|
'failed': 0,
|
||||||
|
'mappers_created': 0,
|
||||||
|
'mappers_updated': 0,
|
||||||
|
'mappers_failed': 0,
|
||||||
|
'settings_created': 0,
|
||||||
|
'settings_updated': 0,
|
||||||
|
'settings_unchanged': 0,
|
||||||
|
'settings_failed': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
self._write_output(f'Found {len(configs)} setting(s) to migrate.', 'success')
|
||||||
|
|
||||||
|
# Process each setting
|
||||||
|
created_settings = []
|
||||||
|
updated_settings = []
|
||||||
|
unchanged_settings = []
|
||||||
|
failed_settings = []
|
||||||
|
|
||||||
|
for config in configs:
|
||||||
|
result = self.create_gateway_authenticator(config)
|
||||||
|
if result['success']:
|
||||||
|
if result['action'] == 'created':
|
||||||
|
created_settings.append(config)
|
||||||
|
elif result['action'] == 'updated':
|
||||||
|
updated_settings.append(config)
|
||||||
|
elif result['action'] == 'skipped':
|
||||||
|
unchanged_settings.append(config)
|
||||||
|
else:
|
||||||
|
failed_settings.append(config)
|
||||||
|
|
||||||
|
# Settings don't have mappers, or authenticators, so authenticator and mapper counts are always 0
|
||||||
|
return {
|
||||||
|
'created': 0,
|
||||||
|
'updated': 0,
|
||||||
|
'unchanged': 0,
|
||||||
|
'failed': 0,
|
||||||
|
'mappers_created': 0,
|
||||||
|
'mappers_updated': 0,
|
||||||
|
'mappers_failed': 0,
|
||||||
|
'settings_created': len(created_settings),
|
||||||
|
'settings_updated': len(updated_settings),
|
||||||
|
'settings_unchanged': len(unchanged_settings),
|
||||||
|
'settings_failed': len(failed_settings),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user