Skip to content

Commit

Permalink
{serviceconnector-passwordless}: Support Fabric As Target Service (#8482
Browse files Browse the repository at this point in the history
)

* First changes

* Fix import

* Add inheritance

* Set up some proper inheritance

* Fix style guide

* Fix args order

* Remove tcp

* error message improve

* Versioning

* Undo sql handlers refactor

* Undo sql handlers refactor

* Undo sql handlers refactor

* Maintain arg and kwargs

* Fix versioning

---------

Co-authored-by: Tony Chen (DevDiv) <[email protected]>
  • Loading branch information
Tonychen0227 and Tony Chen (DevDiv) authored Feb 21, 2025
1 parent f15cd07 commit 07c78a6
Show file tree
Hide file tree
Showing 10 changed files with 152 additions and 8 deletions.
4 changes: 4 additions & 0 deletions src/serviceconnector-passwordless/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
Release History
===============
3.2.0
++++++
* Introduce support for Fabric SQL as a target service. Introduce new `connstr_props` argument to configure Fabric SQL.

3.1.3
++++++
* Fix argument missing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, *_):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,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, *args, **kwargs):
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],
Expand All @@ -61,7 +62,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()
Expand All @@ -88,7 +89,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(
Expand Down Expand Up @@ -149,7 +150,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}:
Expand All @@ -158,6 +159,8 @@ 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


Expand Down Expand Up @@ -960,6 +963,89 @@ def get_create_query(self):
]


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)

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 --connstr_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 check_db_existence(self):
fabric_token = self.get_fabric_access_token()
headers = {"Authorization": "Bearer {}".format(fabric_token)}
response = requests.get(self.target_id, headers=headers)

if response:
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 = {
'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 get_fabric_access_token(self):
return run_cli_cmd('az account get-access-token --output json --resource https://api.fabric.microsoft.com/').get('accessToken')

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

token_struct = struct.pack(
f'<I{len(token_bytes)}s', len(token_bytes), token_bytes)
# This connection option is defined by microsoft in msodbcsql.h
SQL_COPT_SS_ACCESS_TOKEN = 1256
conn_string = self.ODBCConnectionString
return {'connection_string': conn_string, 'attrs_before': {SQL_COPT_SS_ACCESS_TOKEN: token_struct}}

def get_create_query(self):
if self.auth_type in [AUTHTYPES[AUTH_TYPE.SystemIdentity], AUTHTYPES[AUTH_TYPE.UserIdentity]]:
self.aad_username = self.identity_name
else:
raise CLIInternalError("Unsupported auth type: " + self.auth_type)

delete_q = "DROP USER IF EXISTS \"{}\";".format(self.aad_username)
role_q = "CREATE USER \"{}\" FROM EXTERNAL PROVIDER;".format(self.aad_username)
grant_q1 = "ALTER ROLE db_datareader ADD MEMBER \"{}\"".format(self.aad_username)
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]


def getSourceHandler(source_id, source_type):
if source_type in {RESOURCE.WebApp, RESOURCE.FunctionApp}:
return WebappHandler(source_id, source_type)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
# RESOURCE.Postgres,
RESOURCE.PostgresFlexible,
RESOURCE.MysqlFlexible,
RESOURCE.Sql
RESOURCE.Sql,
RESOURCE.FabricSql
]

# pylint: disable=line-too-long
Expand All @@ -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 = {
Expand Down Expand Up @@ -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=<Server_Host>,<Port>" "Database=<Database_Name>".',
'placeholder': 'Server=MyServer,1433 Database=MyDB'
}
}
}

AUTH_TYPE_PARAMS = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
# --------------------------------------------------------------------------------------------


VERSION = '3.1.3'
VERSION = '3.2.0'
NAME = 'serviceconnector-passwordless'
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,38 @@ def test_local_sql_passwordless(self):

# delete connection
self.cmd('connection delete --id {} --yes'.format(connection_id))

def test_aad_webapp_fabric_sql(self):
self.kwargs.update({
'subscription': get_subscription_id(self.cli_ctx),
'source_resource_group': 'azure-service-connector',
'site': 'DotNetAppSqlDb20240704',
'database': 'clitest'
})
name = 'testfabricconn'
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 = 'renzo-srv-6ae35870-c362-44b9-8389-ada214a46bb5-51240650dd56.database.windows.net,1433'
database = 'AzureServiceConnectorTestSqlDb-4fdf6efe-23a9-4d74-8c4a-4ecc70c4d323'

# prepare
self.cmd('webapp identity remove --ids {}'.format(source_id))

# create
self.cmd('webapp connection create fabric-sql --connection {} --source-id {} --target-id {} \
--system-identity --client-type dotnet --opt-out publicnetwork \
--connstr-props "Server={}" "Database={}" '.format(name, source_id, target_id, server, database)
)
# clean
self.cmd('webapp connection delete --id {} --yes'.format(connection_id))

# recreate and test
self.cmd('webapp connection create fabric-sql --connection {} --source-id {} --target-id {} \
--system-identity --client-type dotnet --opt-out publicnetwork \
--connstr-props "Server={}" \
"Database={}" '.format(name, source_id, target_id, server, database)
)

# clean
self.cmd('webapp connection delete --id {} --yes'.format(connection_id))
2 changes: 1 addition & 1 deletion src/serviceconnector-passwordless/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
logger.warn("Wheel is not available, disabling bdist_wheel hook")


VERSION = '3.1.3'
VERSION = '3.2.0'
try:
from azext_serviceconnector_passwordless.config import VERSION
except ImportError:
Expand Down

0 comments on commit 07c78a6

Please sign in to comment.