"""Sync CLI.""" import logging from enum import Enum from typing import Annotated import boto3 import typer from mypy_boto3_sts import STSClient from m2mconfig.connectors.assume_role_secrets_manager import AssumeRoleSecretsManager from m2mconfig.connectors.auth0_connector import ( Auth0ClientResponse, Auth0Connector, get_auth0_mgmt_client_api, get_client_grants_api, get_mgmt_api_token, ) from m2mconfig.connectors.client_credentials_manager import ( ClientCredentialsManager, ClientCredentialsSecretScheduledForDeletion, ) from m2mconfig.constants import PROD_ENVIRONMENT, QA_ENVIRONMENT, UAT_ENVIRONMENT from m2mconfig.error_handlers import handle_uncaught_errors from m2mconfig.registry import validate_config from m2mconfig.schemas import NoAwsAccountException, RegistryEntry logger: logging.Logger = logging.getLogger(__name__) sync_cli: typer.Typer = typer.Typer() class Environment(str, Enum): qa = QA_ENVIRONMENT prod = PROD_ENVIRONMENT uat = UAT_ENVIRONMENT @sync_cli.command("all", short_help="Synchronize all machines in m2m.json.") @handle_uncaught_errors def all( environment: Annotated[ Environment, typer.Argument(help="Pick which environment to synchronize for."), ], path: Annotated[ str, typer.Option(help="Path of m2m.json file"), ] = "./m2m.json", overwrite_secret: Annotated[ bool, typer.Option(help="Overwrite all secrets using latest client credentials"), ] = False, rotate_secret: Annotated[ bool, typer.Option(help="Rotate the client credentials"), ] = False, dry_run: Annotated[ bool, typer.Option(help="Simulate the execution without making changes"), ] = False, ) -> None: """Synchronize all machines in m2m.json.""" registry_entries: list[RegistryEntry] = validate_config(path) mgmt_api_token = get_mgmt_api_token() auth0_connector = Auth0Connector( auth0_mgmt_client_api=get_auth0_mgmt_client_api(mgmt_api_token=mgmt_api_token), client_grants_api=get_client_grants_api(mgmt_api_token=mgmt_api_token), ) secrets_managers: dict[str, AssumeRoleSecretsManager] = {} sts_client = boto3.client("sts") sync = Sync(environment.value, auth0_connector, secrets_managers, sts_client) for entry in registry_entries: try: sync.sync_registry_entry( entry, overwrite_secret=overwrite_secret, rotate_secret=rotate_secret, dry_run=dry_run, ) except NoAwsAccountException: logger.warning( "[%s] No AWS Account configured for %s environment", entry.name, environment.value, ) except ClientCredentialsSecretScheduledForDeletion: logger.info( "[%s] secret is scheduled for deletion, skipping", entry.name, ) @sync_cli.command( "one", short_help="Synchronize one machine, overwriting existing secret." ) @handle_uncaught_errors def one( environment: Annotated[ Environment, typer.Argument(help="Pick which environment to synchronize for."), ], machine_name: Annotated[ str, typer.Argument(help="Name of the machine to synchronize."), ], path: Annotated[ str, typer.Option(help="Path of m2m.json file"), ] = "./m2m.json", overwrite_secret: Annotated[ bool, typer.Option(help="Overwrite secret using latest client credentials"), ] = True, rotate_secret: Annotated[ bool, typer.Option(help="Rotate the client credentials"), ] = False, ) -> None: """Synchronize one machine.""" registry_entries: list[RegistryEntry] = validate_config(path) machine_entry = None for entry in registry_entries: if entry.name == machine_name: machine_entry = entry break if not machine_entry: logger.error( "[%s] Not found in m2m.json registry. Please confirm the Docker image is built/updated", machine_name, ) return mgmt_api_token = get_mgmt_api_token() auth0_connector = Auth0Connector( auth0_mgmt_client_api=get_auth0_mgmt_client_api(mgmt_api_token=mgmt_api_token), client_grants_api=get_client_grants_api(mgmt_api_token=mgmt_api_token), ) secrets_managers: dict[str, AssumeRoleSecretsManager] = {} sts_client = boto3.client("sts") sync = Sync(environment.value, auth0_connector, secrets_managers, sts_client) sync.sync_registry_entry( machine_entry, overwrite_secret=overwrite_secret, rotate_secret=rotate_secret, ) class Sync: """Class used to sync RegistryEntry to Auth0 and AWS Secrets Manager.""" def __init__( self, environment: str, auth0_connector: Auth0Connector, secrets_managers: dict[str, AssumeRoleSecretsManager], sts_client: STSClient, ): """Create Sync class.""" self._environment = environment self._auth0_connector = auth0_connector self._secrets_managers = secrets_managers self._sts_client = sts_client def sync_registry_entry( self, entry: RegistryEntry, overwrite_secret: bool = False, rotate_secret: bool = False, dry_run: bool = False, ) -> None: """Sync exactly one registry entry.""" logger.info("[%s] syncing registry entry", entry.name) if rotate_secret: overwrite_secret = True client_credentials_manager = self._get_client_credentials_manager(entry) if not self._is_client_credentials_manager_syncable( entry, client_credentials_manager, overwrite_secret=overwrite_secret, ): return auth0_client = self._get_or_create_auth0_client( entry, dry_run=dry_run, ) if not auth0_client: logger.warning("[%s] no auth0 client created or found", entry.name) return if rotate_secret: auth0_client = self._rotate_client_secret(entry, dry_run=dry_run) if not auth0_client: logger.warning( "[%s] no auth0 client returned with secret rotation call", entry.name ) return logger.info( "[%s] setting auth0 client credentials in SecretsManager", entry.name ) if not dry_run: client_credentials_manager.set( auth0_client.client_id, auth0_client.client_secret, ) logger.info( "[%s] auth0 client credentials set in SecretsManager", entry.name ) def _get_client_credentials_manager( self, entry: RegistryEntry, ) -> ClientCredentialsManager: """Helper to get the ClientCredentialsManager.""" aws_account_id = entry.get_aws_account(self._environment) logger.info("[%s] using AWS Account Id %s", entry.name, aws_account_id) if aws_account_id not in self._secrets_managers: logger.info("[%s] adding an AssumeRoleSecretsManager", entry.name) self._secrets_managers[aws_account_id] = AssumeRoleSecretsManager( self._environment, self._sts_client, aws_account_id, "assume-auth0-m2m-config", ) return ClientCredentialsManager( environment=self._environment, machine_name=entry.name, secrets_manager=self._secrets_managers[aws_account_id], ) def _is_client_credentials_manager_syncable( self, entry: RegistryEntry, client_credentials_manager: ClientCredentialsManager, overwrite_secret: bool = False, ) -> bool: """Helper to determine if the secret is suitable for sync-ing.""" if not client_credentials_manager.exists(): logger.error( "[%s] secret location does not exist, create it and re-run.", entry.name, ) return False if client_credentials_manager.is_valid() and not overwrite_secret: logger.info( "[%s] secret is valid and overwrite_secret was not specified.", entry.name, ) return False return True def _get_or_create_auth0_client( self, entry: RegistryEntry, dry_run: bool = True, ) -> Auth0ClientResponse | None: """Helper to get or create auth0 client.""" auth0_client = self._auth0_connector.get_app_by_name(entry) if not auth0_client: logger.info("[%s] creating auth0 client", entry.name) if not dry_run: auth0_client = self._auth0_connector.create_client(entry) logger.info("[%s] auth0 client created", entry.name) else: logger.info("[%s] auth0 client already exists", entry.name) if auth0_client and not dry_run: # Create the grant when we're not running in dry-run mode. self._auth0_connector.create_client_grant(client_id=auth0_client.client_id) # Update the client metadata self._auth0_connector.update_client_metadata( auth0_client.client_id, entry, ) return auth0_client def _rotate_client_secret( self, entry: RegistryEntry, dry_run: bool = True, ) -> Auth0ClientResponse | None: """Helper to rotate the auth0 client secret.""" auth0_client = self._auth0_connector.get_app_by_name(entry) if not auth0_client: logger.warning( "[%s] auth0 client does not exist, nothing to rotate", entry.name ) if auth0_client and not dry_run: logger.info("[%s] rotating secret for auth0 client", entry.name) # Rotate the client secret when we're not running in dry-run mode. auth0_client = self._auth0_connector.rotate_client_secret( client_id=auth0_client.client_id ) logger.info("[%s] secret is rotated", entry.name) return auth0_client