From ed07c9912cd90538db9074709004ac0b07831e48 Mon Sep 17 00:00:00 2001 From: "Tony Chen (DevDiv)" Date: Thu, 13 Feb 2025 17:22:35 +0800 Subject: [PATCH 01/14] First changes --- src/serviceconnector-passwordless/HISTORY.rst | 4 + .../_client_factory.py | 2 +- .../_credential_free.py | 111 ++--------------- .../_params.py | 2 + .../_resource_config.py | 11 +- .../commands.py | 6 + .../custom.py | 2 + .../handlers/fabric_sql_handler.py | 116 ++++++++++++++++++ .../handlers/target_handler.py | 99 +++++++++++++++ ..._serviceconnector-passwordless_scenario.py | 35 ++++++ 10 files changed, 286 insertions(+), 102 deletions(-) create mode 100644 src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py create mode 100644 src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/target_handler.py diff --git a/src/serviceconnector-passwordless/HISTORY.rst b/src/serviceconnector-passwordless/HISTORY.rst index c108ca39d60..8335a1448d9 100644 --- a/src/serviceconnector-passwordless/HISTORY.rst +++ b/src/serviceconnector-passwordless/HISTORY.rst @@ -2,6 +2,10 @@ Release History =============== +3.1.4 +++++++ +* Introduce support for Fabric SQL as a target service + 3.1.3 ++++++ * Fix argument missing diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_client_factory.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_client_factory.py index c8331e9b7c2..28505175277 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_client_factory.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_client_factory.py @@ -13,7 +13,7 @@ def cf_connection_cl(cli_ctx, *_): os.environ['AZURE_HTTP_USER_AGENT'] = (os.environ.get('AZURE_HTTP_USER_AGENT') or '') + " CliExtension/{}({})".format(NAME, VERSION) return get_mgmt_service_client(cli_ctx, ServiceLinkerManagementClient, - subscription_bound=False, api_version="2023-04-01-preview") + subscription_bound=False, api_version="2024-07-01-preview") def cf_linker(cli_ctx, *_): diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py index 11f61fa5ee0..97e0c3d27d6 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py @@ -7,6 +7,11 @@ import struct import sys import re +from .handlers.target_handler import ( + AUTHTYPES, + TargetHandler +) +from .handlers.fabric_sql_handler import FabricSqlHandler from knack.log import get_logger from azure.mgmt.core.tools import parse_resource_id from azure.cli.core import telemetry @@ -34,19 +39,11 @@ from ._utils import run_cli_cmd, get_local_ip, confirm_all_ip_allow, confirm_admin_set, confirm_enable_entra_auth logger = get_logger(__name__) -AUTHTYPES = { - AUTH_TYPE.SystemIdentity: 'systemAssignedIdentity', - AUTH_TYPE.UserIdentity: 'userAssignedIdentity', - AUTH_TYPE.ServicePrincipalSecret: 'servicePrincipalSecret', - AUTH_TYPE.UserAccount: 'userAccount', -} - - # pylint: disable=line-too-long, consider-using-f-string, too-many-statements, unused-argument # For db(mysqlFlex/psql/psqlFlex/sql) linker with auth type=systemAssignedIdentity, enable Microsoft Entra auth and create db user on data plane # For other linker, ignore the steps def get_enable_mi_for_db_linker_func(yes=False, new=False): - def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, connection_name, *args, **kwargs): + def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, connection_name, connstr_props=None, *args, **kwargs): # return if connection is not for db mi if auth_info['auth_type'] not in [AUTHTYPES[AUTH_TYPE.SystemIdentity], AUTHTYPES[AUTH_TYPE.UserIdentity], @@ -61,7 +58,7 @@ def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, c if source_handler is None: return None target_handler = getTargetHandler( - cmd, target_id, target_type, auth_info, client_type, connection_name, skip_prompt=yes, new_user=new) + cmd, target_id, target_type, auth_info, client_type, connection_name, connstr_props, skip_prompt=yes, new_user=new) if target_handler is None: return None target_handler.check_db_existence() @@ -88,7 +85,7 @@ def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, c source_object_id = source_handler.get_identity_pid() target_handler.identity_object_id = source_object_id try: - if target_type in [RESOURCE.Sql]: + if target_type in [RESOURCE.Sql, RESOURCE.FabricSql]: target_handler.identity_name = source_handler.get_identity_name() elif target_type in [RESOURCE.Postgres, RESOURCE.MysqlFlexible]: identity_info = run_cli_cmd( @@ -149,7 +146,7 @@ def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, c # pylint: disable=unused-argument, too-many-instance-attributes -def getTargetHandler(cmd, target_id, target_type, auth_info, client_type, connection_name, skip_prompt, new_user): +def getTargetHandler(cmd, target_id, target_type, auth_info, client_type, connection_name, connstr_props, skip_prompt, new_user): if target_type in {RESOURCE.Sql}: return SqlHandler(cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user) if target_type in {RESOURCE.Postgres}: @@ -158,97 +155,11 @@ def getTargetHandler(cmd, target_id, target_type, auth_info, client_type, connec return PostgresFlexHandler(cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user) if target_type in {RESOURCE.MysqlFlexible}: return MysqlFlexibleHandler(cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user) + if target_type in {RESOURCE.FabricSql}: + return FabricSqlHandler(cmd, target_id, target_type, auth_info, connection_name, connstr_props, skip_prompt, new_user) return None -class TargetHandler: - - def __init__(self, cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user): - self.cmd = cmd - self.target_id = target_id - self.target_type = target_type - self.tenant_id = Profile( - cli_ctx=cmd.cli_ctx).get_subscription().get("tenantId") - target_segments = parse_resource_id(target_id) - self.subscription = target_segments.get('subscription') - self.resource_group = target_segments.get('resource_group') - self.auth_type = auth_info['auth_type'] - self.auth_info = auth_info - self.login_username = run_cli_cmd( - 'az account show').get("user").get("name") - self.login_usertype = run_cli_cmd( - 'az account show').get("user").get("type") # servicePrincipal, user - if (self.login_usertype not in ['servicePrincipal', 'user']): - e = CLIInternalError( - f'{self.login_usertype} is not supported. Please login as user or servicePrincipal') - telemetry.set_exception(e, "Unsupported-UserType-" + self.login_usertype) - raise e - self.aad_username = "aad_" + connection_name - self.connection_name = connection_name - self.skip_prompt = skip_prompt - self.new_user = new_user - self.endpoint = "" - self.user_object_id = "" - self.identity_name = "" - self.identity_client_id = "" - self.identity_object_id = "" - - def enable_target_aad_auth(self): - return - - def set_user_admin(self, user_object_id, **kwargs): - return - - def set_target_firewall(self, is_add, ip_name, start_ip=None, end_ip=None): - return - - def create_aad_user(self): - return - - def check_db_existence(self): - return - - def get_auth_flag(self): - if self.auth_type == AUTHTYPES[AUTH_TYPE.UserAccount]: - return '--user-account' - if self.auth_type == AUTHTYPES[AUTH_TYPE.SystemIdentity]: - return '--system-identity' - if self.auth_type == AUTHTYPES[AUTH_TYPE.UserIdentity]: - return '--user-identity' - if self.auth_type == AUTHTYPES[AUTH_TYPE.ServicePrincipalSecret]: - return '--service-principal' - return None - - def get_auth_config(self, user_object_id): - if self.auth_type == AUTHTYPES[AUTH_TYPE.UserAccount]: - return { - 'auth_type': self.auth_type, - 'username': self.aad_username, - 'principal_id': user_object_id - } - if self.auth_type == AUTHTYPES[AUTH_TYPE.SystemIdentity]: - return { - 'auth_type': self.auth_type, - 'username': self.aad_username, - } - if self.auth_type == AUTHTYPES[AUTH_TYPE.UserIdentity]: - return { - 'auth_type': self.auth_type, - 'username': self.aad_username, - 'client_id': self.identity_client_id, - 'subscription_id': self.auth_info['subscription_id'], - } - if self.auth_type == AUTHTYPES[AUTH_TYPE.ServicePrincipalSecret]: - return { - 'auth_type': self.auth_type, - 'username': self.aad_username, - 'principal_id': self.identity_object_id, - 'client_id': self.identity_client_id, - 'secret': self.auth_info['secret'], - } - return None - - class MysqlFlexibleHandler(TargetHandler): def __init__(self, cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user): diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_params.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_params.py index 74b60d1be2c..5f022c7ed2b 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_params.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_params.py @@ -16,6 +16,7 @@ add_secret_store_argument, add_local_connection_block, add_customized_keys_argument, + add_connstr_props_argument, add_configuration_store_argument, add_opt_out_argument ) @@ -89,6 +90,7 @@ def load_arguments(self, _): add_vnet_block(c, target) add_connection_string_argument(c, source, target) add_customized_keys_argument(c) + add_connstr_props_argument(c) add_opt_out_argument(c) c.argument('yes', arg_type=yes_arg_type) c.argument('new', arg_type=new_arg_type) diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_resource_config.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_resource_config.py index 4d77d342ef6..0e6d90bbad8 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_resource_config.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_resource_config.py @@ -34,7 +34,8 @@ # RESOURCE.Postgres, RESOURCE.PostgresFlexible, RESOURCE.MysqlFlexible, - RESOURCE.Sql + RESOURCE.Sql, + RESOURCE.FabricSql ] # pylint: disable=line-too-long @@ -58,6 +59,7 @@ RESOURCE.PostgresFlexible: [AUTH_TYPE.Secret, AUTH_TYPE.SystemIdentity, AUTH_TYPE.UserIdentity, AUTH_TYPE.ServicePrincipalSecret], RESOURCE.MysqlFlexible: [AUTH_TYPE.Secret, AUTH_TYPE.SystemIdentity, AUTH_TYPE.UserIdentity, AUTH_TYPE.ServicePrincipalSecret], RESOURCE.Sql: [AUTH_TYPE.Secret, AUTH_TYPE.SystemIdentity, AUTH_TYPE.UserIdentity, AUTH_TYPE.ServicePrincipalSecret], + RESOURCE.FabricSql: [AUTH_TYPE.SystemIdentity, AUTH_TYPE.UserIdentity], } TARGET_RESOURCES_PARAMS = { @@ -130,6 +132,13 @@ 'placeholder': 'MyDB' } }, + RESOURCE.FabricSql: { + 'connstr_props': { + 'options': ['--connstr-props'], + 'help': 'Connection string properties of the Fabric SQL server. Format like: --connstr-props "Server=," "Database=".', + 'placeholder': 'Server=MyServer,1433 Database=MyDB' + } + } } AUTH_TYPE_PARAMS = { diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/commands.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/commands.py index 84f1003d750..2eeccb135ff 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/commands.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/commands.py @@ -7,6 +7,7 @@ from azure.cli.core.commands import CliCommandType from azure.cli.command_modules.serviceconnector._resource_config import ( + RESOURCE, SOURCE_RESOURCES, TARGET_RESOURCES_DEPRECATED ) @@ -31,6 +32,9 @@ def load_command_table(self, _): client_factory=cf_connector) for target in PASSWORDLESS_TARGET_RESOURCES: + # FabricSql is not supported for Local Connector + if target == RESOURCE.FabricSql: + continue with self.command_group('connection create', local_connection_type, client_factory=cf_connector) as ig: if target in TARGET_RESOURCES_DEPRECATED: @@ -45,6 +49,8 @@ def load_command_table(self, _): # only when the extension is installed if should_load_source(source): for target in PASSWORDLESS_TARGET_RESOURCES: + if source == RESOURCE.KubernetesCluster and target == RESOURCE.FabricSql: + continue with self.command_group(f'{source.value} connection create', connection_type, client_factory=cf_linker) as ig: if target in TARGET_RESOURCES_DEPRECATED: diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/custom.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/custom.py index 5b74f5b587d..c3bbc8fe85b 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/custom.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/custom.py @@ -28,6 +28,7 @@ def connection_create_ext(cmd, client, spring=None, app=None, deployment='default', # Resource.SpringCloud # Resource.*Postgres, Resource.*Sql* server=None, database=None, + connstr_props=None, **kwargs, ): from azure.cli.command_modules.serviceconnector.custom import connection_create_func @@ -52,6 +53,7 @@ def connection_create_ext(cmd, client, customized_keys=customized_keys, opt_out_list=opt_out_list, app_config_id=app_config_id, + connstr_props=connstr_props, **kwargs) diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py new file mode 100644 index 00000000000..4f136d3be7f --- /dev/null +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py @@ -0,0 +1,116 @@ +import struct +import sys +import re + +from .._utils import is_packaged_installed +from .target_handler import AUTHTYPES, TargetHandler +from knack.log import get_logger +from azure.cli.core import telemetry +from azure.cli.core.azclierror import ( + AzureConnectionError, + CLIInternalError +) +from azure.cli.core.extension.operations import _run_pip +from azure.cli.command_modules.serviceconnector._resource_config import AUTH_TYPE +logger = get_logger(__name__) + +class FabricSqlHandler(TargetHandler): + def __init__(self, cmd, target_id, target_type, auth_info, connection_name, connstr_props, skip_prompt, new_user): + super().__init__(cmd, target_id, target_type, + auth_info, connection_name, skip_prompt, new_user) + + self.target_id = target_id + + if not connstr_props: + raise CLIInternalError("Missing additional connection string properties for Fabric SQL target.") + + Server = connstr_props.get('Server') or connstr_props.get('Data Source') + Database = connstr_props.get('Database') or connstr_props.get('Initial Catalog') + if not Server or not Database: + raise CLIInternalError("Missing 'Server' or 'Database' in additonal connection string properties keys." + "Use --connection-string-props 'Server=xxx' 'Database=xxx' to provide the values.") + + # Construct the ODBC connection string + self.ODBCConnectionString = self.construct_odbc_connection_string(Server, Database) + logger.warning("ODBC connection string: %s", self.ODBCConnectionString) + + def construct_odbc_connection_string(self, server, database): + # Map fields to ODBC fields + odbc_dict = { + 'Driver': '{driver}', + 'Server': server, + 'Database': database, + } + + odbc_connection_string = ';'.join([f'{key}={value}' for key, value in odbc_dict.items()]) + return odbc_connection_string + + def create_aad_user(self): + query_list = self.get_create_query() + connection_args = self.get_connection_string() + + logger.warning("Connecting to database...") + self.create_aad_user_in_sql(connection_args, query_list) + + def create_aad_user_in_sql(self, connection_args, query_list): + if not self.new_user: + query_list = query_list[1:] + if not is_packaged_installed('pyodbc'): + _run_pip(["install", "pyodbc"]) + + # pylint: disable=import-error, c-extension-no-member + try: + import pyodbc + except ModuleNotFoundError as e: + raise ModuleNotFoundError( + "Dependency pyodbc can't be installed, please install it manually with `" + sys.executable + " -m pip install pyodbc`.") from e + drivers = [x for x in pyodbc.drivers() if x in [ + 'ODBC Driver 17 for SQL Server', 'ODBC Driver 18 for SQL Server']] + if not drivers: + ex = CLIInternalError( + "Please manually install odbc 17/18 for SQL server, reference: https://docs.microsoft.com/en-us/sql/connect/odbc/download-odbc-driver-for-sql-server/") + telemetry.set_exception(ex, "No-ODBC-Driver") + raise ex + try: + with pyodbc.connect(connection_args.get("connection_string").format(driver=drivers[0]), attrs_before=connection_args.get("attrs_before")) as conn: + with conn.cursor() as cursor: + logger.warning( + "Adding new Microsoft Entra user %s to database...", self.aad_username) + for execution_query in query_list: + try: + logger.warning("Running query: %s", execution_query) + cursor.execute(execution_query) + except pyodbc.ProgrammingError as e: + logger.warning("Query execution failed: %s", str(e)) + conn.commit() + except pyodbc.Error as e: + search_ip = re.search( + "Client with IP address '(.*?)' is not allowed to access the server", str(e)) + if search_ip is not None: + self.ip = search_ip.group(1) + raise AzureConnectionError("Fail to connect sql." + str(e)) from e + + def get_connection_string(self, dbname=""): + token_bytes = run_cli_cmd( + 'az account get-access-token --output json --resource https://api.fabric.microsoft.com/').get('accessToken').encode('utf-16-le') + + token_struct = struct.pack( + f' Date: Thu, 13 Feb 2025 17:25:33 +0800 Subject: [PATCH 02/14] Fix import --- .../handlers/fabric_sql_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py index 4f136d3be7f..0ff6254d4ac 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py @@ -2,8 +2,8 @@ import sys import re -from .._utils import is_packaged_installed -from .target_handler import AUTHTYPES, TargetHandler +from azure.cli.command_modules.serviceconnector._utils import is_packaged_installed +from .target_handler import AUTHTYPES, TargetHandler, run_cli_cmd from knack.log import get_logger from azure.cli.core import telemetry from azure.cli.core.azclierror import ( From 4d7482ec3fb4107866c84aec7bd23b0be518c1b5 Mon Sep 17 00:00:00 2001 From: "Tony Chen (DevDiv)" Date: Fri, 14 Feb 2025 13:55:42 +0800 Subject: [PATCH 03/14] Add inheritance --- .../handlers/fabric_sql_handler.py | 50 ++----------------- 1 file changed, 4 insertions(+), 46 deletions(-) diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py index 0ff6254d4ac..e8a610d41d3 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py @@ -2,19 +2,15 @@ import sys import re +from .._credential_free import SqlHandler from azure.cli.command_modules.serviceconnector._utils import is_packaged_installed -from .target_handler import AUTHTYPES, TargetHandler, run_cli_cmd +from .target_handler import AUTHTYPES, run_cli_cmd from knack.log import get_logger -from azure.cli.core import telemetry -from azure.cli.core.azclierror import ( - AzureConnectionError, - CLIInternalError -) -from azure.cli.core.extension.operations import _run_pip +from azure.cli.core.azclierror import CLIInternalError from azure.cli.command_modules.serviceconnector._resource_config import AUTH_TYPE logger = get_logger(__name__) -class FabricSqlHandler(TargetHandler): +class FabricSqlHandler(SqlHandler): def __init__(self, cmd, target_id, target_type, auth_info, connection_name, connstr_props, skip_prompt, new_user): super().__init__(cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user) @@ -52,44 +48,6 @@ def create_aad_user(self): logger.warning("Connecting to database...") self.create_aad_user_in_sql(connection_args, query_list) - def create_aad_user_in_sql(self, connection_args, query_list): - if not self.new_user: - query_list = query_list[1:] - if not is_packaged_installed('pyodbc'): - _run_pip(["install", "pyodbc"]) - - # pylint: disable=import-error, c-extension-no-member - try: - import pyodbc - except ModuleNotFoundError as e: - raise ModuleNotFoundError( - "Dependency pyodbc can't be installed, please install it manually with `" + sys.executable + " -m pip install pyodbc`.") from e - drivers = [x for x in pyodbc.drivers() if x in [ - 'ODBC Driver 17 for SQL Server', 'ODBC Driver 18 for SQL Server']] - if not drivers: - ex = CLIInternalError( - "Please manually install odbc 17/18 for SQL server, reference: https://docs.microsoft.com/en-us/sql/connect/odbc/download-odbc-driver-for-sql-server/") - telemetry.set_exception(ex, "No-ODBC-Driver") - raise ex - try: - with pyodbc.connect(connection_args.get("connection_string").format(driver=drivers[0]), attrs_before=connection_args.get("attrs_before")) as conn: - with conn.cursor() as cursor: - logger.warning( - "Adding new Microsoft Entra user %s to database...", self.aad_username) - for execution_query in query_list: - try: - logger.warning("Running query: %s", execution_query) - cursor.execute(execution_query) - except pyodbc.ProgrammingError as e: - logger.warning("Query execution failed: %s", str(e)) - conn.commit() - except pyodbc.Error as e: - search_ip = re.search( - "Client with IP address '(.*?)' is not allowed to access the server", str(e)) - if search_ip is not None: - self.ip = search_ip.group(1) - raise AzureConnectionError("Fail to connect sql." + str(e)) from e - def get_connection_string(self, dbname=""): token_bytes = run_cli_cmd( 'az account get-access-token --output json --resource https://api.fabric.microsoft.com/').get('accessToken').encode('utf-16-le') From 4a9cdf101e8559929e69e9bb6c71a20b6c4c4ed1 Mon Sep 17 00:00:00 2001 From: "Tony Chen (DevDiv)" Date: Mon, 17 Feb 2025 11:21:28 +0800 Subject: [PATCH 04/14] Set up some proper inheritance --- .../_credential_free.py | 195 +--------------- .../handlers/fabric_sql_handler.py | 32 ++- .../handlers/sql_handler.py | 213 ++++++++++++++++++ 3 files changed, 240 insertions(+), 200 deletions(-) create mode 100644 src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/sql_handler.py diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py index 97e0c3d27d6..e4e0222e135 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py @@ -4,16 +4,16 @@ # -------------------------------------------------------------------------------------------- # pylint: disable=no-member, too-many-lines, anomalous-backslash-in-string, redefined-outer-name, no-else-raise, attribute-defined-outside-init,too-many-positional-arguments -import struct import sys import re from .handlers.target_handler import ( AUTHTYPES, TargetHandler ) +from azure.mgmt.core.tools import parse_resource_id +from .handlers.sql_handler import SqlHandler from .handlers.fabric_sql_handler import FabricSqlHandler from knack.log import get_logger -from azure.mgmt.core.tools import parse_resource_id from azure.cli.core import telemetry from azure.cli.core.azclierror import ( AzureConnectionError, @@ -356,197 +356,6 @@ def get_create_query(self): ] -class SqlHandler(TargetHandler): - - def __init__(self, cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user): - super().__init__(cmd, target_id, target_type, - auth_info, connection_name, skip_prompt, new_user) - self.endpoint = cmd.cli_ctx.cloud.suffixes.sql_server_hostname - target_segments = parse_resource_id(target_id) - self.server = target_segments.get('name') - self.dbname = target_segments.get('child_name_1') - self.ip = "" - - def check_db_existence(self): - try: - db_info = run_cli_cmd( - 'az sql db show --ids "{}"'.format(self.target_id)) - if db_info is None: - e = ResourceNotFoundError( - "No database found with name {}".format(self.dbname)) - telemetry.set_exception(e, "No-Db") - raise e - except CLIInternalError as e: - telemetry.set_exception(e, "No-Db") - raise e - - def set_user_admin(self, user_object_id, **kwargs): - # pylint: disable=not-an-iterable - admins = run_cli_cmd( - 'az sql server ad-admin list --ids "{}"'.format(self.target_id)) - if not user_object_id: - if not admins: - e = ValidationError( - 'No Microsoft Entra admin found. Please set current user as Microsoft Entra admin and try again.') - telemetry.set_exception(e, "Missing-Aad-Admin") - raise e - else: - logger.warning( - 'Unable to check if current user is Microsoft Entra admin. Please confirm current user as Microsoft Entra admin manually.') - return - admin_info = next((ad for ad in admins if ad.get('sid') == user_object_id), None) - if not admin_info: - set_admin = True - if not self.skip_prompt: - set_admin = confirm_admin_set() - if set_admin: - logger.warning('Setting current user as database server Microsoft Entra admin:' - ' user=%s object id=%s', self.login_username, user_object_id) - admin_info = run_cli_cmd('az sql server ad-admin create -g "{}" --server-name "{}" --display-name "{}" --object-id "{}" --subscription "{}"'.format( - self.resource_group, self.server, self.login_username, user_object_id, self.subscription)) - self.admin_username = admin_info.get('login', self.login_username) if admin_info else self.login_username - - def create_aad_user(self): - - query_list = self.get_create_query() - connection_args = self.get_connection_string() - ip_name = generate_random_string(prefix='svc_').lower() - try: - logger.warning("Connecting to database...") - self.create_aad_user_in_sql(connection_args, query_list) - except AzureConnectionError as e: - from azure.cli.core.util import in_cloud_console - if in_cloud_console(): - self.set_target_firewall( - True, ip_name, '0.0.0.0', '0.0.0.0') - else: - if not self.ip: - error_code = '' - error_res = re.search( - r'\((\d{5})\)', str(e)) - if error_res: - error_code = error_res.group(1) - telemetry.set_exception(e, "Connect-Db-Fail-" + error_code) - raise e - logger.warning(e) - # allow local access - ip_address = self.ip - self.set_target_firewall(True, ip_name, ip_address, ip_address) - try: - # create again - self.create_aad_user_in_sql(connection_args, query_list) - except AzureConnectionError as e: - logger.warning(e) - ex = AzureConnectionError( - "Please confirm local environment can connect to database and try again.") - error_code = '' - error_res = re.search( - r'\((\d{5})\)', str(e)) - if error_res: - error_code = error_res.group(1) - telemetry.set_exception(e, "Connect-Db-Fail-" + error_code) - raise ex from e - finally: - self.set_target_firewall(False, ip_name) - - def set_target_firewall(self, is_add, ip_name, start_ip=None, end_ip=None): - if is_add: - target = run_cli_cmd( - 'az sql server show --ids "{}"'.format(self.target_id)) - # logger.warning("Update database server firewall rule to connect...") - if target.get('publicNetworkAccess') == "Disabled": - ex = AzureConnectionError( - "The target resource doesn't allow public access. Please enable it manually and try again.") - telemetry.set_exception(ex, "Public-Access-Disabled") - raise ex - logger.warning("Add firewall rule %s %s - %s...%s", ip_name, start_ip, end_ip, - ('(it will be removed after connection is created)' if self.auth_type != AUTHTYPES[ - AUTH_TYPE.UserAccount] else '(Please delete it manually if it has security risk.)')) - run_cli_cmd( - 'az sql server firewall-rule create -g "{0}" -s "{1}" -n "{2}" ' - '--subscription "{3}" --start-ip-address {4} --end-ip-address {5}'.format( - self.resource_group, self.server, ip_name, self.subscription, start_ip, end_ip) - ) - else: - if self.auth_type == AUTHTYPES[AUTH_TYPE.UserAccount]: - return - logger.warning( - "Remove database server firewall rule %s to recover...", ip_name) - try: - run_cli_cmd( - 'az sql server firewall-rule delete -g "{0}" -s "{1}" -n "{2}" --subscription "{3}"'.format( - self.resource_group, self.server, ip_name, self.subscription) - ) - except CLIInternalError as e: - logger.warning( - "Can't remove firewall rule %s. Please manually delete it to avoid security issue. %s", ip_name, str(e)) - - def create_aad_user_in_sql(self, connection_args, query_list): - if not self.new_user: - query_list = query_list[1:] - if not is_packaged_installed('pyodbc'): - _run_pip(["install", "pyodbc"]) - - # pylint: disable=import-error, c-extension-no-member - try: - import pyodbc - except ModuleNotFoundError as e: - raise ModuleNotFoundError( - "Dependency pyodbc can't be installed, please install it manually with `" + sys.executable + " -m pip install pyodbc`.") from e - drivers = [x for x in pyodbc.drivers() if x in [ - 'ODBC Driver 17 for SQL Server', 'ODBC Driver 18 for SQL Server']] - if not drivers: - ex = CLIInternalError( - "Please manually install odbc 17/18 for SQL server, reference: https://docs.microsoft.com/en-us/sql/connect/odbc/download-odbc-driver-for-sql-server/") - telemetry.set_exception(ex, "No-ODBC-Driver") - raise ex - try: - with pyodbc.connect(connection_args.get("connection_string").format(driver=drivers[0]), attrs_before=connection_args.get("attrs_before")) as conn: - with conn.cursor() as cursor: - logger.warning( - "Adding new Microsoft Entra user %s to database...", self.aad_username) - for execution_query in query_list: - try: - logger.warning("Running query: %s", execution_query) - cursor.execute(execution_query) - except pyodbc.ProgrammingError as e: - logger.warning("Query execution failed: %s", str(e)) - conn.commit() - except pyodbc.Error as e: - search_ip = re.search( - "Client with IP address '(.*?)' is not allowed to access the server", str(e)) - if search_ip is not None: - self.ip = search_ip.group(1) - raise AzureConnectionError("Fail to connect sql." + str(e)) from e - - def get_connection_string(self, dbname=""): - token_bytes = run_cli_cmd( - 'az account get-access-token --output json --resource https://database.windows.net/').get('accessToken').encode('utf-16-le') - - token_struct = struct.pack( - f' Date: Mon, 17 Feb 2025 14:27:16 +0800 Subject: [PATCH 05/14] Fix style guide --- .../_credential_free.py | 12 ++++++------ .../_resource_config.py | 2 +- .../handlers/fabric_sql_handler.py | 9 +++++---- .../handlers/sql_handler.py | 2 +- .../handlers/target_handler.py | 3 ++- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py index e4e0222e135..7146eb5b1f4 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py @@ -11,9 +11,6 @@ TargetHandler ) from azure.mgmt.core.tools import parse_resource_id -from .handlers.sql_handler import SqlHandler -from .handlers.fabric_sql_handler import FabricSqlHandler -from knack.log import get_logger from azure.cli.core import telemetry from azure.cli.core.azclierror import ( AzureConnectionError, @@ -22,7 +19,6 @@ ResourceNotFoundError ) from azure.cli.core.extension.operations import _install_deps_for_psycopg2, _run_pip -from azure.cli.core._profile import Profile from azure.cli.command_modules.serviceconnector._utils import ( generate_random_string, is_packaged_installed, @@ -36,14 +32,18 @@ get_source_resource_name, get_target_resource_name, ) -from ._utils import run_cli_cmd, get_local_ip, confirm_all_ip_allow, confirm_admin_set, confirm_enable_entra_auth +from .handlers.sql_handler import SqlHandler +from .handlers.fabric_sql_handler import FabricSqlHandler +from knack.log import get_logger +from ._utils import run_cli_cmd, get_local_ip, confirm_all_ip_allow, confirm_enable_entra_auth logger = get_logger(__name__) + # pylint: disable=line-too-long, consider-using-f-string, too-many-statements, unused-argument # For db(mysqlFlex/psql/psqlFlex/sql) linker with auth type=systemAssignedIdentity, enable Microsoft Entra auth and create db user on data plane # For other linker, ignore the steps def get_enable_mi_for_db_linker_func(yes=False, new=False): - def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, connection_name, connstr_props=None, *args, **kwargs): + def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, connection_name, *args, connstr_props=None, **kwargs): # return if connection is not for db mi if auth_info['auth_type'] not in [AUTHTYPES[AUTH_TYPE.SystemIdentity], AUTHTYPES[AUTH_TYPE.UserIdentity], diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_resource_config.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_resource_config.py index 0e6d90bbad8..eabb8a16f19 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_resource_config.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_resource_config.py @@ -138,7 +138,7 @@ 'help': 'Connection string properties of the Fabric SQL server. Format like: --connstr-props "Server=," "Database=".', 'placeholder': 'Server=MyServer,1433 Database=MyDB' } - } + } } AUTH_TYPE_PARAMS = { diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py index 6c166bc9f64..03a37770ede 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py @@ -9,6 +9,7 @@ logger = get_logger(__name__) + class FabricSqlHandler(SqlHandler): def __init__(self, cmd, target_id, target_type, auth_info, connection_name, connstr_props, skip_prompt, new_user): super().__init__(cmd, target_id, target_type, @@ -38,11 +39,11 @@ def check_db_existence(self): response_json = response.json() if response_json["id"]: return - + e = ResourceNotFoundError("No database found with name {}".format(self.dbname)) telemetry.set_exception(e, "No-Db") raise e - + def construct_odbc_connection_string(self, server, database): # Map fields to ODBC fields odbc_dict = { @@ -66,7 +67,7 @@ def get_fabric_access_token(self): def set_user_admin(self, user_object_id, **kwargs): return - + def get_connection_string(self, dbname=""): token_bytes = self.get_fabric_access_token().encode('utf-16-le') @@ -89,4 +90,4 @@ def get_create_query(self): grant_q2 = "ALTER ROLE db_datawriter ADD MEMBER \"{}\"".format(self.aad_username) grant_q3 = "ALTER ROLE db_ddladmin ADD MEMBER \"{}\"".format(self.aad_username) - return [delete_q, role_q, grant_q1, grant_q2, grant_q3] \ No newline at end of file + return [delete_q, role_q, grant_q1, grant_q2, grant_q3] diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/sql_handler.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/sql_handler.py index eb3f48fcc0e..ca9a79c9623 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/sql_handler.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/sql_handler.py @@ -23,6 +23,7 @@ from .._utils import run_cli_cmd, confirm_admin_set logger = get_logger(__name__) + class SqlHandler(TargetHandler): def __init__(self, cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user): super().__init__(cmd, target_id, target_type, @@ -210,4 +211,3 @@ def get_create_query(self): self.dbname, self.aad_username) return [delete_q, role_q, grant_q] - \ No newline at end of file diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/target_handler.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/target_handler.py index fc4ed06a45c..b3c45d53437 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/target_handler.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/target_handler.py @@ -12,6 +12,7 @@ AUTH_TYPE.UserAccount: 'userAccount', } + class TargetHandler: def __init__(self, cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user): self.cmd = cmd @@ -96,4 +97,4 @@ def get_auth_config(self, user_object_id): 'client_id': self.identity_client_id, 'secret': self.auth_info['secret'], } - return None \ No newline at end of file + return None From c3966fddad316f8e64d8d1a19ece12e46618ad8c Mon Sep 17 00:00:00 2001 From: "Tony Chen (DevDiv)" Date: Mon, 17 Feb 2025 16:18:54 +0800 Subject: [PATCH 06/14] Fix args order --- .../azext_serviceconnector_passwordless/_credential_free.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py index 7146eb5b1f4..e1907554d03 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py @@ -43,7 +43,7 @@ # For db(mysqlFlex/psql/psqlFlex/sql) linker with auth type=systemAssignedIdentity, enable Microsoft Entra auth and create db user on data plane # For other linker, ignore the steps def get_enable_mi_for_db_linker_func(yes=False, new=False): - def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, connection_name, *args, connstr_props=None, **kwargs): + def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, connection_name, connstr_props=None): # return if connection is not for db mi if auth_info['auth_type'] not in [AUTHTYPES[AUTH_TYPE.SystemIdentity], AUTHTYPES[AUTH_TYPE.UserIdentity], From d1bebd7838f923fb7a40e39384fbfb22ada76a1a Mon Sep 17 00:00:00 2001 From: "Tony Chen (DevDiv)" Date: Mon, 17 Feb 2025 16:50:32 +0800 Subject: [PATCH 07/14] Remove tcp --- .../tests/latest/test_serviceconnector-passwordless_scenario.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/tests/latest/test_serviceconnector-passwordless_scenario.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/tests/latest/test_serviceconnector-passwordless_scenario.py index 2170001dd9b..40b83eaca98 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/tests/latest/test_serviceconnector-passwordless_scenario.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/tests/latest/test_serviceconnector-passwordless_scenario.py @@ -367,7 +367,7 @@ def test_aad_webapp_fabric_sql(self): source_id = SOURCE_RESOURCES.get(RESOURCE.WebApp).format(**self.kwargs) connection_id = source_id + "/providers/Microsoft.ServiceLinker/linkers/" + name target_id = 'https://api.fabric.microsoft.com/v1/workspaces/13c65326-ecab-43f6-8a05-60927aaa4cec/SqlDatabases/4fdf6efe-23a9-4d74-8c4a-4ecc70c4d323' - server = 'tcp:renzo-srv-6ae35870-c362-44b9-8389-ada214a46bb5-51240650dd56.database.windows.net,1433' + server = 'renzo-srv-6ae35870-c362-44b9-8389-ada214a46bb5-51240650dd56.database.windows.net,1433' database = 'AzureServiceConnectorTestSqlDb-4fdf6efe-23a9-4d74-8c4a-4ecc70c4d323' # prepare From 73ff0b3b0d2c0536b2f91652775956a3c5f68aef Mon Sep 17 00:00:00 2001 From: "Tony Chen (DevDiv)" Date: Mon, 17 Feb 2025 22:32:42 +0800 Subject: [PATCH 08/14] error message improve --- .../handlers/fabric_sql_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py index 03a37770ede..ddabfaa039d 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py @@ -24,7 +24,7 @@ def __init__(self, cmd, target_id, target_type, auth_info, connection_name, conn Database = connstr_props.get('Database') or connstr_props.get('Initial Catalog') if not Server or not Database: raise CLIInternalError("Missing 'Server' or 'Database' in additonal connection string properties keys." - "Use --connection-string-props 'Server=xxx' 'Database=xxx' to provide the values.") + "Use --connstr_props 'Server=xxx' 'Database=xxx' to provide the values.") # Construct the ODBC connection string self.ODBCConnectionString = self.construct_odbc_connection_string(Server, Database) From 30efa76cdc2e6003ff4065d44a0b74543299a077 Mon Sep 17 00:00:00 2001 From: "Tony Chen (DevDiv)" Date: Tue, 18 Feb 2025 10:39:55 +0800 Subject: [PATCH 09/14] Versioning --- .../azext_serviceconnector_passwordless/config.py | 2 +- src/serviceconnector-passwordless/setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/config.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/config.py index 4314fdc8d79..cf55747e498 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/config.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/config.py @@ -4,5 +4,5 @@ # -------------------------------------------------------------------------------------------- -VERSION = '3.1.3' +VERSION = '3.1.4' NAME = 'serviceconnector-passwordless' diff --git a/src/serviceconnector-passwordless/setup.py b/src/serviceconnector-passwordless/setup.py index 62fc66b4f12..acf87856f8a 100644 --- a/src/serviceconnector-passwordless/setup.py +++ b/src/serviceconnector-passwordless/setup.py @@ -15,7 +15,7 @@ logger.warn("Wheel is not available, disabling bdist_wheel hook") -VERSION = '3.1.3' +VERSION = '3.1.4' try: from azext_serviceconnector_passwordless.config import VERSION except ImportError: From ba6e6a94b08cc1410b3d0e75f7da48a7dc604381 Mon Sep 17 00:00:00 2001 From: "Tony Chen (DevDiv)" Date: Tue, 18 Feb 2025 11:04:34 +0800 Subject: [PATCH 10/14] Undo sql handlers refactor --- .../_credential_free.py | 381 +++++++++++++++++- .../handlers/fabric_sql_handler.py | 81 ---- .../handlers/sql_handler.py | 213 ---------- .../handlers/target_handler.py | 100 ----- 4 files changed, 373 insertions(+), 402 deletions(-) delete mode 100644 src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/sql_handler.py delete mode 100644 src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/target_handler.py diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py index e1907554d03..69e92022889 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py @@ -4,12 +4,10 @@ # -------------------------------------------------------------------------------------------- # pylint: disable=no-member, too-many-lines, anomalous-backslash-in-string, redefined-outer-name, no-else-raise, attribute-defined-outside-init,too-many-positional-arguments +import struct import sys import re -from .handlers.target_handler import ( - AUTHTYPES, - TargetHandler -) +from knack.log import get_logger from azure.mgmt.core.tools import parse_resource_id from azure.cli.core import telemetry from azure.cli.core.azclierror import ( @@ -19,6 +17,7 @@ ResourceNotFoundError ) from azure.cli.core.extension.operations import _install_deps_for_psycopg2, _run_pip +from azure.cli.core._profile import Profile from azure.cli.command_modules.serviceconnector._utils import ( generate_random_string, is_packaged_installed, @@ -32,12 +31,16 @@ get_source_resource_name, get_target_resource_name, ) -from .handlers.sql_handler import SqlHandler -from .handlers.fabric_sql_handler import FabricSqlHandler -from knack.log import get_logger -from ._utils import run_cli_cmd, get_local_ip, confirm_all_ip_allow, confirm_enable_entra_auth +from ._utils import run_cli_cmd, get_local_ip, confirm_all_ip_allow, confirm_admin_set, confirm_enable_entra_auth logger = get_logger(__name__) +AUTHTYPES = { + AUTH_TYPE.SystemIdentity: 'systemAssignedIdentity', + AUTH_TYPE.UserIdentity: 'userAssignedIdentity', + AUTH_TYPE.ServicePrincipalSecret: 'servicePrincipalSecret', + AUTH_TYPE.UserAccount: 'userAccount', +} + # pylint: disable=line-too-long, consider-using-f-string, too-many-statements, unused-argument # For db(mysqlFlex/psql/psqlFlex/sql) linker with auth type=systemAssignedIdentity, enable Microsoft Entra auth and create db user on data plane @@ -160,6 +163,94 @@ def getTargetHandler(cmd, target_id, target_type, auth_info, client_type, connec return None +class TargetHandler: + + def __init__(self, cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user): + self.cmd = cmd + self.target_id = target_id + self.target_type = target_type + self.tenant_id = Profile( + cli_ctx=cmd.cli_ctx).get_subscription().get("tenantId") + target_segments = parse_resource_id(target_id) + self.subscription = target_segments.get('subscription') + self.resource_group = target_segments.get('resource_group') + self.auth_type = auth_info['auth_type'] + self.auth_info = auth_info + self.login_username = run_cli_cmd( + 'az account show').get("user").get("name") + self.login_usertype = run_cli_cmd( + 'az account show').get("user").get("type") # servicePrincipal, user + if (self.login_usertype not in ['servicePrincipal', 'user']): + e = CLIInternalError( + f'{self.login_usertype} is not supported. Please login as user or servicePrincipal') + telemetry.set_exception(e, "Unsupported-UserType-" + self.login_usertype) + raise e + self.aad_username = "aad_" + connection_name + self.connection_name = connection_name + self.skip_prompt = skip_prompt + self.new_user = new_user + self.endpoint = "" + self.user_object_id = "" + self.identity_name = "" + self.identity_client_id = "" + self.identity_object_id = "" + + def enable_target_aad_auth(self): + return + + def set_user_admin(self, user_object_id, **kwargs): + return + + def set_target_firewall(self, is_add, ip_name, start_ip=None, end_ip=None): + return + + def create_aad_user(self): + return + + def check_db_existence(self): + return + + def get_auth_flag(self): + if self.auth_type == AUTHTYPES[AUTH_TYPE.UserAccount]: + return '--user-account' + if self.auth_type == AUTHTYPES[AUTH_TYPE.SystemIdentity]: + return '--system-identity' + if self.auth_type == AUTHTYPES[AUTH_TYPE.UserIdentity]: + return '--user-identity' + if self.auth_type == AUTHTYPES[AUTH_TYPE.ServicePrincipalSecret]: + return '--service-principal' + return None + + def get_auth_config(self, user_object_id): + if self.auth_type == AUTHTYPES[AUTH_TYPE.UserAccount]: + return { + 'auth_type': self.auth_type, + 'username': self.aad_username, + 'principal_id': user_object_id + } + if self.auth_type == AUTHTYPES[AUTH_TYPE.SystemIdentity]: + return { + 'auth_type': self.auth_type, + 'username': self.aad_username, + } + if self.auth_type == AUTHTYPES[AUTH_TYPE.UserIdentity]: + return { + 'auth_type': self.auth_type, + 'username': self.aad_username, + 'client_id': self.identity_client_id, + 'subscription_id': self.auth_info['subscription_id'], + } + if self.auth_type == AUTHTYPES[AUTH_TYPE.ServicePrincipalSecret]: + return { + 'auth_type': self.auth_type, + 'username': self.aad_username, + 'principal_id': self.identity_object_id, + 'client_id': self.identity_client_id, + 'secret': self.auth_info['secret'], + } + return None + + class MysqlFlexibleHandler(TargetHandler): def __init__(self, cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user): @@ -356,6 +447,197 @@ def get_create_query(self): ] +class SqlHandler(TargetHandler): + + def __init__(self, cmd, target_id, target_type, auth_info, connection_name, skip_prompt, new_user): + super().__init__(cmd, target_id, target_type, + auth_info, connection_name, skip_prompt, new_user) + self.endpoint = cmd.cli_ctx.cloud.suffixes.sql_server_hostname + target_segments = parse_resource_id(target_id) + self.server = target_segments.get('name') + self.dbname = target_segments.get('child_name_1') + self.ip = "" + + def check_db_existence(self): + try: + db_info = run_cli_cmd( + 'az sql db show --ids "{}"'.format(self.target_id)) + if db_info is None: + e = ResourceNotFoundError( + "No database found with name {}".format(self.dbname)) + telemetry.set_exception(e, "No-Db") + raise e + except CLIInternalError as e: + telemetry.set_exception(e, "No-Db") + raise e + + def set_user_admin(self, user_object_id, **kwargs): + # pylint: disable=not-an-iterable + admins = run_cli_cmd( + 'az sql server ad-admin list --ids "{}"'.format(self.target_id)) + if not user_object_id: + if not admins: + e = ValidationError( + 'No Microsoft Entra admin found. Please set current user as Microsoft Entra admin and try again.') + telemetry.set_exception(e, "Missing-Aad-Admin") + raise e + else: + logger.warning( + 'Unable to check if current user is Microsoft Entra admin. Please confirm current user as Microsoft Entra admin manually.') + return + admin_info = next((ad for ad in admins if ad.get('sid') == user_object_id), None) + if not admin_info: + set_admin = True + if not self.skip_prompt: + set_admin = confirm_admin_set() + if set_admin: + logger.warning('Setting current user as database server Microsoft Entra admin:' + ' user=%s object id=%s', self.login_username, user_object_id) + admin_info = run_cli_cmd('az sql server ad-admin create -g "{}" --server-name "{}" --display-name "{}" --object-id "{}" --subscription "{}"'.format( + self.resource_group, self.server, self.login_username, user_object_id, self.subscription)) + self.admin_username = admin_info.get('login', self.login_username) if admin_info else self.login_username + + def create_aad_user(self): + + query_list = self.get_create_query() + connection_args = self.get_connection_string() + ip_name = generate_random_string(prefix='svc_').lower() + try: + logger.warning("Connecting to database...") + self.create_aad_user_in_sql(connection_args, query_list) + except AzureConnectionError as e: + from azure.cli.core.util import in_cloud_console + if in_cloud_console(): + self.set_target_firewall( + True, ip_name, '0.0.0.0', '0.0.0.0') + else: + if not self.ip: + error_code = '' + error_res = re.search( + r'\((\d{5})\)', str(e)) + if error_res: + error_code = error_res.group(1) + telemetry.set_exception(e, "Connect-Db-Fail-" + error_code) + raise e + logger.warning(e) + # allow local access + ip_address = self.ip + self.set_target_firewall(True, ip_name, ip_address, ip_address) + try: + # create again + self.create_aad_user_in_sql(connection_args, query_list) + except AzureConnectionError as e: + logger.warning(e) + ex = AzureConnectionError( + "Please confirm local environment can connect to database and try again.") + error_code = '' + error_res = re.search( + r'\((\d{5})\)', str(e)) + if error_res: + error_code = error_res.group(1) + telemetry.set_exception(e, "Connect-Db-Fail-" + error_code) + raise ex from e + finally: + self.set_target_firewall(False, ip_name) + + def set_target_firewall(self, is_add, ip_name, start_ip=None, end_ip=None): + if is_add: + target = run_cli_cmd( + 'az sql server show --ids "{}"'.format(self.target_id)) + # logger.warning("Update database server firewall rule to connect...") + if target.get('publicNetworkAccess') == "Disabled": + ex = AzureConnectionError( + "The target resource doesn't allow public access. Please enable it manually and try again.") + telemetry.set_exception(ex, "Public-Access-Disabled") + raise ex + logger.warning("Add firewall rule %s %s - %s...%s", ip_name, start_ip, end_ip, + ('(it will be removed after connection is created)' if self.auth_type != AUTHTYPES[ + AUTH_TYPE.UserAccount] else '(Please delete it manually if it has security risk.)')) + run_cli_cmd( + 'az sql server firewall-rule create -g "{0}" -s "{1}" -n "{2}" ' + '--subscription "{3}" --start-ip-address {4} --end-ip-address {5}'.format( + self.resource_group, self.server, ip_name, self.subscription, start_ip, end_ip) + ) + else: + if self.auth_type == AUTHTYPES[AUTH_TYPE.UserAccount]: + return + logger.warning( + "Remove database server firewall rule %s to recover...", ip_name) + try: + run_cli_cmd( + 'az sql server firewall-rule delete -g "{0}" -s "{1}" -n "{2}" --subscription "{3}"'.format( + self.resource_group, self.server, ip_name, self.subscription) + ) + except CLIInternalError as e: + logger.warning( + "Can't remove firewall rule %s. Please manually delete it to avoid security issue. %s", ip_name, str(e)) + + def create_aad_user_in_sql(self, connection_args, query_list): + if not self.new_user: + query_list = query_list[1:] + if not is_packaged_installed('pyodbc'): + _run_pip(["install", "pyodbc"]) + + # pylint: disable=import-error, c-extension-no-member + try: + import pyodbc + except ModuleNotFoundError as e: + raise ModuleNotFoundError( + "Dependency pyodbc can't be installed, please install it manually with `" + sys.executable + " -m pip install pyodbc`.") from e + drivers = [x for x in pyodbc.drivers() if x in [ + 'ODBC Driver 17 for SQL Server', 'ODBC Driver 18 for SQL Server']] + if not drivers: + ex = CLIInternalError( + "Please manually install odbc 17/18 for SQL server, reference: https://docs.microsoft.com/en-us/sql/connect/odbc/download-odbc-driver-for-sql-server/") + telemetry.set_exception(ex, "No-ODBC-Driver") + raise ex + try: + with pyodbc.connect(connection_args.get("connection_string").format(driver=drivers[0]), attrs_before=connection_args.get("attrs_before")) as conn: + with conn.cursor() as cursor: + logger.warning( + "Adding new Microsoft Entra user %s to database...", self.aad_username) + for execution_query in query_list: + try: + logger.warning("Running query: %s", execution_query) + cursor.execute(execution_query) + except pyodbc.ProgrammingError as e: + logger.warning("Query execution failed: %s", str(e)) + conn.commit() + except pyodbc.Error as e: + search_ip = re.search( + "Client with IP address '(.*?)' is not allowed to access the server", str(e)) + if search_ip is not None: + self.ip = search_ip.group(1) + raise AzureConnectionError("Fail to connect sql." + str(e)) from e + + def get_connection_string(self, dbname=""): + token_bytes = run_cli_cmd( + 'az account get-access-token --output json --resource https://database.windows.net/').get('accessToken').encode('utf-16-le') + + token_struct = struct.pack( + f' Date: Tue, 18 Feb 2025 11:11:52 +0800 Subject: [PATCH 11/14] Undo sql handlers refactor --- .../azext_serviceconnector_passwordless/_credential_free.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py index 69e92022889..de8785c992f 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py @@ -7,6 +7,7 @@ import struct import sys import re +import requests from knack.log import get_logger from azure.mgmt.core.tools import parse_resource_id from azure.cli.core import telemetry @@ -961,6 +962,7 @@ def get_create_query(self): self.aad_username) ] + class FabricSqlHandler(SqlHandler): def __init__(self, cmd, target_id, target_type, auth_info, connection_name, connstr_props, skip_prompt, new_user): super().__init__(cmd, target_id, target_type, @@ -1043,6 +1045,7 @@ def get_create_query(self): return [delete_q, role_q, grant_q1, grant_q2, grant_q3] + def getSourceHandler(source_id, source_type): if source_type in {RESOURCE.WebApp, RESOURCE.FunctionApp}: return WebappHandler(source_id, source_type) @@ -1054,8 +1057,6 @@ def getSourceHandler(source_id, source_type): return SpringHandler(source_id, source_type) if source_type in {RESOURCE.Local}: return LocalHandler(source_id, source_type) - if source_type in {RESOURCE.FabricSql}: - return FabricSqlHandler() return None From fd69c9671969474671fdf685885b3addc5920dae Mon Sep 17 00:00:00 2001 From: "Tony Chen (DevDiv)" Date: Tue, 18 Feb 2025 11:19:54 +0800 Subject: [PATCH 12/14] Undo sql handlers refactor --- .../handlers/fabric_sql_handler.py | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py deleted file mode 100644 index 672ef99b887..00000000000 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/handlers/fabric_sql_handler.py +++ /dev/null @@ -1,12 +0,0 @@ -import struct -import requests -from .target_handler import AUTHTYPES, run_cli_cmd -from .sql_handler import ResourceNotFoundError, SqlHandler -from azure.cli.core import telemetry -from knack.log import get_logger -from azure.cli.core.azclierror import CLIInternalError -from azure.cli.command_modules.serviceconnector._resource_config import AUTH_TYPE - -logger = get_logger(__name__) - - From 2254e9233987f489255090a9d45bd079b28a59d4 Mon Sep 17 00:00:00 2001 From: "Tony Chen (DevDiv)" Date: Tue, 18 Feb 2025 15:21:41 +0800 Subject: [PATCH 13/14] Maintain arg and kwargs --- .../azext_serviceconnector_passwordless/_credential_free.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py index de8785c992f..1265716de8a 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/_credential_free.py @@ -47,7 +47,7 @@ # For db(mysqlFlex/psql/psqlFlex/sql) linker with auth type=systemAssignedIdentity, enable Microsoft Entra auth and create db user on data plane # For other linker, ignore the steps def get_enable_mi_for_db_linker_func(yes=False, new=False): - def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, connection_name, connstr_props=None): + def enable_mi_for_db_linker(cmd, source_id, target_id, auth_info, client_type, connection_name, connstr_props, *args, **kwargs): # return if connection is not for db mi if auth_info['auth_type'] not in [AUTHTYPES[AUTH_TYPE.SystemIdentity], AUTHTYPES[AUTH_TYPE.UserIdentity], From f0bcf1a67718db3c5cfe982a0a7e18d811d1afb5 Mon Sep 17 00:00:00 2001 From: "Tony Chen (DevDiv)" Date: Wed, 19 Feb 2025 10:21:51 +0800 Subject: [PATCH 14/14] Fix versioning --- src/serviceconnector-passwordless/HISTORY.rst | 4 ++-- .../azext_serviceconnector_passwordless/config.py | 2 +- src/serviceconnector-passwordless/setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/serviceconnector-passwordless/HISTORY.rst b/src/serviceconnector-passwordless/HISTORY.rst index 8335a1448d9..f30cad14546 100644 --- a/src/serviceconnector-passwordless/HISTORY.rst +++ b/src/serviceconnector-passwordless/HISTORY.rst @@ -2,9 +2,9 @@ Release History =============== -3.1.4 +3.2.0 ++++++ -* Introduce support for Fabric SQL as a target service +* Introduce support for Fabric SQL as a target service. Introduce new `connstr_props` argument to configure Fabric SQL. 3.1.3 ++++++ diff --git a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/config.py b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/config.py index cf55747e498..ac95db358c0 100644 --- a/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/config.py +++ b/src/serviceconnector-passwordless/azext_serviceconnector_passwordless/config.py @@ -4,5 +4,5 @@ # -------------------------------------------------------------------------------------------- -VERSION = '3.1.4' +VERSION = '3.2.0' NAME = 'serviceconnector-passwordless' diff --git a/src/serviceconnector-passwordless/setup.py b/src/serviceconnector-passwordless/setup.py index acf87856f8a..027c4211f04 100644 --- a/src/serviceconnector-passwordless/setup.py +++ b/src/serviceconnector-passwordless/setup.py @@ -15,7 +15,7 @@ logger.warn("Wheel is not available, disabling bdist_wheel hook") -VERSION = '3.1.4' +VERSION = '3.2.0' try: from azext_serviceconnector_passwordless.config import VERSION except ImportError: