diff --git a/README.md b/README.md index 282855f5..bd63df89 100644 --- a/README.md +++ b/README.md @@ -15,29 +15,67 @@ Easiest install is to use pip: pip install dbt-sqlserver On Ubuntu make sure you have the ODBC header files before installing - - sudo apt install unixodbc-dev - -## Configure your profile -Configure your dbt profile for using SQL Server authentication or Integrated Security: -##### SQL Server authentication - type: sqlserver - driver: 'ODBC Driver 17 for SQL Server' (The ODBC Driver installed on your system) - server: server-host-name or ip - port: 1433 - user: username - password: password - database: databasename - schema: schemaname - -##### Integrated Security - type: sqlserver - driver: 'ODBC Driver 17 for SQL Server' - server: server-host-name or ip - port: 1433 - user: username - schema: schemaname - windows_login: True + +``` +sudo apt install unixodbc-dev +``` + +## Authentication +`SqlPassword` is the default connection method, but you can also use the following [`pyodbc`-supported ActiveDirectory methods](https://docs.microsoft.com/en-us/sql/connect/odbc/using-azure-active-directory?view=sql-server-ver15#new-andor-modified-dsn-and-connection-string-keywords) to authenticate: +- Integrated (i.e. Windows Login) +- ActiveDirectory Password +- ActiveDirectory Interactive +- ActiveDirectory Integrated +- ActiveDirectory MSI (to be implemented) +- Service Principal (a.k.a. AAD Application) +#### boilerplate +this should be in every target definition +``` +type: sqlserver +driver: 'ODBC Driver 17 for SQL Server' (The ODBC Driver installed on your system) +server: server-host-name or ip +port: 1433 +schema: schemaname +``` +#### SQL Server authentication +``` +user: username +password: password +``` + +#### Integrated Security +``` +windows_login: True +``` +#### ActiveDirectory Password +Definitely not ideal, but available +``` +authentication: ActiveDirectoryPassword +user: bill.gates@microsoft.com +password: i<3opensource? +``` +#### ActiveDirectory Interactive (*Windows only*) +brings up the Azure AD prompt so you can MFA if need be. +``` +authentication: ActiveDirectoryInteractive +user: bill.gates@microsoft.com +``` +##### ActiveDirectory Integrated (*Windows only*) +uses your machine's credentials (might be disabled by your AAD admins) +``` +authentication: ActiveDirectoryIntegrated +``` +##### Service Principal +`client_*` and `app_*` can be used interchangeably +``` +tenant_id: ActiveDirectoryIntegrated +client_id: clientid +client_secret: ActiveDirectoryIntegrated +``` +##### ActiveDirectory MSI (*to be implemented*) +``` +authentication: ActiveDirectoryMsi +``` ## Supported features diff --git a/dbt/adapters/sqlserver/connections.py b/dbt/adapters/sqlserver/connections.py index 9d9b0484..94c1f42a 100644 --- a/dbt/adapters/sqlserver/connections.py +++ b/dbt/adapters/sqlserver/connections.py @@ -1,11 +1,14 @@ from contextlib import contextmanager import pyodbc +import os import time +import struct import dbt.exceptions from dbt.adapters.base import Credentials from dbt.adapters.sql import SQLConnectionManager +from azure.identity import DefaultAzureCredential from dbt.logger import GLOBAL_LOGGER as logger @@ -13,6 +16,23 @@ from typing import Optional +def create_token(tenant_id, client_id, client_secret): + # bc DefaultAzureCredential will look in env variables + os.environ['AZURE_TENANT_ID'] = tenant_id + os.environ['AZURE_CLIENT_ID'] = client_id + os.environ['AZURE_CLIENT_SECRET'] = client_secret + + token = DefaultAzureCredential().get_token('https://database.windows.net//.default') + # convert to byte string interspersed with the 1-byte + # TODO decide which is cleaner? + # exptoken=b''.join([bytes({i})+bytes(1) for i in bytes(token.token, "UTF-8")]) + exptoken = bytes(1).join([bytes(i, "UTF-8") for i in token.token])+bytes(1) + # make c object with bytestring length prefix + tokenstruct = struct.pack("=i", len(exptoken)) + exptoken + + return tokenstruct + + @dataclass class SQLServerCredentials(Credentials): driver: str @@ -23,6 +43,13 @@ class SQLServerCredentials(Credentials): UID: Optional[str] = None PWD: Optional[str] = None windows_login: Optional[bool] = False + tenant_id: Optional[str] = None + client_id: Optional[str] = None + client_secret: Optional[str] = None + # "sql", "ActiveDirectoryPassword" or "ActiveDirectoryInteractive", or + # "ServicePrincipal" + authentication: Optional[str] = "sql" + encrypt: Optional[str] = "yes" _ALIASES = { 'user': 'UID' @@ -31,6 +58,9 @@ class SQLServerCredentials(Credentials): , 'password': 'PWD' , 'server': 'host' , 'trusted_connection': 'windows_login' + , 'auth': 'authentication' + , 'app_id': 'client_id' + , 'app_secret': 'client_secret' } @property @@ -40,11 +70,13 @@ def type(self): def _connection_keys(self): # return an iterator of keys to pretty-print in 'dbt debug' # raise NotImplementedError - return 'server', 'database', 'schema', 'port', 'UID', 'windows_login' + return 'server', 'database', 'schema', 'port', 'UID', \ + 'windows_login', 'authentication', 'encrypt' class SQLServerConnectionManager(SQLConnectionManager): TYPE = 'sqlserver' + TOKEN = None @contextmanager def exception_handler(self, sql): @@ -97,16 +129,55 @@ def open(cls, connection): con_str.append(f"Database={credentials.database}") - if not getattr(credentials, 'windows_login', False): - con_str.append(f"UID={credentials.UID}") - con_str.append(f"PWD={credentials.PWD}") - else: + type_auth = getattr(credentials, 'authentication', 'sql') + + if 'ActiveDirectory' in type_auth: + con_str.append(f"Authentication={credentials.authentication}") + + if type_auth == "ActiveDirectoryPassword": + con_str.append(f"UID={{{credentials.UID}}}") + con_str.append(f"PWD={{{credentials.PWD}}}") + elif type_auth == "ActiveDirectoryInteractive": + con_str.append(f"UID={{{credentials.UID}}}") + elif type_auth == "ActiveDirectoryIntegrated": + # why is this necessary??? + con_str.remove("UID={None}") + elif type_auth == "ActiveDirectoryMsi": + raise ValueError("ActiveDirectoryMsi is not supported yet") + + elif type_auth == 'ServicePrincipal': + app_id = getattr(credentials, 'AppId', None) + app_secret = getattr(credentials, 'AppSecret', None) + + elif getattr(credentials, 'windows_login', False): con_str.append(f"trusted_connection=yes") + elif type_auth == 'sql': + con_str.append("Authentication=SqlPassword") + con_str.append(f"UID={{{credentials.UID}}}") + con_str.append(f"PWD={{{credentials.PWD}}}") + + if not getattr(credentials, 'encrypt', False): + con_str.append(f"Encrypt={credentials.encrypt}") con_str_concat = ';'.join(con_str) logger.debug(f'Using connection string: {con_str_concat}') - handle = pyodbc.connect(con_str_concat, autocommit=True) + if type_auth != 'ServicePrincipal': + handle = pyodbc.connect(con_str_concat, autocommit=True) + + elif type_auth == 'ServicePrincipal': + + # create token if it does not exist + if cls.TOKEN is None: + tenant_id = getattr(credentials, 'tenant_id', None) + client_id = getattr(credentials, 'client_id', None) + client_secret = getattr(credentials, 'client_secret', None) + + cls.TOKEN = create_token(tenant_id, client_id, client_secret) + + handle = pyodbc.connect(con_str_concat, + attrs_before = {1256:cls.TOKEN}, + autocommit=True) connection.state = 'open' connection.handle = handle diff --git a/setup.py b/setup.py index f068c1c3..fbd0c7de 100644 --- a/setup.py +++ b/setup.py @@ -27,5 +27,6 @@ install_requires=[ 'dbt-core>=0.18.0', 'pyodbc>=4.0.27', + 'azure-identity>=1.4.0' ] )