Skip to content

Commit 14c770f

Browse files
feat: SFI SQL Policy Changes (#469)
* Added Sfi Sql related code changes * removing the index script module from main.bicep * Adding managed identity authentication to scripts file * Added code changes in copy_kp_files to resolved unzip issue * added chnages in copy_kb_files * update copy_kb_files * updated post deployment scripts * Added pyodbc changes in create_sql_tables.py * Created new managed identity with data reader and writer role * adding main.json changes * Adding some changes to support pyodbc connection * updating the odbc driver in db.py * removed debug related code from app.py * refactor the code and added comments * Added except block to connect using username, password for existing deployment * resolved the pylint issues * Resolved pylint issues * Updated the test_app.py and test_db.py * Resolved pylint issues for unit tests * Updated post deployment script * updated main.json
1 parent 5c27106 commit 14c770f

23 files changed

+824
-216
lines changed

ClientAdvisor/App/WebApp.Dockerfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ RUN apk add --no-cache --virtual .build-deps \
1818
libffi-dev \
1919
openssl-dev \
2020
curl \
21+
unixodbc-dev \
2122
&& apk add --no-cache \
22-
libpq
23+
libpq \
24+
&& curl -O https://download.microsoft.com/download/7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8/msodbcsql18_18.4.1.1-1_amd64.apk \
25+
&& apk add --allow-untrusted msodbcsql18_18.4.1.1-1_amd64.apk \
26+
&& rm msodbcsql18_18.4.1.1-1_amd64.apk
2327

2428
COPY ./ClientAdvisor/App/requirements.txt /usr/src/app/
2529
RUN pip install --no-cache-dir -r /usr/src/app/requirements.txt \

ClientAdvisor/App/app.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
format_stream_response, generateFilterString,
2525
parse_multi_columns)
2626
from db import get_connection
27+
from db import dict_cursor
2728

2829
bp = Blueprint("routes", __name__, static_folder="static", template_folder="static")
2930

@@ -1583,7 +1584,8 @@ def get_users():
15831584
ORDER BY NextMeeting ASC;
15841585
"""
15851586
cursor.execute(sql_stmt)
1586-
rows = cursor.fetchall()
1587+
# Since pyodbc returns query results as a list of tuples, using `dict_cursor` function to convert these tuples into a list of dictionaries
1588+
rows = dict_cursor(cursor)
15871589

15881590
if len(rows) <= 6:
15891591
# update ClientMeetings,Assets,Retirement tables sample data to current date
@@ -1618,7 +1620,8 @@ def get_users():
16181620
FROM DaysDifference
16191621
"""
16201622
cursor.execute(combined_stmt)
1621-
date_diff_rows = cursor.fetchall()
1623+
# Since pyodbc returns query results as a list of tuples, using `dict_cursor` function to convert these tuples into a list of dictionaries
1624+
date_diff_rows = dict_cursor(cursor)
16221625

