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:
Peter Braun 2025-07-14 19:29:21 +02:00 committed by thedoubl3j
parent c5e55fe0f5
commit ab9bde3698
No known key found for this signature in database
GPG Key ID: E84C42ACF75B0768
4 changed files with 70 additions and 26 deletions

View File

@ -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)}')

View File

@ -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():

View File

@ -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)

View File

@ -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)