Skip to content

Commit b2ce30d

Browse files
luis-dkaarthy-dk
authored andcommitted
feat(connections): connect to azure mssql using managed identities
1 parent 5c62432 commit b2ce30d

10 files changed

Lines changed: 244 additions & 12 deletions

File tree

deploy/testgen-base.dockerfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ RUN apk del \
4848
cmake \
4949
musl-dev \
5050
gfortran \
51-
curl \
5251
gpg \
5352
linux-headers \
5453
openblas-dev \

deploy/testgen.dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
ARG TESTGEN_BASE_LABEL=v8
1+
ARG TESTGEN_BASE_LABEL=v9
22

33
FROM datakitchen/dataops-testgen-base:${TESTGEN_BASE_LABEL} AS release-image
44

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ dependencies = [
6161
"cron-converter==1.2.1",
6262
"cron-descriptor==2.0.5",
6363
"pybars3==0.9.7",
64+
"azure-identity==1.25.1",
6465

6566
# Pinned to match the manually compiled libs or for security
6667
"pyarrow==18.1.0",

testgen/common/database/flavor/flavor_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ class ConnectionParams(TypedDict):
2222
private_key_passphrase: bytes
2323
http_path: str
2424
service_account_key: dict[str,Any]
25+
connect_with_identity: bool
26+
sql_flavor_code: str
2527

2628
class FlavorService:
2729

@@ -49,6 +51,8 @@ def init(self, connection_params: ConnectionParams):
4951
self.catalog = connection_params.get("catalog") or ""
5052
self.warehouse = connection_params.get("warehouse") or ""
5153
self.service_account_key = connection_params.get("service_account_key", None)
54+
self.connect_with_identity = connection_params.get("connect_with_identity") or False
55+
self.sql_flavor_code = connection_params.get("sql_flavor_code") or self.flavor
5256

5357
password = connection_params.get("project_pw_encrypted", None)
5458
if isinstance(password, memoryview) or isinstance(password, bytes):

testgen/common/database/flavor/mssql_flavor_service.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from urllib.parse import quote_plus
22

3+
from sqlalchemy.engine import URL
4+
35
from testgen import settings
46
from testgen.common.database.flavor.flavor_service import FlavorService
57

@@ -14,14 +16,28 @@ def get_connection_string_head(self):
1416
return f"mssql+pyodbc://{self.username}:{quote_plus(self.password)}@"
1517

1618
def get_connection_string_from_fields(self):
17-
strConnect = (
18-
f"mssql+pyodbc://{self.username}:{quote_plus(self.password)}@{self.host}:{self.port}/{self.dbname}?driver=ODBC+Driver+18+for+SQL+Server"
19+
connection_url = URL.create(
20+
"mssql+pyodbc",
21+
username=self.username,
22+
password=quote_plus(self.password or ""),
23+
host=self.host,
24+
port=int(self.port or 1443),
25+
database=self.dbname,
26+
query={
27+
"driver": "ODBC Driver 18 for SQL Server",
28+
},
1929
)
2030

21-
if "synapse" in self.host:
22-
strConnect += "&autocommit=True"
31+
if self.connect_with_identity:
32+
connection_url = connection_url._replace(username=None, password=None).update_query_dict({
33+
"encrypt": "yes",
34+
"authentication": "ActiveDirectoryMsi",
35+
})
36+
37+
if self.sql_flavor_code == "synapse_mssql":
38+
connection_url = connection_url.update_query_dict({"autocommit": True})
2339

24-
return strConnect
40+
return connection_url.render_as_string(hide_password=False)
2541

2642
def get_pre_connection_queries(self):
2743
return [

testgen/common/models/connection.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class Connection(Entity):
6262
http_path: str = Column(String)
6363
warehouse: str = Column(String)
6464
service_account_key: JSON_TYPE = Column(EncryptedJson)
65+
connect_with_identity: bool = Column(Boolean, default=False)
6566

6667
_get_by = "connection_id"
6768
_default_order_by = (asc(func.lower(connection_name)),)

testgen/template/dbsetup/030_initialize_new_schema_structure.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ CREATE TABLE connections (
7070
url VARCHAR(200) default '',
7171
connect_by_url BOOLEAN default FALSE,
7272
connect_by_key BOOLEAN DEFAULT FALSE,
73+
connect_with_identity BOOLEAN DEFAULT FALSE,
7374
private_key BYTEA,
7475
private_key_passphrase BYTEA,
7576
http_path VARCHAR(200),
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
SET SEARCH_PATH TO {SCHEMA_NAME};
2+
ALTER TABLE connections ADD COLUMN connect_with_identity BOOLEAN DEFAULT FALSE;

testgen/ui/components/frontend/js/components/connection_form.js

Lines changed: 205 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
* @property {boolean} connect_by_url
3232
* @property {string?} url
3333
* @property {boolean} connect_by_key
34+
* @property {boolean} connect_with_identity
3435
* @property {string?} private_key
3536
* @property {string?} private_key_passphrase
3637
* @property {string?} http_path
@@ -126,6 +127,7 @@ const ConnectionForm = (props, saveButton) => {
126127
warehouse: connection?.warehouse ?? '',
127128
url: connection?.url ?? '',
128129
service_account_key: connection?.service_account_key ?? '',
130+
connect_with_identity: connection?.connect_with_identity ?? false,
129131
sql_flavor_code: connectionFlavor.rawVal ?? '',
130132
connection_name: connectionName.rawVal ?? '',
131133
max_threads: connectionMaxThreads.rawVal ?? 4,
@@ -550,7 +552,197 @@ const RedshiftSpectrumForm = RedshiftForm;
550552

551553
const PostgresqlForm = RedshiftForm;
552554

553-
const AzureMSSQLForm = RedshiftForm;
555+
const AzureMSSQLForm = (
556+
connection,
557+
flavor,
558+
onChange,
559+
originalConnection,
560+
dynamicConnectionUrl,
561+
) => {
562+
const isValid = van.state(true);
563+
const connectByUrl = van.state(connection.rawVal.connect_by_url ?? false);
564+
const connectionHost = van.state(connection.rawVal.project_host ?? '');
565+
const connectionPort = van.state(connection.rawVal.project_port || defaultPorts[flavor.flavor]);
566+
const connectionDatabase = van.state(connection.rawVal.project_db ?? '');
567+
const connectionUsername = van.state(connection.rawVal.project_user ?? '');
568+
const connectionPassword = van.state(connection.rawVal?.project_pw_encrypted ?? '');
569+
const connectionUrl = van.state(connection.rawVal?.url ?? '');
570+
const connectWithIdentity = van.state(connection.rawVal?.connect_with_identity ?? '');
571+
572+
const validityPerField = {};
573+
574+
van.derive(() => {
575+
onChange({
576+
project_host: connectionHost.val,
577+
project_port: connectionPort.val,
578+
project_db: connectionDatabase.val,
579+
project_user: connectionUsername.val,
580+
project_pw_encrypted: connectionPassword.val,
581+
connect_by_url: connectByUrl.val,
582+
url: connectByUrl.val ? connectionUrl.val : connectionUrl.rawVal,
583+
connect_by_key: false,
584+
connect_with_identity: connectWithIdentity.val,
585+
}, isValid.val);
586+
});
587+
588+
van.derive(() => {
589+
const newUrlValue = (dynamicConnectionUrl.val ?? '').replace(extractPrefix(dynamicConnectionUrl.rawVal), '');
590+
if (!connectByUrl.rawVal) {
591+
connectionUrl.val = newUrlValue;
592+
}
593+
});
594+
595+
return div(
596+
{class: 'flex-column fx-gap-3 fx-flex'},
597+
div(
598+
{ class: 'flex-column border border-radius-1 p-3 mt-1 fx-gap-1', style: 'position: relative;' },
599+
Caption({content: 'Server', style: 'position: absolute; top: -10px; background: var(--app-background-color); padding: 0px 8px;' }),
600+
RadioGroup({
601+
label: 'Connect by',
602+
options: [
603+
{
604+
label: 'Host',
605+
value: false,
606+
},
607+
{
608+
label: 'URL',
609+
value: true,
610+
},
611+
],
612+
value: connectByUrl,
613+
onChange: (value) => connectByUrl.val = value,
614+
layout: 'inline',
615+
}),
616+
div(
617+
{ class: 'flex-row fx-gap-3 fx-flex' },
618+
Input({
619+
name: 'db_host',
620+
label: 'Host',
621+
value: connectionHost,
622+
class: 'fx-flex',
623+
disabled: connectByUrl,
624+
onChange: (value, state) => {
625+
connectionHost.val = value;
626+
validityPerField['db_host'] = state.valid;
627+
isValid.val = Object.values(validityPerField).every(v => v);
628+
},
629+
validators: [
630+
maxLength(250),
631+
requiredIf(() => !connectByUrl.val),
632+
],
633+
}),
634+
Input({
635+
name: 'db_port',
636+
label: 'Port',
637+
value: connectionPort,
638+
type: 'number',
639+
disabled: connectByUrl,
640+
onChange: (value, state) => {
641+
connectionPort.val = value;
642+
validityPerField['db_port'] = state.valid;
643+
isValid.val = Object.values(validityPerField).every(v => v);
644+
},
645+
validators: [
646+
minLength(3),
647+
maxLength(5),
648+
requiredIf(() => !connectByUrl.val),
649+
],
650+
})
651+
),
652+
Input({
653+
name: 'db_name',
654+
label: 'Database',
655+
value: connectionDatabase,
656+
disabled: connectByUrl,
657+
onChange: (value, state) => {
658+
connectionDatabase.val = value;
659+
validityPerField['db_name'] = state.valid;
660+
isValid.val = Object.values(validityPerField).every(v => v);
661+
},
662+
validators: [
663+
maxLength(100),
664+
requiredIf(() => !connectByUrl.val),
665+
],
666+
}),
667+
() => div(
668+
{ class: 'flex-row fx-gap-3 fx-align-stretch', style: 'position: relative;' },
669+
Input({
670+
label: 'URL',
671+
value: connectionUrl,
672+
class: 'fx-flex',
673+
name: 'url_suffix',
674+
prefix: span({ style: 'white-space: nowrap; color: var(--disabled-text-color)' }, extractPrefix(dynamicConnectionUrl.val)),
675+
disabled: !connectByUrl.val,
676+
onChange: (value, state) => {
677+
connectionUrl.val = value;
678+
validityPerField['url_suffix'] = state.valid;
679+
isValid.val = Object.values(validityPerField).every(v => v);
680+
},
681+
validators: [
682+
requiredIf(() => connectByUrl.val),
683+
],
684+
}),
685+
),
686+
),
687+
688+
div(
689+
{ class: 'flex-column border border-radius-1 p-3 mt-1 fx-gap-1', style: 'position: relative;' },
690+
Caption({content: 'Authentication', style: 'position: absolute; top: -10px; background: var(--app-background-color); padding: 0px 8px;' }),
691+
692+
RadioGroup({
693+
label: 'Connection Strategy',
694+
options: [
695+
{label: 'Connect By Password', value: false},
696+
{label: 'Connect with Managed Identity', value: true},
697+
],
698+
value: connectWithIdentity,
699+
onChange: (value) => connectWithIdentity.val = value,
700+
layout: 'inline',
701+
}),
702+
703+
() => {
704+
const _connectWithIdentity = connectWithIdentity.val;
705+
if (_connectWithIdentity) {
706+
return div(
707+
{class: 'flex-row p-4 fx-justify-center text-secondary'},
708+
'Configured Microsoft Entra ID credentials will be used',
709+
);
710+
}
711+
712+
return div(
713+
{class: 'flex-column fx-gap-1'},
714+
Input({
715+
name: 'db_user',
716+
label: 'Username',
717+
value: connectionUsername,
718+
onChange: (value, state) => {
719+
connectionUsername.val = value;
720+
validityPerField['db_user'] = state.valid;
721+
isValid.val = Object.values(validityPerField).every(v => v);
722+
},
723+
validators: [
724+
requiredIf(() => !connectWithIdentity.val),
725+
maxLength(50),
726+
],
727+
}),
728+
Input({
729+
name: 'password',
730+
label: 'Password',
731+
value: connectionPassword,
732+
type: 'password',
733+
passwordSuggestions: false,
734+
placeholder: (originalConnection?.connection_id && originalConnection?.project_pw_encrypted) ? secretsPlaceholder : '',
735+
onChange: (value, state) => {
736+
connectionPassword.val = value;
737+
validityPerField['password'] = state.valid;
738+
isValid.val = Object.values(validityPerField).every(v => v);
739+
},
740+
}),
741+
)
742+
},
743+
),
744+
);
745+
};
554746

555747
const SynapseMSSQLForm = RedshiftForm;
556748

@@ -1110,19 +1302,27 @@ const BigqueryForm = (
11101302
};
11111303

11121304
function extractPrefix(url) {
1113-
const parts = (url ?? '').split('@');
1114-
if (!parts[0]) {
1305+
if (!url) {
11151306
return '';
11161307
}
1117-
return `${parts[0]}@`;
1308+
1309+
if (url.includes('@')) {
1310+
const parts = url.split('@');
1311+
if (!parts[0]) {
1312+
return '';
1313+
}
1314+
return `${parts[0]}@`;
1315+
}
1316+
1317+
return url.slice(0, url.indexOf('://') + 3);
11181318
}
11191319

11201320
function shouldRefreshUrl(previous, current) {
11211321
if (current.connect_by_url) {
11221322
return false;
11231323
}
11241324

1125-
const fields = ['sql_flavor', 'project_host', 'project_port', 'project_db', 'project_user', 'connect_by_key', 'http_path', 'warehouse'];
1325+
const fields = ['sql_flavor', 'project_host', 'project_port', 'project_db', 'project_user', 'connect_by_key', 'http_path', 'warehouse', 'connect_with_identity'];
11261326
return fields.some((fieldName) => previous[fieldName] !== current[fieldName]);
11271327
}
11281328

testgen/ui/views/connections.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ def on_save_connection_clicked(updated_connection):
119119
elif updated_connection.get("project_pw_encrypted") == CLEAR_SENTINEL:
120120
updated_connection["project_pw_encrypted"] = ""
121121

122+
if updated_connection.get("connect_with_identity"):
123+
updated_connection["project_user"] = ""
124+
updated_connection["project_pw_encrypted"] = ""
125+
122126
updated_connection["sql_flavor"] = self._get_sql_flavor_from_value(updated_connection["sql_flavor_code"]).flavor
123127

124128
set_save(True)
@@ -143,6 +147,10 @@ def on_test_connection_clicked(updated_connection: dict) -> None:
143147
elif updated_connection.get("private_key_passphrase") == CLEAR_SENTINEL:
144148
updated_connection["private_key_passphrase"] = ""
145149

150+
if updated_connection.get("connect_with_identity"):
151+
updated_connection["project_user"] = ""
152+
updated_connection["project_pw_encrypted"] = ""
153+
146154
updated_connection["sql_flavor"] = self._get_sql_flavor_from_value(updated_connection["sql_flavor_code"]).flavor
147155

148156
set_check_status(True)

0 commit comments

Comments
 (0)