16231626
client_days = (
16241627
date_diff_rows[0]["ClientMeetingDaysDifference"]

ClientAdvisor/App/db.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,60 @@
11
# db.py
22
import os
33

4-
import pymssql
54
from dotenv import load_dotenv
5+
from azure.identity import DefaultAzureCredential
6+
import pyodbc
7+
import struct
8+
import logging
9+
610

711
load_dotenv()
812

13+
driver = "{ODBC Driver 18 for SQL Server}"
914
server = os.environ.get("SQLDB_SERVER")
1015
database = os.environ.get("SQLDB_DATABASE")
1116
username = os.environ.get("SQLDB_USERNAME")
1217
password = os.environ.get("SQLDB_PASSWORD")
18+
mid_id = os.environ.get("SQLDB_USER_MID")
19+
20+
21+
def dict_cursor(cursor):
22+
"""
23+
Converts rows fetched by the cursor into a list of dictionaries.
24+
25+
Args:
26+
cursor: A database cursor object.
27+
28+
Returns:
29+
A list of dictionaries representing rows.
30+
"""
31+
columns = [column[0] for column in cursor.description]
32+
return [dict(zip(columns, row)) for row in cursor.fetchall()]
1333

1434

1535
def get_connection():
36+
try:
37+
credential = DefaultAzureCredential(managed_identity_client_id=mid_id)
38+
39+
token_bytes = credential.get_token(
40+
"https://database.windows.net/.default"
41+
).token.encode("utf-16-LE")
42+
token_struct = struct.pack(f"<I{len(token_bytes)}s", len(token_bytes), token_bytes)
43+
SQL_COPT_SS_ACCESS_TOKEN = (
44+
1256 # This connection option is defined by Microsoft in msodbcsql.h
45+
)
1646

17-
conn = pymssql.connect(
18-
server=server, user=username, password=password, database=database, as_dict=True
19-
)
20-
return conn
47+
# Set up the connection
48+
connection_string = f"DRIVER={driver};SERVER={server};DATABASE={database};"
49+
conn = pyodbc.connect(
50+
connection_string, attrs_before={SQL_COPT_SS_ACCESS_TOKEN: token_struct}
51+
)
52+
return conn
53+
except pyodbc.Error as e:
54+
logging.error(f"Failed with Default Credential: {str(e)}")
55+
conn = pyodbc.connect(
56+
f"DRIVER={driver};SERVER={server};DATABASE={database};UID={username};PWD={password}",
57+
timeout=5
58+
)
59+
logging.info("Connected using Username & Password")
60+
return conn

ClientAdvisor/App/requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,6 @@ isort==6.0.0
2323
# Testing tools
2424
pytest>=8.2,<9 # Compatible version for pytest-asyncio
2525
pytest-asyncio==0.25.3
26-
pytest-cov==6.0.0
26+
pytest-cov==6.0.0
27+
28+
pyodbc==5.2.0

ClientAdvisor/App/tests/test_app.py

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -200,50 +200,60 @@ async def test_ensure_cosmos_generic_exception(mock_init_cosmosdb_client, client
200200

201201

202202
@pytest.mark.asyncio
203-
async def test_get_users_success(client):
203+
@patch("app.get_connection")
204+
@patch("app.dict_cursor")
205+
async def test_get_users_success(mock_dict_cursor, mock_get_connection, client):
206+
# Mock database connection and cursor
204207
mock_conn = MagicMock()
205208
mock_cursor = MagicMock()
209+
mock_get_connection.return_value = mock_conn
206210
mock_conn.cursor.return_value = mock_cursor
207-
mock_cursor.fetchall.return_value = [
208-
{
209-
"ClientId": 1,
210-
"ndays": 10,
211-
"ClientMeetingDaysDifference": 1,
212-
"AssetMonthsDifference": 1,
213-
"StatusMonthsDifference": 1,
214-
"DaysDifference": 1,
215-
"Client": "Client A",
216-
"Email": "clienta@example.com",
217-
"AssetValue": "1,000,000",
218-
"ClientSummary": "Summary A",
219-
"LastMeetingDateFormatted": "Monday January 1, 2023",
220-
"LastMeetingStartTime": "10:00 AM",
221-
"LastMeetingEndTime": "10:30 AM",
222-
"NextMeetingFormatted": "Monday January 8, 2023",
223-
"NextMeetingStartTime": "11:00 AM",
224-
"NextMeetingEndTime": "11:30 AM",
225-
}
226-
]
227211

228-
with patch("app.get_connection", return_value=mock_conn):
229-
response = await client.get("/api/users")
230-
assert response.status_code == 200
231-
res_text = await response.get_data(as_text=True)
232-
assert json.loads(res_text) == [
212+
# Mock query results
213+
mock_dict_cursor.side_effect = [
214+
[ # First call (client data)
233215
{
234216
"ClientId": 1,
235-
"ClientName": "Client A",
236-
"ClientEmail": "clienta@example.com",
217+
"Client": "Client A",
218+
"Email": "clienta@example.com",
237219
"AssetValue": "1,000,000",
238-
"NextMeeting": "Monday January 8, 2023",
239-
"NextMeetingTime": "11:00 AM",
240-
"NextMeetingEndTime": "11:30 AM",
241-
"LastMeeting": "Monday January 1, 2023",
220+
"ClientSummary": "Summary A",
221+
"LastMeetingDateFormatted": "Monday January 1, 2023",
242222
"LastMeetingStartTime": "10:00 AM",
243223
"LastMeetingEndTime": "10:30 AM",
244-
"ClientSummary": "Summary A",
224+
"NextMeetingFormatted": "Monday January 8, 2023",
225+
"NextMeetingStartTime": "11:00 AM",
226+
"NextMeetingEndTime": "11:30 AM",
227+
}
228+
],
229+
[ # Second call (date difference query)
230+
{
231+
"ClientMeetingDaysDifference": 5,
232+
"AssetMonthsDifference": 1,
233+
"StatusMonthsDifference": 1
245234
}
246235
]
236+
]
237+
238+
# Call the function
239+
response = await client.get("/api/users")
240+
assert response.status_code == 200
241+
res_text = await response.get_data(as_text=True)
242+
assert json.loads(res_text) == [
243+
{
244+
"ClientId": 1,
245+
"ClientName": "Client A",
246+
"ClientEmail": "clienta@example.com",
247+
"AssetValue": "1,000,000",
248+
"NextMeeting": "Monday January 8, 2023",
249+
"NextMeetingTime": "11:00 AM",
250+
"NextMeetingEndTime": "11:30 AM",
251+
"LastMeeting": "Monday January 1, 2023",
252+
"LastMeetingStartTime": "10:00 AM",
253+
"LastMeetingEndTime": "10:30 AM",
254+
"ClientSummary": "Summary A",
255+
}
256+
]
247257

248258

249259
@pytest.mark.asyncio

ClientAdvisor/App/tests/test_db.py

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,92 @@
1+
import struct
12
from unittest.mock import MagicMock, patch
23

34
import db
5+
import pyodbc
46

7+
# Mock configuration
58
db.server = "mock_server"
69
db.username = "mock_user"
710
db.password = "mock_password"
811
db.database = "mock_database"
12+
db.driver = "mock_driver"
13+
db.mid_id = "mock_mid_id" # Managed identity client ID if needed
914

1015

11-
@patch("db.pymssql.connect")
12-
def test_get_connection(mock_connect):
16+
@patch("db.pyodbc.connect") # Mock pyodbc.connect
17+
@patch("db.DefaultAzureCredential") # Mock DefaultAzureCredential
18+
def test_get_connection(mock_credential_class, mock_connect):
19+
# Mock the DefaultAzureCredential and get_token method
20+
mock_credential = MagicMock()
21+
mock_credential_class.return_value = mock_credential
22+
mock_token = MagicMock()
23+
mock_token.token = "mock_token"
24+
mock_credential.get_token.return_value = mock_token
1325
# Create a mock connection object
1426
mock_conn = MagicMock()
1527
mock_connect.return_value = mock_conn
1628

1729
# Call the function
1830
conn = db.get_connection()
1931

20-
# Assert that pymssql.connect was called with the correct parameters
32+
# Assert that DefaultAzureCredential and get_token were called correctly
33+
mock_credential_class.assert_called_once_with(managed_identity_client_id=db.mid_id)
34+
mock_credential.get_token.assert_called_once_with("https://database.windows.net/.default")
35+
36+
# Assert that pyodbc.connect was called with the correct parameters, including the token
37+
expected_attrs_before = {
38+
1256: struct.pack(f"<I{len(mock_token.token.encode('utf-16-LE'))}s", len(mock_token.token.encode("utf-16-LE")), mock_token.token.encode("utf-16-LE"))
39+
}
2140
mock_connect.assert_called_once_with(
22-
server="mock_server",
23-
user="mock_user",
24-
password="mock_password",
25-
database="mock_database",
26-
as_dict=True,
41+
f"DRIVER={db.driver};SERVER={db.server};DATABASE={db.database};",
42+
attrs_before=expected_attrs_before
43+
)
44+
45+
# Assert that the connection returned is the mock connection
46+
assert conn == mock_conn
47+
48+
49+
@patch("db.pyodbc.connect") # Mock pyodbc.connect
50+
@patch("db.DefaultAzureCredential") # Mock DefaultAzureCredential
51+
def test_get_connection_token_failure(mock_credential_class, mock_connect):
52+
# Mock the DefaultAzureCredential and get_token method
53+
mock_credential = MagicMock()
54+
mock_credential_class.return_value = mock_credential
55+
mock_token = MagicMock()
56+
mock_token.token = "mock_token"
57+
mock_credential.get_token.return_value = mock_token
58+
59+
# Create a mock connection object
60+
mock_conn = MagicMock()
61+
mock_connect.return_value = mock_conn
62+
63+
# Simulate a failure in pyodbc.connect by raising pyodbc.Error on the first call
64+
mock_connect.side_effect = [pyodbc.Error("pyodbc connection error"), mock_conn]
65+
66+
# Call the function and ensure fallback is used after the pyodbc error
67+
conn = db.get_connection()
68+
69+
# Assert that pyodbc.connect was called with username and password as fallback
70+
mock_connect.assert_any_call(
71+
f"DRIVER={db.driver};SERVER={db.server};DATABASE={db.database};UID={db.username};PWD={db.password}",
72+
timeout=5
2773
)
2874

2975
# Assert that the connection returned is the mock connection
3076
assert conn == mock_conn
77+
78+
79+
def test_dict_cursor():
80+
# Create a mock cursor
81+
mock_cursor = MagicMock()
82+
83+
# Simulate the cursor.description and cursor.fetchall
84+
mock_cursor.description = [("id",), ("name",), ("age",)]
85+
mock_cursor.fetchall.return_value = [(1, "Alice", 30), (2, "Bob", 25)]
86+
87+
# Call the dict_cursor function
88+
result = db.dict_cursor(mock_cursor)
89+
90+
# Verify the result
91+
expected_result = [{'id': 1, 'name': 'Alice', 'age': 30}, {'id': 2, 'name': 'Bob', 'age': 25}]
92+
assert result == expected_result

ClientAdvisor/AzureFunction/Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
# FROM mcr.microsoft.com/azure-functions/python:4-python3.11-appservice
33
FROM mcr.microsoft.com/azure-functions/python:4-python3.11
44

5+
# Install Microsoft ODBC Driver
6+
RUN apt-get update && \
7+
ACCEPT_EULA=Y apt-get install -y msodbcsql18
8+
59
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
610
AzureFunctionsJobHost__Logging__Console__IsEnabled=true
711

ClientAdvisor/AzureFunction/function_app.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
from semantic_kernel.functions.kernel_arguments import KernelArguments
1818
from semantic_kernel.functions.kernel_function_decorator import kernel_function
1919
from semantic_kernel.kernel import Kernel
20-
import pymssql
20+
from azure.identity import DefaultAzureCredential
21+
import pyodbc
22+
import struct
23+
import logging
2124

2225
# Azure Function App
2326
app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
@@ -120,13 +123,7 @@ def get_SQL_Response(
120123
sql_query = sql_query.replace("```sql",'').replace("```",'')
121124
#print(sql_query)
122125

123-
connectionString = os.environ.get("SQLDB_CONNECTION_STRING")
124-
server = os.environ.get("SQLDB_SERVER")
125-
database = os.environ.get("SQLDB_DATABASE")
126-
username = os.environ.get("SQLDB_USERNAME")
127-
password = os.environ.get("SQLDB_PASSWORD")
128-
129-
conn = pymssql.connect(server, username, password, database)
126+
conn = get_connection()
130127
# conn = pyodbc.connect(connectionString)
131128
cursor = conn.cursor()
132129
cursor.execute(sql_query)
@@ -224,6 +221,40 @@ def get_answers_from_calltranscripts(
224221
answer = completion.choices[0].message.content
225222
return answer
226223

224+
def get_connection():
225+
driver = "{ODBC Driver 18 for SQL Server}"
226+
server = os.environ.get("SQLDB_SERVER")
227+
database = os.environ.get("SQLDB_DATABASE")
228+
username = os.environ.get("SQLDB_USERNAME")
229+
password = os.environ.get("SQLDB_PASSWORD")
230+
mid_id = os.environ.get("SQLDB_USER_MID")
231+
try :
232+
credential = DefaultAzureCredential(managed_identity_client_id=mid_id)
233+
234+
token_bytes = credential.get_token(
235+
"https://database.windows.net/.default"
236+
).token.encode("utf-16-LE")
237+
token_struct = struct.pack(f"<I{len(token_bytes)}s", len(token_bytes), token_bytes)
238+
SQL_COPT_SS_ACCESS_TOKEN = (
239+
1256 # This connection option is defined by microsoft in msodbcsql.h
240+
)
241+
242+
# Set up the connection
243+
connection_string = f"DRIVER={driver};SERVER={server};DATABASE={database};"
244+
conn = pyodbc.connect(
245+
connection_string, attrs_before={SQL_COPT_SS_ACCESS_TOKEN: token_struct}
246+
)
247+
return conn
248+
249+
except pyodbc.Error as e:
250+
logging.error(f"Failed with Default Credential: {str(e)}")
251+
conn = pyodbc.connect(
252+
f"DRIVER={driver};SERVER={server};DATABASE={database};UID={username};PWD={password}",
253+
timeout=5
254+
)
255+
logging.info("Connected using Username & Password")
256+
return conn
257+
227258
# Get data from Azure Open AI
228259
async def stream_processor(response):
229260
async for message in response:

ClientAdvisor/AzureFunction/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ openai==1.63.0
88
semantic_kernel==1.0.4
99
pymssql==2.3.2
1010
azure-search-documents==11.6.0b9
11+
12+
pyodbc==5.2.0
13+
azure-identity==1.20.0

0 commit comments

Comments
 (0)