mirror of
https://github.com/ansible/awx.git
synced 2026-03-10 22:19:28 -02:30
add force flag to enforce updates even when authenticator already exists (#7015)
* add force flag to enforce updates even when authenticator already exists * remove cleartext field * update list of encrypted fields * show updated and unchanged authenticators in report
This commit is contained in:
@@ -18,6 +18,7 @@ class Command(BaseCommand):
|
|||||||
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('--force', action='store_true', help='Force migration even if configurations already exist')
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
# Read Gateway connection parameters from environment variables
|
# Read Gateway connection parameters from environment variables
|
||||||
@@ -30,6 +31,7 @@ class Command(BaseCommand):
|
|||||||
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']
|
||||||
|
force = options['force']
|
||||||
|
|
||||||
# If the management command isn't called with all parameters needed to talk to Gateway, consider
|
# If the management command isn't called with all parameters needed to talk to Gateway, consider
|
||||||
# it a dry-run and exit cleanly
|
# it a dry-run and exit cleanly
|
||||||
@@ -57,21 +59,23 @@ class Command(BaseCommand):
|
|||||||
# Initialize migrators
|
# Initialize migrators
|
||||||
migrators = []
|
migrators = []
|
||||||
if not skip_oidc:
|
if not skip_oidc:
|
||||||
migrators.append(GitHubMigrator(gateway_client, self))
|
migrators.append(GitHubMigrator(gateway_client, self, force=force))
|
||||||
migrators.append(OIDCMigrator(gateway_client, self))
|
migrators.append(OIDCMigrator(gateway_client, self, force=force))
|
||||||
|
|
||||||
if not skip_saml:
|
if not skip_saml:
|
||||||
migrators.append(SAMLMigrator(gateway_client, self))
|
migrators.append(SAMLMigrator(gateway_client, self, force=force))
|
||||||
|
|
||||||
if not skip_ad:
|
if not skip_ad:
|
||||||
migrators.append(AzureADMigrator(gateway_client, self))
|
migrators.append(AzureADMigrator(gateway_client, self, force=force))
|
||||||
|
|
||||||
if not skip_ldap:
|
if not skip_ldap:
|
||||||
migrators.append(LDAPMigrator(gateway_client, self))
|
migrators.append(LDAPMigrator(gateway_client, self, force=force))
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
total_results = {
|
total_results = {
|
||||||
'created': 0,
|
'created': 0,
|
||||||
|
'updated': 0,
|
||||||
|
'unchanged': 0,
|
||||||
'failed': 0,
|
'failed': 0,
|
||||||
'mappers_created': 0,
|
'mappers_created': 0,
|
||||||
'mappers_updated': 0,
|
'mappers_updated': 0,
|
||||||
@@ -93,6 +97,8 @@ class Command(BaseCommand):
|
|||||||
# Overall summary
|
# Overall summary
|
||||||
self.stdout.write(self.style.SUCCESS('\n=== Migration 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 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 authenticators failed: {total_results["failed"]}')
|
||||||
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"]}')
|
||||||
@@ -113,6 +119,8 @@ class Command(BaseCommand):
|
|||||||
"""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 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'Authenticators failed: {result.get("failed", 0)}')
|
||||||
self.stdout.write(f'Mappers created: {result.get("mappers_created", 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 updated: {result.get("mappers_updated", 0)}')
|
||||||
|
|||||||
@@ -13,16 +13,32 @@ class BaseAuthenticatorMigrator:
|
|||||||
Defines the contract that all specific authenticator migrators must follow.
|
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.
|
Initialize the authenticator migrator.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
gateway_client: GatewayClient instance for API calls
|
gateway_client: GatewayClient instance for API calls
|
||||||
command: Optional Django management command instance (for styled output)
|
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.gateway_client = gateway_client
|
||||||
self.command = command
|
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):
|
def migrate(self):
|
||||||
"""
|
"""
|
||||||
@@ -36,23 +52,36 @@ class BaseAuthenticatorMigrator:
|
|||||||
|
|
||||||
if not configs:
|
if not configs:
|
||||||
self._write_output(f'No {self.get_authenticator_type()} authenticators found to migrate.', 'warning')
|
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')
|
self._write_output(f'Found {len(configs)} {self.get_authenticator_type()} authentication configuration(s).', 'success')
|
||||||
|
|
||||||
# Process each authenticator configuration
|
# Process each authenticator configuration
|
||||||
created_authenticators = []
|
created_authenticators = []
|
||||||
for config in configs:
|
updated_authenticators = []
|
||||||
if self.create_gateway_authenticator(config):
|
unchanged_authenticators = []
|
||||||
created_authenticators.append(config)
|
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_created = 0
|
||||||
mappers_updated = 0
|
mappers_updated = 0
|
||||||
mappers_failed = 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')
|
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)
|
mapper_result = self._process_gateway_mappers(config)
|
||||||
mappers_created += mapper_result['created']
|
mappers_created += mapper_result['created']
|
||||||
mappers_updated += mapper_result['updated']
|
mappers_updated += mapper_result['updated']
|
||||||
@@ -60,7 +89,9 @@ class BaseAuthenticatorMigrator:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'created': len(created_authenticators),
|
'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_created': mappers_created,
|
||||||
'mappers_updated': mappers_updated,
|
'mappers_updated': mappers_updated,
|
||||||
'mappers_failed': mappers_failed,
|
'mappers_failed': mappers_failed,
|
||||||
@@ -98,7 +129,7 @@ class BaseAuthenticatorMigrator:
|
|||||||
|
|
||||||
def _generate_authenticator_slug(self, auth_type, category):
|
def _generate_authenticator_slug(self, auth_type, category):
|
||||||
"""Generate a deterministic slug for an authenticator."""
|
"""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={}):
|
def submit_authenticator(self, gateway_config, ignore_keys=[], config={}):
|
||||||
"""
|
"""
|
||||||
@@ -110,12 +141,12 @@ class BaseAuthenticatorMigrator:
|
|||||||
config: Optional AWX config dict to store result data
|
config: Optional AWX config dict to store result data
|
||||||
|
|
||||||
Returns:
|
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')
|
authenticator_slug = gateway_config.get('slug')
|
||||||
if not authenticator_slug:
|
if not authenticator_slug:
|
||||||
self._write_output('Gateway config missing slug, cannot submit authenticator', 'error')
|
self._write_output('Gateway config missing slug, cannot submit authenticator', 'error')
|
||||||
return False
|
return {'success': False, 'action': None, 'error': 'Missing slug'}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if authenticator already exists by slug
|
# Check if authenticator already exists by slug
|
||||||
@@ -132,7 +163,7 @@ class BaseAuthenticatorMigrator:
|
|||||||
# Store the existing result for mapper creation
|
# Store the existing result for mapper creation
|
||||||
config['gateway_authenticator_id'] = authenticator_id
|
config['gateway_authenticator_id'] = authenticator_id
|
||||||
config['gateway_authenticator'] = existing_authenticator
|
config['gateway_authenticator'] = existing_authenticator
|
||||||
return True
|
return {'success': True, 'action': 'skipped', 'error': None}
|
||||||
else:
|
else:
|
||||||
self._write_output(f'⚠ Authenticator exists but configuration differs (ID: {authenticator_id})', 'warning')
|
self._write_output(f'⚠ Authenticator exists but configuration differs (ID: {authenticator_id})', 'warning')
|
||||||
self._write_output(' Configuration comparison:')
|
self._write_output(' Configuration comparison:')
|
||||||
@@ -155,12 +186,12 @@ class BaseAuthenticatorMigrator:
|
|||||||
# Store the updated result for mapper creation
|
# Store the updated result for mapper creation
|
||||||
config['gateway_authenticator_id'] = authenticator_id
|
config['gateway_authenticator_id'] = authenticator_id
|
||||||
config['gateway_authenticator'] = result
|
config['gateway_authenticator'] = result
|
||||||
return True
|
return {'success': True, 'action': 'updated', 'error': None}
|
||||||
except GatewayAPIError as e:
|
except GatewayAPIError as e:
|
||||||
self._write_output(f'✗ Failed to update authenticator: {e.message}', 'error')
|
self._write_output(f'✗ Failed to update authenticator: {e.message}', 'error')
|
||||||
if e.response_data:
|
if e.response_data:
|
||||||
self._write_output(f' Details: {e.response_data}', 'error')
|
self._write_output(f' Details: {e.response_data}', 'error')
|
||||||
return False
|
return {'success': False, 'action': 'update_failed', 'error': e.message}
|
||||||
else:
|
else:
|
||||||
# Authenticator doesn't exist, create it
|
# Authenticator doesn't exist, create it
|
||||||
self._write_output('Creating new authenticator...')
|
self._write_output('Creating new authenticator...')
|
||||||
@@ -173,16 +204,16 @@ class BaseAuthenticatorMigrator:
|
|||||||
# Store the result for potential mapper creation later
|
# Store the result for potential mapper creation later
|
||||||
config['gateway_authenticator_id'] = result.get('id')
|
config['gateway_authenticator_id'] = result.get('id')
|
||||||
config['gateway_authenticator'] = result
|
config['gateway_authenticator'] = result
|
||||||
return True
|
return {'success': True, 'action': 'created', 'error': None}
|
||||||
|
|
||||||
except GatewayAPIError as e:
|
except GatewayAPIError as e:
|
||||||
self._write_output(f'✗ Failed to submit authenticator: {e.message}', 'error')
|
self._write_output(f'✗ Failed to submit authenticator: {e.message}', 'error')
|
||||||
if e.response_data:
|
if e.response_data:
|
||||||
self._write_output(f' Details: {e.response_data}', 'error')
|
self._write_output(f' Details: {e.response_data}', 'error')
|
||||||
return False
|
return {'success': False, 'action': 'failed', 'error': e.message}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._write_output(f'✗ Unexpected error submitting authenticator: {str(e)}', 'error')
|
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=[]):
|
def _authenticator_configs_match(self, existing_auth, new_config, ignore_keys=[]):
|
||||||
"""
|
"""
|
||||||
@@ -197,6 +228,11 @@ class BaseAuthenticatorMigrator:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if configurations match, False otherwise
|
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
|
# Keep track of the differences between the existing and the new configuration
|
||||||
# Logging them makes debugging much easier
|
# Logging them makes debugging much easier
|
||||||
@@ -219,7 +255,7 @@ class BaseAuthenticatorMigrator:
|
|||||||
|
|
||||||
# Helper function to check if a key should be ignored
|
# Helper function to check if a key should be ignored
|
||||||
def should_ignore_key(config_key):
|
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
|
# Check if all keys in new config exist in existing config with same values
|
||||||
for key, value in new_config_section.items():
|
for key, value in new_config_section.items():
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ class GitHubMigrator(BaseAuthenticatorMigrator):
|
|||||||
# CALLBACK_URL - automatically created by Gateway
|
# CALLBACK_URL - automatically created by Gateway
|
||||||
# SCOPE - relevant for mappers with team/org requirement, allows to read the org or team
|
# 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
|
# 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)
|
# Submit the authenticator (create or update as needed)
|
||||||
return self.submit_authenticator(gateway_config, ignore_keys, config)
|
return self.submit_authenticator(gateway_config, ignore_keys, config)
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ class LDAPMigrator(BaseAuthenticatorMigrator):
|
|||||||
|
|
||||||
# LDAP authenticators have auto-generated fields that should be ignored during comparison
|
# LDAP authenticators have auto-generated fields that should be ignored during comparison
|
||||||
# BIND_PASSWORD - encrypted value, can't be compared
|
# BIND_PASSWORD - encrypted value, can't be compared
|
||||||
ignore_keys = ['BIND_PASSWORD']
|
ignore_keys = []
|
||||||
|
|
||||||
# Submit the authenticator using the base class method
|
# Submit the authenticator using the base class method
|
||||||
return self.submit_authenticator(gateway_config, config=config, ignore_keys=ignore_keys)
|
return self.submit_authenticator(gateway_config, config=config, ignore_keys=ignore_keys)
|
||||||
|
|||||||
Reference in New Issue
Block a user