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 a03097245f..3b52439c53 100644 --- a/awx/main/management/commands/import_auth_config_to_gateway.py +++ b/awx/main/management/commands/import_auth_config_to_gateway.py @@ -18,6 +18,7 @@ class Command(BaseCommand): 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-saml', action='store_true', help='Skip importing SAML authenticator') + parser.add_argument('--force', action='store_true', help='Force migration even if configurations already exist') def handle(self, *args, **options): # Read Gateway connection parameters from environment variables @@ -30,6 +31,7 @@ class Command(BaseCommand): skip_ldap = options['skip_ldap'] skip_ad = options['skip_ad'] skip_saml = options['skip_saml'] + force = options['force'] # If the management command isn't called with all parameters needed to talk to Gateway, consider # it a dry-run and exit cleanly @@ -57,21 +59,23 @@ class Command(BaseCommand): # Initialize migrators migrators = [] if not skip_oidc: - migrators.append(GitHubMigrator(gateway_client, self)) - migrators.append(OIDCMigrator(gateway_client, self)) + migrators.append(GitHubMigrator(gateway_client, self, force=force)) + migrators.append(OIDCMigrator(gateway_client, self, force=force)) if not skip_saml: - migrators.append(SAMLMigrator(gateway_client, self)) + migrators.append(SAMLMigrator(gateway_client, self, force=force)) if not skip_ad: - migrators.append(AzureADMigrator(gateway_client, self)) + migrators.append(AzureADMigrator(gateway_client, self, force=force)) if not skip_ldap: - migrators.append(LDAPMigrator(gateway_client, self)) + migrators.append(LDAPMigrator(gateway_client, self, force=force)) # Run migrations total_results = { 'created': 0, + 'updated': 0, + 'unchanged': 0, 'failed': 0, 'mappers_created': 0, 'mappers_updated': 0, @@ -93,6 +97,8 @@ class Command(BaseCommand): # Overall summary self.stdout.write(self.style.SUCCESS('\n=== Migration Summary ===')) self.stdout.write(f'Total authenticators created: {total_results["created"]}') + self.stdout.write(f'Total authenticators updated: {total_results["updated"]}') + self.stdout.write(f'Total authenticators unchanged: {total_results["unchanged"]}') self.stdout.write(f'Total authenticators failed: {total_results["failed"]}') self.stdout.write(f'Total mappers created: {total_results["mappers_created"]}') self.stdout.write(f'Total mappers updated: {total_results["mappers_updated"]}') @@ -113,6 +119,8 @@ class Command(BaseCommand): """Print a summary of the export results.""" 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)}') + self.stdout.write(f'Authenticators unchanged: {result.get("unchanged", 0)}') self.stdout.write(f'Authenticators failed: {result.get("failed", 0)}') self.stdout.write(f'Mappers created: {result.get("mappers_created", 0)}') self.stdout.write(f'Mappers updated: {result.get("mappers_updated", 0)}') diff --git a/awx/sso/utils/base_migrator.py b/awx/sso/utils/base_migrator.py index 270af65bd2..9fa2245f34 100644 --- a/awx/sso/utils/base_migrator.py +++ b/awx/sso/utils/base_migrator.py @@ -13,16 +13,32 @@ class BaseAuthenticatorMigrator: Defines the contract that all specific authenticator migrators must follow. """ - def __init__(self, gateway_client=None, command=None): + def __init__(self, gateway_client=None, command=None, force=False): """ Initialize the authenticator migrator. Args: gateway_client: GatewayClient instance for API calls command: Optional Django management command instance (for styled output) + force: If True, force migration even if configurations already exist """ self.gateway_client = gateway_client self.command = command + self.force = force + self.encrypted_fields = [ + # LDAP Fields + 'BIND_PASSWORD', + # The following authenticators all use the same key to store encrypted information: + # Generic OIDC + # RADIUS + # TACACS+ + # GitHub OAuth2 + # Azure AD OAuth2 + # Google OAuth2 + 'SECRET', + # SAML Fields + 'SP_PRIVATE_KEY', + ] def migrate(self): """ @@ -36,23 +52,36 @@ class BaseAuthenticatorMigrator: if not configs: self._write_output(f'No {self.get_authenticator_type()} authenticators found to migrate.', 'warning') - return {'created': 0, 'failed': 0, 'mappers_created': 0, 'mappers_failed': 0} + return {'created': 0, 'updated': 0, 'unchanged': 0, 'failed': 0, 'mappers_created': 0, 'mappers_updated': 0, 'mappers_failed': 0} self._write_output(f'Found {len(configs)} {self.get_authenticator_type()} authentication configuration(s).', 'success') # Process each authenticator configuration created_authenticators = [] - for config in configs: - if self.create_gateway_authenticator(config): - created_authenticators.append(config) + updated_authenticators = [] + unchanged_authenticators = [] + failed_authenticators = [] - # Process mappers for successfully created/updated authenticators + for config in configs: + result = self.create_gateway_authenticator(config) + if result['success']: + if result['action'] == 'created': + created_authenticators.append(config) + elif result['action'] == 'updated': + updated_authenticators.append(config) + elif result['action'] == 'skipped': + unchanged_authenticators.append(config) + else: + failed_authenticators.append(config) + + # Process mappers for successfully created/updated/unchanged authenticators mappers_created = 0 mappers_updated = 0 mappers_failed = 0 - if created_authenticators: + successful_authenticators = created_authenticators + updated_authenticators + unchanged_authenticators + if successful_authenticators: self._write_output('\n=== Processing Authenticator Mappers ===', 'success') - for config in created_authenticators: + for config in successful_authenticators: mapper_result = self._process_gateway_mappers(config) mappers_created += mapper_result['created'] mappers_updated += mapper_result['updated'] @@ -60,7 +89,9 @@ class BaseAuthenticatorMigrator: return { 'created': len(created_authenticators), - 'failed': len(configs) - len(created_authenticators), + 'updated': len(updated_authenticators), + 'unchanged': len(unchanged_authenticators), + 'failed': len(failed_authenticators), 'mappers_created': mappers_created, 'mappers_updated': mappers_updated, 'mappers_failed': mappers_failed, @@ -98,7 +129,7 @@ class BaseAuthenticatorMigrator: def _generate_authenticator_slug(self, auth_type, category): """Generate a deterministic slug for an authenticator.""" - return f"aap-{auth_type}-{category}" + return f"aap-{auth_type}-{category}".lower() def submit_authenticator(self, gateway_config, ignore_keys=[], config={}): """ @@ -110,12 +141,12 @@ class BaseAuthenticatorMigrator: config: Optional AWX config dict to store result data Returns: - bool: True if authenticator was submitted successfully, False otherwise + dict: Result with 'success' (bool), 'action' ('created', 'updated', 'skipped'), 'error' (str or None) """ authenticator_slug = gateway_config.get('slug') if not authenticator_slug: self._write_output('Gateway config missing slug, cannot submit authenticator', 'error') - return False + return {'success': False, 'action': None, 'error': 'Missing slug'} try: # Check if authenticator already exists by slug @@ -132,7 +163,7 @@ class BaseAuthenticatorMigrator: # Store the existing result for mapper creation config['gateway_authenticator_id'] = authenticator_id config['gateway_authenticator'] = existing_authenticator - return True + return {'success': True, 'action': 'skipped', 'error': None} else: self._write_output(f'⚠ Authenticator exists but configuration differs (ID: {authenticator_id})', 'warning') self._write_output(' Configuration comparison:') @@ -155,12 +186,12 @@ class BaseAuthenticatorMigrator: # Store the updated result for mapper creation config['gateway_authenticator_id'] = authenticator_id config['gateway_authenticator'] = result - return True + return {'success': True, 'action': 'updated', 'error': None} except GatewayAPIError as e: self._write_output(f'✗ Failed to update authenticator: {e.message}', 'error') if e.response_data: self._write_output(f' Details: {e.response_data}', 'error') - return False + return {'success': False, 'action': 'update_failed', 'error': e.message} else: # Authenticator doesn't exist, create it self._write_output('Creating new authenticator...') @@ -173,16 +204,16 @@ class BaseAuthenticatorMigrator: # Store the result for potential mapper creation later config['gateway_authenticator_id'] = result.get('id') config['gateway_authenticator'] = result - return True + return {'success': True, 'action': 'created', 'error': None} except GatewayAPIError as e: self._write_output(f'✗ Failed to submit authenticator: {e.message}', 'error') if e.response_data: self._write_output(f' Details: {e.response_data}', 'error') - return False + return {'success': False, 'action': 'failed', 'error': e.message} except Exception as e: self._write_output(f'✗ Unexpected error submitting authenticator: {str(e)}', 'error') - return False + return {'success': False, 'action': 'failed', 'error': str(e)} def _authenticator_configs_match(self, existing_auth, new_config, ignore_keys=[]): """ @@ -197,6 +228,11 @@ class BaseAuthenticatorMigrator: Returns: bool: True if configurations match, False otherwise """ + # Add encrypted fields to ignore_keys if force flag is not set + # This prevents secrets from being updated unless explicitly forced + effective_ignore_keys = ignore_keys.copy() + if not self.force: + effective_ignore_keys.extend(self.encrypted_fields) # Keep track of the differences between the existing and the new configuration # Logging them makes debugging much easier @@ -219,7 +255,7 @@ class BaseAuthenticatorMigrator: # Helper function to check if a key should be ignored def should_ignore_key(config_key): - return config_key in ignore_keys + return config_key in effective_ignore_keys # Check if all keys in new config exist in existing config with same values for key, value in new_config_section.items(): diff --git a/awx/sso/utils/github_migrator.py b/awx/sso/utils/github_migrator.py index 708bb510a5..b1f4adbccd 100644 --- a/awx/sso/utils/github_migrator.py +++ b/awx/sso/utils/github_migrator.py @@ -158,7 +158,7 @@ class GitHubMigrator(BaseAuthenticatorMigrator): # CALLBACK_URL - automatically created by Gateway # SCOPE - relevant for mappers with team/org requirement, allows to read the org or team # SECRET - the secret is encrypted in Gateway, we have no way of comparing the decrypted value - ignore_keys = ['CALLBACK_URL', 'SCOPE', 'SECRET'] + ignore_keys = ['CALLBACK_URL', 'SCOPE'] # Submit the authenticator (create or update as needed) return self.submit_authenticator(gateway_config, ignore_keys, config) diff --git a/awx/sso/utils/ldap_migrator.py b/awx/sso/utils/ldap_migrator.py index 3976393189..bf61f5fc06 100644 --- a/awx/sso/utils/ldap_migrator.py +++ b/awx/sso/utils/ldap_migrator.py @@ -165,7 +165,7 @@ class LDAPMigrator(BaseAuthenticatorMigrator): # LDAP authenticators have auto-generated fields that should be ignored during comparison # BIND_PASSWORD - encrypted value, can't be compared - ignore_keys = ['BIND_PASSWORD'] + ignore_keys = [] # Submit the authenticator using the base class method return self.submit_authenticator(gateway_config, config=config, ignore_keys=ignore_keys)