diff --git a/dlt/destinations/impl/fabric/configuration.py b/dlt/destinations/impl/fabric/configuration.py index 37c34a3582..17e999324d 100644 --- a/dlt/destinations/impl/fabric/configuration.py +++ b/dlt/destinations/impl/fabric/configuration.py @@ -1,23 +1,43 @@ """Configuration for Fabric Warehouse destination - extends Synapse configuration with COPY INTO support""" from typing import Optional, Final, ClassVar, Dict, Any, List -from dlt.common.configuration import configspec +from dlt.common.configuration import configspec, NotResolved from dlt.common.configuration.specs import AzureServicePrincipalCredentials from dlt.common.destination.client import DestinationClientDwhWithStagingConfiguration -from dlt.common.exceptions import MissingDependencyException +from dlt.common.typing import TSecretStrValue, Annotated from dlt.common.utils import digest128 -from dlt import version +from dlt.destinations.impl.mssql.configuration import ( + apply_authentication_to_dsn, + build_token_attrs_before, + setup_token_credential, + validate_authentication, +) -_AZURE_STORAGE_EXTRA = f"{version.DLT_PKG_NAME}[az]" +# Fabric Warehouse only supports Entra ID authentication, so it defaults to Service Principal +# (the shared MsSql auth machinery additionally enables azure-identity token methods). +_DEFAULT_AUTHENTICATION = "ActiveDirectoryServicePrincipal" @configspec(init=False) class FabricCredentials(AzureServicePrincipalCredentials): - """Credentials for Microsoft Fabric Warehouse with Service Principal authentication. + """Credentials for Microsoft Fabric Warehouse. - Fabric Warehouse requires Azure AD Service Principal authentication. - Inherits from AzureServicePrincipalCredentials for Service Principal fields and - automatic fallback to DefaultAzureCredential. + Supports several Entra ID authentication methods, selected through `authentication`: + + * **Driver-native** (the ODBC driver authenticates): `ActiveDirectoryServicePrincipal` + (default), `ActiveDirectoryPassword`, `ActiveDirectoryIntegrated`, + `ActiveDirectoryInteractive`, `ActiveDirectoryMsi`. + * **azure-identity** (dlt acquires an access token and injects it, works cross-platform): + `ActiveDirectoryDefault` (alias `default`, uses `DefaultAzureCredential`), + `ActiveDirectoryDeviceCode` (uses `DeviceCodeCredential`). + + When `authentication` is left at its default but no Service Principal secret is configured, + dlt falls back to `ActiveDirectoryDefault` and injects its token. + + Alternatively, `access_token` or `azure_credential` can be injected directly, bypassing + `authentication` entirely (see their docstrings for precedence). + + Inherits from AzureServicePrincipalCredentials for the Service Principal fields. """ drivername: str = "mssql+pyodbc" @@ -35,43 +55,65 @@ class FabricCredentials(AzureServicePrincipalCredentials): connect_timeout: int = 15 """Connection timeout in seconds (default: 15)""" + authentication: str = _DEFAULT_AUTHENTICATION + """Authentication method. Driver-native: `ActiveDirectoryServicePrincipal` (default), + `ActiveDirectoryPassword`, `ActiveDirectoryIntegrated`, `ActiveDirectoryInteractive`, + `ActiveDirectoryMsi`. azure-identity (token injected by dlt): `ActiveDirectoryDefault` + (alias `default`), `ActiveDirectoryDeviceCode`.""" + + username: str | None = None + """User principal name, used with `ActiveDirectoryPassword` authentication.""" + + password: TSecretStrValue | None = None + """Password, used with `ActiveDirectoryPassword` authentication.""" + + access_token: Optional[TSecretStrValue] = None + """Pre-acquired Entra ID access token. When set, dlt injects it directly via `attrs_before` + without acquiring anything. Takes precedence over `azure_credential` and `authentication`.""" + + azure_credential: Annotated[Optional[Any], NotResolved()] = None + """An externally constructed `azure.core.credentials.TokenCredential` (e.g. + `DefaultAzureCredential()`) injected at runtime, not resolved from config providers. dlt + calls its `get_token()` to acquire an access token. Takes precedence over `authentication`, + but `access_token` takes precedence over this.""" + # Override to make optional - not needed for Fabric Warehouse credentials (only for staging) azure_storage_account_name: Optional[str] = None """Not used for Fabric Warehouse credentials (only staging credentials need this)""" def on_partial(self) -> None: - """Enable fallback to DefaultAzureCredential if explicit credentials not provided.""" - try: - from azure.identity import DefaultAzureCredential - except ModuleNotFoundError: - raise MissingDependencyException(self.__class__.__name__, [_AZURE_STORAGE_EXTRA]) - - # If no explicit Service Principal credentials, use default credentials - if not self.azure_client_id or not self.azure_client_secret or not self.azure_tenant_id: - self._set_default_credentials(DefaultAzureCredential()) - # Resolve if we have warehouse connection details (not storage account name) - if self.host and self.database: - self.resolve() + """Set up token-based credentials and resolve once host and database are known. + + Token-based methods (and the default Service Principal method without a secret) get an + azure-identity credential whose token is injected into the connection. Driver-native + methods need no token and resolve as-is. Auth logic is shared with `MsSqlCredentials`. + """ + setup_token_credential(self) + # Resolve if we have the warehouse connection details (not the storage account name) + if self.host and self.database: + self.resolve() + + def on_resolved(self) -> None: + """Validate the configured authentication method.""" + validate_authentication(self) def get_odbc_dsn_dict(self) -> Dict[str, Any]: """Build ODBC DSN dictionary with Fabric-specific settings.""" - params = { + params: dict[str, Any] = { "DRIVER": "{ODBC Driver 18 for SQL Server}", "SERVER": f"{self.host},{self.port}", "DATABASE": self.database, - "AUTHENTICATION": "ActiveDirectoryServicePrincipal", "LongAsMax": "yes", # Required for UTF-8 collation support "Encrypt": "yes", "TrustServerCertificate": "no", } - - # Add Service Principal credentials if provided - if self.azure_client_id and self.azure_tenant_id and self.azure_client_secret: - params["UID"] = f"{self.azure_client_id}@{self.azure_tenant_id}" - params["PWD"] = str(self.azure_client_secret) - + apply_authentication_to_dsn(self, params) return params + def to_odbc_attrs_before(self) -> dict[int, bytes] | None: + """Return pyodbc `attrs_before` with an Entra ID access token, or None for driver-native auth.""" + return build_token_attrs_before(self) + def to_odbc_dsn(self) -> str: """Build ODBC connection string for pyodbc.""" params = self.get_odbc_dsn_dict() diff --git a/dlt/destinations/impl/fabric/fabric.py b/dlt/destinations/impl/fabric/fabric.py index c0e4332ffe..685adebbff 100644 --- a/dlt/destinations/impl/fabric/fabric.py +++ b/dlt/destinations/impl/fabric/fabric.py @@ -107,6 +107,12 @@ def _ensure_fabric_token_initialized( if cache_key in self._token_initialized_cache: return + if not credentials.azure_client_secret: + # No Service Principal secret configured: ClientSecretCredential would raise before + # any data moves. Skip the proactive Fabric token initialization rather than fail the + # whole load; COPY INTO still works as long as the staging credential is otherwise valid. + return + try: import requests from azure.identity import ClientSecretCredential diff --git a/dlt/destinations/impl/fabric/sql_client.py b/dlt/destinations/impl/fabric/sql_client.py index c7309eab95..4fbfeac40a 100644 --- a/dlt/destinations/impl/fabric/sql_client.py +++ b/dlt/destinations/impl/fabric/sql_client.py @@ -13,6 +13,8 @@ class FabricSqlClient(SynapseSqlClient): """SQL client for Microsoft Fabric Warehouse Inherits all behavior from Synapse since Fabric Warehouse is built on Synapse technology. + Azure AD token injection (for token-based authentication) is handled by the shared + `PyOdbcMsSqlClient.open_connection`. """ def __init__( diff --git a/dlt/destinations/impl/mssql/configuration.py b/dlt/destinations/impl/mssql/configuration.py index b36a690f30..4ddc8f3831 100644 --- a/dlt/destinations/impl/mssql/configuration.py +++ b/dlt/destinations/impl/mssql/configuration.py @@ -1,14 +1,218 @@ import dataclasses -from typing import ClassVar, Any, Final, List, Dict, Optional +import struct +from typing import ClassVar, Any, Final, List, Dict, Optional, TYPE_CHECKING -from dlt.common.configuration import configspec -from dlt.common.configuration.specs import ConnectionStringCredentials -from dlt.common.typing import TSecretStrValue -from dlt.common.exceptions import SystemConfigurationException +from dlt import version +from dlt.common.configuration import configspec, NotResolved +from dlt.common.configuration.exceptions import ConfigurationException +from dlt.common.configuration.specs import ConnectionStringCredentials, CredentialsWithDefault +from dlt.common.typing import TSecretStrValue, Annotated +from dlt.common.exceptions import MissingDependencyException, SystemConfigurationException from dlt.common.destination.client import DestinationClientDwhWithStagingConfiguration from dlt.common.utils import digest128 +if TYPE_CHECKING: + from azure.core.credentials import TokenCredential + +_AZURE_AUTH_EXTRA = f"{version.DLT_PKG_NAME}[az]" + +# pyodbc connection attribute used to inject a pre-acquired Entra ID access token. +# https://learn.microsoft.com/sql/connect/odbc/using-azure-active-directory#authenticating-with-an-access-token +SQL_COPT_SS_ACCESS_TOKEN = 1256 +SQL_TOKEN_SCOPE = "https://database.windows.net/.default" + +# Authentication methods the ODBC driver performs natively (passed as `Authentication=` in the +# DSN). Poolable: the driver signs in itself, so no token needs to be reacquired and injected +# per connection. +DRIVER_NATIVE_AUTHENTICATION = frozenset( + { + "ActiveDirectoryServicePrincipal", + "ActiveDirectoryPassword", + "ActiveDirectoryIntegrated", + "ActiveDirectoryInteractive", + "ActiveDirectoryMsi", + } +) + +# Authentication methods pyodbc/msodbcsql does not support natively. dlt acquires the access +# token itself with azure-identity and injects it into the connection (`attrs_before`), so these +# work cross-platform but are not poolable. +AZURE_IDENTITY_INJECTION_AUTHENTICATION = frozenset( + { + "ActiveDirectoryDefault", + "ActiveDirectoryDeviceCode", + } +) + +# Thin alias for `ActiveDirectoryDefault`, resolved by `_normalize_authentication`. +_AUTHENTICATION_ALIASES = { + "default": "ActiveDirectoryDefault", +} + + +def _normalize_authentication(authentication: str) -> str: + """Resolve the thin `default` alias to the canonical `ActiveDirectoryDefault` name.""" + return _AUTHENTICATION_ALIASES.get(authentication.lower(), authentication) + + +def create_token_credential(authentication: str) -> "TokenCredential": + """Create an azure-identity credential for a token-injection authentication method. + + Expects the canonical name (`ActiveDirectoryDefault` or `ActiveDirectoryDeviceCode`), as + already resolved by `_normalize_authentication`. + """ + try: + from azure.identity import DefaultAzureCredential, DeviceCodeCredential + except ModuleNotFoundError: + raise MissingDependencyException("MsSqlCredentials", [_AZURE_AUTH_EXTRA]) + + credential_factories = { + "ActiveDirectoryDefault": DefaultAzureCredential, + "ActiveDirectoryDeviceCode": DeviceCodeCredential, + } + return credential_factories[authentication]() # type: ignore[no-any-return] + + +def uses_token_authentication(credentials: Any) -> bool: + """True when dlt must acquire an access token and inject it into the connection. + + `access_token`/`azure_credential` take precedence over `authentication`: when either is + set, dlt injects that token directly regardless of the configured method. Otherwise derived + from the configured method, not from `has_default_credentials`: cooperative `on_partial` + calls up the MRO may set a default credential even for driver-native methods. + """ + if credentials.access_token or credentials.azure_credential: + return True + authentication = credentials.authentication + if not authentication: + return False + if _normalize_authentication(authentication) in AZURE_IDENTITY_INJECTION_AUTHENTICATION: + return True + # ActiveDirectoryServicePrincipal without a secret cannot authenticate through the ODBC + # driver, so dlt falls back to a DefaultAzureCredential token. + return authentication == "ActiveDirectoryServicePrincipal" and not ( + credentials.azure_client_id + and credentials.azure_client_secret + and credentials.azure_tenant_id + ) + + +def setup_token_credential(credentials: Any) -> None: + """Create and store the azure-identity credential for token-injection authentication.""" + if credentials.access_token or credentials.azure_credential: + # The token (or a credential able to fetch one) was injected directly: no + # DefaultAzureCredential fallback is needed. + return + authentication = credentials.authentication or "" + normalized = _normalize_authentication(authentication) + if normalized in AZURE_IDENTITY_INJECTION_AUTHENTICATION: + credentials._set_default_credentials(create_token_credential(normalized)) + elif authentication == "ActiveDirectoryServicePrincipal" and not ( + credentials.azure_client_id + and credentials.azure_client_secret + and credentials.azure_tenant_id + ): + credentials._set_default_credentials(create_token_credential("ActiveDirectoryDefault")) + + +def get_token_credential(credentials: Any) -> "TokenCredential": + """Return the azure-identity credential used for token authentication. + + Reuses the credential created during resolution (so azure-identity can cache tokens + across connections) and creates one on demand otherwise. + """ + if credentials.has_default_credentials(): + return credentials.default_credentials() # type: ignore[no-any-return] + authentication = _normalize_authentication(credentials.authentication or "") + if authentication not in AZURE_IDENTITY_INJECTION_AUTHENTICATION: + authentication = "ActiveDirectoryDefault" + return create_token_credential(authentication) + + +def get_access_token(credentials: Any) -> Optional[str]: + """Return a directly usable Entra ID access token, bypassing `authentication` resolution. + + Prefers a pre-fetched `access_token`, then a token fetched from an injected + `azure_credential`. Returns None when neither is set, so callers fall back to the + `authentication` named-method resolution. + """ + if credentials.access_token: + return str(credentials.access_token) + if credentials.azure_credential: + return credentials.azure_credential.get_token(SQL_TOKEN_SCOPE).token # type: ignore[no-any-return] + return None + + +def build_token_attrs_before(credentials: Any) -> dict[int, bytes] | None: + """Return pyodbc `attrs_before` with an Entra ID access token, or None for driver-native auth.""" + token = get_access_token(credentials) + if token is None: + if not uses_token_authentication(credentials): + return None + token = get_token_credential(credentials).get_token(SQL_TOKEN_SCOPE).token + encoded_token = token.encode("utf-16-le") + token_struct = struct.pack(f" None: + """Validate the configured authentication method.""" + if credentials.access_token or credentials.azure_credential: + # A token (or a credential able to fetch one) was injected directly: it takes + # precedence over `authentication`, whose value does not need to be validated. + return + authentication = credentials.authentication + if not authentication: + return # plain SQL login (username/password) + normalized = _normalize_authentication(authentication) + if ( + normalized not in DRIVER_NATIVE_AUTHENTICATION + and normalized not in AZURE_IDENTITY_INJECTION_AUTHENTICATION + ): + supported = sorted(DRIVER_NATIVE_AUTHENTICATION) + sorted( + AZURE_IDENTITY_INJECTION_AUTHENTICATION + ) + raise ConfigurationException( + f"Unsupported `authentication` method `{authentication}`." + f" Supported methods: {', '.join(supported)}." + ) + if authentication == "ActiveDirectoryPassword" and not ( + credentials.username and credentials.password + ): + raise ConfigurationException( + "`authentication = ActiveDirectoryPassword` requires `username` and `password`." + ) + + +def apply_authentication_to_dsn(credentials: Any, params: dict[str, Any]) -> None: + """Add UID/PWD/Authentication keys to an ODBC DSN dict based on the authentication method.""" + if uses_token_authentication(credentials): + # The access token is injected via attrs_before, so the DSN carries no credentials. + return + authentication = credentials.authentication + if not authentication: + # Plain SQL login. + params["UID"] = credentials.username + params["PWD"] = credentials.password + return + params["AUTHENTICATION"] = authentication + if ( + authentication == "ActiveDirectoryServicePrincipal" + and credentials.azure_client_id + and credentials.azure_tenant_id + and credentials.azure_client_secret + ): + params["UID"] = f"{credentials.azure_client_id}@{credentials.azure_tenant_id}" + params["PWD"] = str(credentials.azure_client_secret) + elif ( + authentication == "ActiveDirectoryPassword" + and credentials.username + and credentials.password + ): + params["UID"] = credentials.username + params["PWD"] = credentials.password + def escape_mssql_odbc_value(value: Optional[str]) -> str: """Escape a value for MSSQL ADO/ODBC connection string format. @@ -50,7 +254,7 @@ def build_odbc_dsn(params: Dict[str, Any]) -> str: @configspec(init=False) -class MsSqlCredentials(ConnectionStringCredentials): +class MsSqlCredentials(ConnectionStringCredentials, CredentialsWithDefault): drivername: Final[str] = dataclasses.field(default="mssql", init=False, repr=False, compare=False) # type: ignore[misc] database: str = None username: str = None @@ -60,6 +264,33 @@ class MsSqlCredentials(ConnectionStringCredentials): connect_timeout: int = 30 driver: str = None + authentication: str | None = None + """Authentication method. Empty (default) uses plain SQL login (`username`/`password`). + Driver-native (passed as `Authentication=` in the DSN, poolable): + `ActiveDirectoryServicePrincipal`, `ActiveDirectoryPassword`, `ActiveDirectoryIntegrated`, + `ActiveDirectoryInteractive`, `ActiveDirectoryMsi`. azure-identity (token acquired and + injected by dlt, not poolable): `ActiveDirectoryDefault` (alias `default`), + `ActiveDirectoryDeviceCode`.""" + + azure_tenant_id: str | None = None + """Entra ID tenant id, used with `ActiveDirectoryServicePrincipal` authentication.""" + + azure_client_id: str | None = None + """Service Principal client id, used with `ActiveDirectoryServicePrincipal` authentication.""" + + azure_client_secret: TSecretStrValue | None = None + """Service Principal client secret, used with `ActiveDirectoryServicePrincipal` authentication.""" + + access_token: Optional[TSecretStrValue] = None + """Pre-acquired Entra ID access token. When set, dlt injects it directly via `attrs_before` + without acquiring anything. Takes precedence over `azure_credential` and `authentication`.""" + + azure_credential: Annotated[Optional[Any], NotResolved()] = None + """An externally constructed `azure.core.credentials.TokenCredential` (e.g. + `DefaultAzureCredential()`) injected at runtime, not resolved from config providers. dlt + calls its `get_token()` to acquire an access token. Takes precedence over `authentication`, + but `access_token` takes precedence over this.""" + __config_gen_annotations__: ClassVar[List[str]] = ["port", "connect_timeout"] SUPPORTED_DRIVERS: ClassVar[List[str]] = [ @@ -76,6 +307,7 @@ def parse_native_representation(self, native_value: Any) -> None: self.connect_timeout = int(self.query.get("connect_timeout", self.connect_timeout)) def on_resolved(self) -> None: + validate_authentication(self) if self.driver not in self.SUPPORTED_DRIVERS: raise SystemConfigurationException( f"The specified driver `{self.driver}` is not supported." @@ -90,7 +322,14 @@ def get_query(self) -> Dict[str, Any]: def on_partial(self) -> None: self.driver = self._get_driver() - if not self.is_partial(): + setup_token_credential(self) + if self.authentication or self.access_token or self.azure_credential: + # Entra ID methods (token or driver-native) supply their own credentials and do not + # rely on username/password; resolve once we have a target. `on_resolved` validates. + if self.host and self.database: + self.resolve() + elif not self.is_partial(): + # Plain SQL login needs username/password. self.resolve() def _get_driver(self) -> str: @@ -111,13 +350,12 @@ def _get_driver(self) -> str: ) def get_odbc_dsn_dict(self) -> Dict[str, Any]: - params = { + params: dict[str, Any] = { "DRIVER": self.driver, "SERVER": f"{self.host},{self.port}", "DATABASE": self.database, - "UID": self.username, - "PWD": self.password, } + apply_authentication_to_dsn(self, params) if self.query is not None: params.update({k.upper(): v for k, v in self.query.items()}) return params @@ -126,6 +364,10 @@ def to_odbc_dsn(self) -> str: params = self.get_odbc_dsn_dict() return build_odbc_dsn(params) + def to_odbc_attrs_before(self) -> dict[int, bytes] | None: + """Return pyodbc `attrs_before` with an Entra ID access token, or None for driver-native auth.""" + return build_token_attrs_before(self) + @configspec class MsSqlClientConfiguration(DestinationClientDwhWithStagingConfiguration): diff --git a/dlt/destinations/impl/mssql/sql_client.py b/dlt/destinations/impl/mssql/sql_client.py index c06122a802..ab5d9e6fdd 100644 --- a/dlt/destinations/impl/mssql/sql_client.py +++ b/dlt/destinations/impl/mssql/sql_client.py @@ -55,10 +55,19 @@ def __init__( self.credentials = credentials def open_connection(self) -> pyodbc.Connection: - self._conn = pyodbc.connect( - self.credentials.to_odbc_dsn(), - timeout=self.credentials.connect_timeout, - ) + # For Entra ID token authentication, inject a fresh access token via attrs_before. + attrs_before = self.credentials.to_odbc_attrs_before() + if attrs_before: + self._conn = pyodbc.connect( + self.credentials.to_odbc_dsn(), + timeout=self.credentials.connect_timeout, + attrs_before=attrs_before, + ) + else: + self._conn = pyodbc.connect( + self.credentials.to_odbc_dsn(), + timeout=self.credentials.connect_timeout, + ) # https://github.com/mkleehammer/pyodbc/wiki/Using-an-Output-Converter-function self._conn.add_output_converter(-155, handle_datetimeoffset) self._conn.autocommit = True diff --git a/docs/website/docs/dlt-ecosystem/destinations/fabric.md b/docs/website/docs/dlt-ecosystem/destinations/fabric.md index 48cf16c7c4..3457ac13bf 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/fabric.md +++ b/docs/website/docs/dlt-ecosystem/destinations/fabric.md @@ -29,21 +29,41 @@ Supported driver versions: You can also [configure the driver name](#additional-destination-options) explicitly. -### Service Principal Authentication +### Authentication -Fabric Warehouse requires Azure Active Directory Service Principal authentication. You'll need: - -1. **Tenant ID**: Your Azure AD tenant ID (GUID) -2. **Client ID**: Application (service principal) client ID (GUID) -3. **Client Secret**: Application client secret -4. **Host**: Your Fabric warehouse SQL endpoint -5. **Database**: The database name within your warehouse +Fabric Warehouse authenticates with Microsoft Entra ID. Whichever method you choose, you always set the warehouse SQL endpoint as the `host` (`.datawarehouse.fabric.microsoft.com`) and the warehouse name as the `database`. **Finding your SQL endpoint:** - In the Fabric portal, go to your warehouse **Settings** - Select **SQL endpoint** - Copy the **SQL connection string** - it should be in the format: `.datawarehouse.fabric.microsoft.com` +The authentication method is selected with the `authentication` credential option. `dlt` supports +two families of methods. + +With the **driver-native** methods, the ODBC driver performs the Entra ID sign-in: + +| `authentication` | Description | Required fields | +|---|---|---| +| `ActiveDirectoryServicePrincipal` (default) | Service Principal | `azure_tenant_id`, `azure_client_id`, `azure_client_secret` | +| `ActiveDirectoryPassword` | Entra ID username/password | `username`, `password` | +| `ActiveDirectoryIntegrated` | Integrated Windows authentication | None | +| `ActiveDirectoryInteractive` | Interactive browser prompt (driver) | None | +| `ActiveDirectoryMsi` | Managed identity (driver) | None | + +With the **azure-identity** methods, `dlt` acquires an access token with +[azure-identity](https://learn.microsoft.com/python/api/overview/azure/identity-readme) and injects +it into the connection. These work cross-platform (including macOS, where the ODBC driver's built-in +Entra ID modes are unreliable) and need no secret in `secrets.toml`: + +| `authentication` | azure-identity credential | +|---|---| +| `ActiveDirectoryDefault` (alias `default`) | `DefaultAzureCredential` (managed identity, environment, Azure CLI, …) | +| `ActiveDirectoryDeviceCode` | `DeviceCodeCredential` | + +When `authentication` is left at its default but no Service Principal secret is configured, `dlt` +falls back to `ActiveDirectoryDefault` (`DefaultAzureCredential`). + ### Create a pipeline **1. Initialize a project with a pipeline that loads to Fabric by running:** @@ -62,6 +82,8 @@ pip install "dlt[fabric]" **3. Enter your credentials into `.dlt/secrets.toml`.** +Service Principal (default): + ```toml [destination.fabric.credentials] host = ".datawarehouse.fabric.microsoft.com" @@ -73,6 +95,15 @@ port = 1433 connect_timeout = 30 ``` +azure-identity, e.g. `DefaultAzureCredential` after `az login`, which needs no secret: + +```toml +[destination.fabric.credentials] +host = ".datawarehouse.fabric.microsoft.com" +database = "mydb" +authentication = "default" +``` + ## Write disposition All write dispositions are supported, including the [`upsert`](../../general-usage/merge-loading.md#upsert-strategy) and [`insert-only`](../../general-usage/merge-loading.md#insert-only-strategy) merge strategies. @@ -205,7 +236,7 @@ driver="ODBC Driver 18 for SQL Server" While Fabric Warehouse is based on SQL Server, there are key differences: -1. **Authentication**: Fabric requires Service Principal; username/password auth is not supported +1. **Authentication**: Fabric uses Entra ID; in addition to Service Principal, `dlt` supports several azure-identity methods (see [Authentication](#authentication)) 2. **Type System**: Uses `varchar` and `datetime2` instead of `nvarchar` and `datetimeoffset` 3. **Collation**: Optimized for UTF-8 collations with automatic `LongAsMax` configuration 4. **SQL Dialect**: Uses `fabric` SQLglot dialect for proper SQL generation @@ -232,7 +263,7 @@ sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 Ensure your Service Principal has: - Proper permissions on the Fabric workspace - Access to the target database/warehouse -- Correct tenant ID (your Azure AD tenant, not the workspace/capacity ID) +- Correct tenant ID (your Entra ID tenant, not the workspace/capacity ID) ### UTF-8 Character Issues diff --git a/docs/website/docs/dlt-ecosystem/destinations/mssql.md b/docs/website/docs/dlt-ecosystem/destinations/mssql.md index 82aa70c86a..5c352fa3cc 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/mssql.md +++ b/docs/website/docs/dlt-ecosystem/destinations/mssql.md @@ -97,6 +97,48 @@ destination.mssql.credentials="mssql://loader:loader@localhost/dlt_data?TrustSer destination.mssql.credentials="mssql://loader:loader@localhost/dlt_data?LongAsMax=yes" ``` +### Microsoft Entra ID authentication + +For Azure-hosted SQL Server (Azure SQL Database, Managed Instance) you can authenticate with +Entra ID instead of a SQL login. Set the `authentication` credential option. + +With the **azure-identity** methods, `dlt` acquires an access token and injects it into the +connection, so they work cross-platform (including macOS) and need no password in `secrets.toml`. +They require the `azure-identity` package (installed with `pip install "dlt[az]"`). + +| `authentication` | How it authenticates | +|---|---| +| _(empty, default)_ | SQL login with `username`/`password` | +| `ActiveDirectoryServicePrincipal` | Service Principal (`azure_tenant_id`, `azure_client_id`, `azure_client_secret`), handled by the ODBC driver | +| `ActiveDirectoryPassword` | Entra ID `username`/`password` (handled by the ODBC driver) | +| `ActiveDirectoryIntegrated` | Integrated Windows authentication (handled by the ODBC driver) | +| `ActiveDirectoryInteractive` | Interactive prompt (handled by the ODBC driver) | +| `ActiveDirectoryMsi` | Managed identity (handled by the ODBC driver) | +| `ActiveDirectoryDefault` (alias `default`) | `DefaultAzureCredential` (managed identity, environment, Azure CLI, …), token injected by dlt | +| `ActiveDirectoryDeviceCode` | `DeviceCodeCredential`, token injected by dlt | + +Passwordless example using `DefaultAzureCredential` (e.g. after `az login`): +```toml +[destination.mssql.credentials] +database = "dlt_data" +host = "loader.database.windows.net" +authentication = "default" +``` + +Service Principal example: +```toml +[destination.mssql.credentials] +database = "dlt_data" +host = "loader.database.windows.net" +authentication = "ActiveDirectoryServicePrincipal" +azure_tenant_id = "your-tenant-id" +azure_client_id = "your-client-id" +azure_client_secret = "your-client-secret" +``` + +When `authentication` is left empty but no `password` is set, `dlt` falls back to +`DefaultAzureCredential`. + **To pass credentials directly**, use the [explicit instance of the destination](../../general-usage/destination.md#pass-explicit-credentials) ```py pipeline = dlt.pipeline( diff --git a/docs/website/docs/dlt-ecosystem/destinations/synapse.md b/docs/website/docs/dlt-ecosystem/destinations/synapse.md index 08e14eb094..70dd6841ed 100644 --- a/docs/website/docs/dlt-ecosystem/destinations/synapse.md +++ b/docs/website/docs/dlt-ecosystem/destinations/synapse.md @@ -97,6 +97,11 @@ pipeline = dlt.pipeline( dataset_name='chess_data' ) ``` +**Microsoft Entra ID authentication** is supported through the same `authentication` credential +option as the [mssql destination](./mssql.md#microsoft-entra-id-authentication), including the +passwordless azure-identity methods (`ActiveDirectoryDefault`, `ActiveDirectoryDeviceCode`) that +inject an access token. + To use **Active Directory Principal**, you can use the `sqlalchemy.engine.URL.create` method to create the connection URL using your Active Directory Service Principal credentials. First, create the connection string as: ```py conn_str = ( diff --git a/tests/load/fabric/test_fabric_configuration.py b/tests/load/fabric/test_fabric_configuration.py index e69c71bde7..8c92dcf276 100644 --- a/tests/load/fabric/test_fabric_configuration.py +++ b/tests/load/fabric/test_fabric_configuration.py @@ -1,11 +1,13 @@ """Tests for Microsoft Fabric Warehouse destination configuration""" import os +import struct from typing import Optional import pytest from dlt.common.configuration import resolve_configuration +from dlt.common.configuration.exceptions import ConfigurationException from dlt.common.schema import Schema from dlt.common.utils import digest128 from dlt.destinations.impl.fabric.factory import fabric @@ -13,6 +15,7 @@ FabricCredentials, FabricClientConfiguration, ) +from dlt.destinations.impl.mssql.configuration import get_access_token, uses_token_authentication # mark all tests as essential, do not remove pytestmark = pytest.mark.essential @@ -221,3 +224,240 @@ def test_fabric_credentials_authentication_method() -> None: # Verify ActiveDirectoryServicePrincipal is set dsn_dict = creds.get_odbc_dsn_dict() assert dsn_dict["AUTHENTICATION"] == "ActiveDirectoryServicePrincipal" + + +# --------------------------------------------------------------------------- +# Authentication methods +# --------------------------------------------------------------------------- + + +class _FakeAccessToken: + token = "fake-access-token" + + +class _FakeTokenCredential: + """Minimal azure-identity-like credential, avoids hitting Azure in unit tests.""" + + def get_token(self, *scopes: str, **kwargs: object) -> _FakeAccessToken: + return _FakeAccessToken() + + +def _warehouse_credentials( + authentication: str | None = None, **kwargs: object +) -> FabricCredentials: + creds = FabricCredentials() + creds.host = "test.datawarehouse.fabric.microsoft.com" + creds.database = "testdb" + if authentication is not None: + creds.authentication = authentication + for key, value in kwargs.items(): + setattr(creds, key, value) + return creds + + +def test_fabric_authentication_default_is_service_principal() -> None: + assert FabricCredentials().authentication == "ActiveDirectoryServicePrincipal" + + +@pytest.mark.parametrize( + "authentication,expected", + [ + ("default", "DefaultAzureCredential"), + ("ActiveDirectoryDefault", "DefaultAzureCredential"), + ("ActiveDirectoryDeviceCode", "DeviceCodeCredential"), + ], +) +def test_fabric_azure_identity_credential_mapping(authentication: str, expected: str) -> None: + """Each azure-identity method maps to the right credential and skips the DSN AUTHENTICATION.""" + creds = _warehouse_credentials(authentication) + creds.on_partial() + + assert type(creds.default_credentials()).__name__ == expected + + dsn = creds.get_odbc_dsn_dict() + assert "AUTHENTICATION" not in dsn + assert "UID" not in dsn + assert "PWD" not in dsn + assert dsn["LongAsMax"] == "yes" + + +@pytest.mark.parametrize( + "authentication", + ["auto", "cli", "environment", "interactive", "devicecode", "msi", "managedidentity"], +) +def test_fabric_removed_dlt_custom_alias_raises(authentication: str) -> None: + """The old dlt-custom lowercase aliases were replaced by native ODBC/azure-identity names.""" + creds = _warehouse_credentials(authentication) + with pytest.raises(ConfigurationException): + creds.on_partial() # resolves (host+database present) -> on_resolved -> validate raises + + +def test_fabric_service_principal_without_secret_falls_back_to_token() -> None: + """Default method without a Service Principal secret injects a DefaultAzureCredential token.""" + creds = _warehouse_credentials() + creds.on_partial() + + assert uses_token_authentication(creds) is True + assert type(creds.default_credentials()).__name__ == "DefaultAzureCredential" + assert "AUTHENTICATION" not in creds.get_odbc_dsn_dict() + + +def test_fabric_service_principal_with_secret_is_driver_native() -> None: + """Default method with a Service Principal secret authenticates through the ODBC driver.""" + creds = _warehouse_credentials( + azure_tenant_id="t", azure_client_id="c", azure_client_secret="s" + ) + creds.on_partial() + + assert uses_token_authentication(creds) is False + dsn = creds.get_odbc_dsn_dict() + assert dsn["AUTHENTICATION"] == "ActiveDirectoryServicePrincipal" + assert dsn["UID"] == "c@t" + assert dsn["PWD"] == "s" + assert creds.to_odbc_attrs_before() is None + + +@pytest.mark.parametrize( + "authentication", + ["ActiveDirectoryIntegrated", "ActiveDirectoryInteractive", "ActiveDirectoryMsi"], +) +def test_fabric_driver_native_passthrough(authentication: str) -> None: + creds = _warehouse_credentials(authentication) + creds.on_partial() + + dsn = creds.get_odbc_dsn_dict() + assert dsn["AUTHENTICATION"] == authentication + assert "UID" not in dsn + assert "PWD" not in dsn + assert creds.to_odbc_attrs_before() is None + + +def test_fabric_active_directory_password() -> None: + creds = _warehouse_credentials( + "ActiveDirectoryPassword", username="user@contoso.com", password="pwd" + ) + creds.on_partial() + + dsn = creds.get_odbc_dsn_dict() + assert dsn["AUTHENTICATION"] == "ActiveDirectoryPassword" + assert dsn["UID"] == "user@contoso.com" + assert dsn["PWD"] == "pwd" + assert creds.to_odbc_attrs_before() is None + + +def test_fabric_active_directory_password_requires_username_password() -> None: + creds = _warehouse_credentials("ActiveDirectoryPassword") + with pytest.raises(ConfigurationException): + creds.on_partial() # on_partial -> resolve() -> on_resolved validates + + +def test_fabric_unsupported_authentication_raises() -> None: + creds = _warehouse_credentials("SqlPassword") + with pytest.raises(ConfigurationException): + creds.on_partial() + + +def test_fabric_to_odbc_attrs_before_token_struct() -> None: + """The injected token follows the SQL_COPT_SS_ACCESS_TOKEN struct layout.""" + creds = _warehouse_credentials("default") + creds._set_default_credentials(_FakeTokenCredential()) + + attrs = creds.to_odbc_attrs_before() + assert attrs is not None + token_struct = attrs[1256] # SQL_COPT_SS_ACCESS_TOKEN + length = struct.unpack(" None: + """Resolution succeeds without a Service Principal secret for token authentication.""" + creds = FabricCredentials() + creds.host = "abc.datawarehouse.fabric.microsoft.com" + creds.database = "mydb" + creds.authentication = "ActiveDirectoryDeviceCode" + + resolved = resolve_configuration(creds) + + assert resolved.is_resolved() + assert uses_token_authentication(resolved) is True + assert type(resolved.default_credentials()).__name__ == "DeviceCodeCredential" + assert "AUTHENTICATION" not in resolved.get_odbc_dsn_dict() + + +# --------------------------------------------------------------------------- +# Injectable access_token / azure_credential (precedence over `authentication`) +# --------------------------------------------------------------------------- + + +class _RaisingTokenCredential: + """A TokenCredential whose `get_token` must never be called (used to prove precedence).""" + + def get_token(self, *scopes: str, **kwargs: object) -> _FakeAccessToken: + raise AssertionError("azure_credential.get_token() should not have been called") + + +def test_fabric_access_token_and_azure_credential_default_to_none() -> None: + creds = FabricCredentials() + assert creds.access_token is None + assert creds.azure_credential is None + + +def test_fabric_get_access_token_uses_azure_credential() -> None: + creds = _warehouse_credentials(azure_credential=_FakeTokenCredential()) + assert get_access_token(creds) == "fake-access-token" + + +def test_fabric_get_access_token_prefers_access_token_over_azure_credential() -> None: + creds = _warehouse_credentials( + access_token="explicit-token", azure_credential=_RaisingTokenCredential() + ) + assert get_access_token(creds) == "explicit-token" + + +def test_fabric_access_token_takes_precedence_over_default_service_principal() -> None: + """access_token bypasses the default ActiveDirectoryServicePrincipal authentication entirely, + even though that method is on by default for Fabric and no Service Principal secret is set.""" + creds = _warehouse_credentials(access_token="explicit-token") + creds.on_partial() + + assert uses_token_authentication(creds) is True + dsn = creds.get_odbc_dsn_dict() + assert "AUTHENTICATION" not in dsn + assert "UID" not in dsn + assert "PWD" not in dsn + + attrs = creds.to_odbc_attrs_before() + assert attrs is not None + assert attrs[1256][4:].decode("utf-16-le") == "explicit-token" + + +def test_fabric_azure_credential_takes_precedence_over_authentication() -> None: + creds = _warehouse_credentials( + "ActiveDirectoryDeviceCode", azure_credential=_FakeTokenCredential() + ) + creds.on_partial() + + # setup_token_credential must skip the azure-identity DefaultAzureCredential machinery + assert creds.has_default_credentials() is False + assert uses_token_authentication(creds) is True + + dsn = creds.get_odbc_dsn_dict() + assert "AUTHENTICATION" not in dsn + + attrs = creds.to_odbc_attrs_before() + assert attrs is not None + assert attrs[1256][4:].decode("utf-16-le") == "fake-access-token" + + +def test_fabric_resolve_configuration_access_token_without_service_principal() -> None: + creds = FabricCredentials() + creds.host = "abc.datawarehouse.fabric.microsoft.com" + creds.database = "mydb" + creds.access_token = "explicit-token" + + resolved = resolve_configuration(creds) + + assert resolved.is_resolved() + assert uses_token_authentication(resolved) is True + assert "AUTHENTICATION" not in resolved.get_odbc_dsn_dict() diff --git a/tests/load/mssql/docker-compose.yml b/tests/load/mssql/docker-compose.yml new file mode 100644 index 0000000000..44659559e4 --- /dev/null +++ b/tests/load/mssql/docker-compose.yml @@ -0,0 +1,18 @@ +# Local SQL Server for mssql destination integration tests. +# Start with `docker compose up`; tests skip automatically when the server is unreachable. +# +# Uses SQL Server 2025, which is required because the destination maps the dlt `json` type to +# the native `json` column type that older SQL Server versions do not support. +# On Apple Silicon, enable Rosetta in Docker Desktop so the amd64 image runs under emulation. +services: + mssql: + image: mcr.microsoft.com/mssql/server:2025-latest + container_name: dlt_test_mssql + restart: on-failure + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "Strong!Passw0rd" + MSSQL_PID: "Developer" + ports: + - 1433:1433 + # (this is just an example, not intended to be a production configuration) diff --git a/tests/load/mssql/test_mssql_configuration.py b/tests/load/mssql/test_mssql_configuration.py index d261391edc..f9836524b3 100644 --- a/tests/load/mssql/test_mssql_configuration.py +++ b/tests/load/mssql/test_mssql_configuration.py @@ -1,14 +1,22 @@ import os +import struct import pyodbc import pytest from dlt.common.configuration import ConfigFieldMissingException, resolve_configuration +from dlt.common.configuration.exceptions import ConfigurationException from dlt.common.exceptions import SystemConfigurationException from dlt.common.schema import Schema from dlt.common.utils import digest128 from dlt.destinations import mssql -from dlt.destinations.impl.mssql.configuration import MsSqlClientConfiguration, MsSqlCredentials +from dlt.destinations.impl.mssql.configuration import ( + MsSqlClientConfiguration, + MsSqlCredentials, + get_access_token, + uses_token_authentication, + validate_authentication, +) # mark all tests as essential, do not remove pytestmark = pytest.mark.essential @@ -208,3 +216,270 @@ def test_to_odbc_dsn_driver_not_specified() -> None: } for d in MsSqlCredentials.SUPPORTED_DRIVERS ] + + +# --------------------------------------------------------------------------- +# Authentication methods +# --------------------------------------------------------------------------- + + +class _FakeAccessToken: + token = "fake-access-token" + + +class _FakeTokenCredential: + """Minimal azure-identity-like credential, avoids hitting Azure in unit tests.""" + + def get_token(self, *scopes: str, **kwargs: object) -> _FakeAccessToken: + return _FakeAccessToken() + + +def _mssql_credentials(authentication: object = None, **kwargs: object) -> MsSqlCredentials: + creds = MsSqlCredentials() + creds.host = "sql.example.com" + creds.database = "test_db" + creds.driver = "ODBC Driver 18 for SQL Server" # avoid probing for an installed driver + if authentication is not None: + creds.authentication = authentication # type: ignore[assignment] + for key, value in kwargs.items(): + setattr(creds, key, value) + return creds + + +def test_mssql_authentication_defaults_to_sql_login() -> None: + assert MsSqlCredentials().authentication is None + + +def test_mssql_sql_login_dsn_uses_uid_pwd() -> None: + creds = _mssql_credentials(username="loader", password="secret") + creds.on_partial() + + dsn = creds.get_odbc_dsn_dict() + assert "AUTHENTICATION" not in dsn + assert dsn["UID"] == "loader" + assert dsn["PWD"] == "secret" + assert creds.to_odbc_attrs_before() is None + + +@pytest.mark.parametrize( + "authentication,expected", + [ + ("default", "DefaultAzureCredential"), + ("ActiveDirectoryDefault", "DefaultAzureCredential"), + ("ActiveDirectoryDeviceCode", "DeviceCodeCredential"), + ], +) +def test_mssql_azure_identity_credential_mapping(authentication: str, expected: str) -> None: + creds = _mssql_credentials(authentication) + creds.on_partial() + + assert type(creds.default_credentials()).__name__ == expected + + dsn = creds.get_odbc_dsn_dict() + assert "AUTHENTICATION" not in dsn + assert "UID" not in dsn + assert "PWD" not in dsn + + +@pytest.mark.parametrize( + "authentication", + ["auto", "cli", "environment", "interactive", "devicecode", "msi", "managedidentity"], +) +def test_mssql_removed_dlt_custom_alias_raises(authentication: str) -> None: + """The old dlt-custom lowercase aliases were replaced by native ODBC/azure-identity names.""" + creds = _mssql_credentials(authentication) + with pytest.raises(ConfigurationException): + validate_authentication(creds) + + +def test_mssql_service_principal_driver_native() -> None: + creds = _mssql_credentials( + "ActiveDirectoryServicePrincipal", + azure_tenant_id="t", + azure_client_id="c", + azure_client_secret="s", + ) + creds.on_partial() + + assert uses_token_authentication(creds) is False + dsn = creds.get_odbc_dsn_dict() + assert dsn["AUTHENTICATION"] == "ActiveDirectoryServicePrincipal" + assert dsn["UID"] == "c@t" + assert dsn["PWD"] == "s" + assert creds.to_odbc_attrs_before() is None + + +def test_mssql_service_principal_without_secret_falls_back_to_token() -> None: + creds = _mssql_credentials("ActiveDirectoryServicePrincipal") + creds.on_partial() + + assert uses_token_authentication(creds) is True + assert type(creds.default_credentials()).__name__ == "DefaultAzureCredential" + assert "AUTHENTICATION" not in creds.get_odbc_dsn_dict() + + +@pytest.mark.parametrize( + "authentication", + ["ActiveDirectoryIntegrated", "ActiveDirectoryInteractive", "ActiveDirectoryMsi"], +) +def test_mssql_driver_native_passthrough(authentication: str) -> None: + creds = _mssql_credentials(authentication) + creds.on_partial() + + dsn = creds.get_odbc_dsn_dict() + assert dsn["AUTHENTICATION"] == authentication + assert "UID" not in dsn + assert "PWD" not in dsn + assert creds.to_odbc_attrs_before() is None + + +def test_mssql_active_directory_password() -> None: + creds = _mssql_credentials( + "ActiveDirectoryPassword", username="user@contoso.com", password="pwd" + ) + creds.on_partial() + + dsn = creds.get_odbc_dsn_dict() + assert dsn["AUTHENTICATION"] == "ActiveDirectoryPassword" + assert dsn["UID"] == "user@contoso.com" + assert dsn["PWD"] == "pwd" + + +def test_mssql_active_directory_password_requires_username_password() -> None: + creds = _mssql_credentials("ActiveDirectoryPassword") + with pytest.raises(ConfigurationException): + validate_authentication(creds) + + +def test_mssql_unsupported_authentication_raises() -> None: + creds = _mssql_credentials("SqlPassword", username="u", password="p") + with pytest.raises(ConfigurationException): + creds.on_partial() # resolves (all present) -> on_resolved -> validate raises + + +def test_mssql_to_odbc_attrs_before_token_struct() -> None: + creds = _mssql_credentials("default") + creds._set_default_credentials(_FakeTokenCredential()) + + attrs = creds.to_odbc_attrs_before() + assert attrs is not None + token_struct = attrs[1256] # SQL_COPT_SS_ACCESS_TOKEN + length = struct.unpack(" None: + creds = MsSqlCredentials() + creds.host = "sql.example.com" + creds.database = "test_db" + creds.driver = "ODBC Driver 18 for SQL Server" + creds.authentication = "ActiveDirectoryDeviceCode" + + resolved = resolve_configuration(creds) + + assert resolved.is_resolved() + assert uses_token_authentication(resolved) is True + assert type(resolved.default_credentials()).__name__ == "DeviceCodeCredential" + assert "AUTHENTICATION" not in resolved.get_odbc_dsn_dict() + + +# --------------------------------------------------------------------------- +# Injectable access_token / azure_credential (precedence over `authentication`) +# --------------------------------------------------------------------------- + + +class _RaisingTokenCredential: + """A TokenCredential whose `get_token` must never be called (used to prove precedence).""" + + def get_token(self, *scopes: str, **kwargs: object) -> _FakeAccessToken: + raise AssertionError("azure_credential.get_token() should not have been called") + + +def test_mssql_access_token_and_azure_credential_default_to_none() -> None: + creds = MsSqlCredentials() + assert creds.access_token is None + assert creds.azure_credential is None + + +def test_mssql_get_access_token_returns_none_without_token_or_credential() -> None: + creds = _mssql_credentials("ActiveDirectoryDeviceCode") + assert get_access_token(creds) is None + + +def test_mssql_get_access_token_uses_azure_credential() -> None: + creds = _mssql_credentials(azure_credential=_FakeTokenCredential()) + assert get_access_token(creds) == "fake-access-token" + + +def test_mssql_get_access_token_prefers_access_token_over_azure_credential() -> None: + creds = _mssql_credentials( + access_token="explicit-token", azure_credential=_RaisingTokenCredential() + ) + assert get_access_token(creds) == "explicit-token" + + +def test_mssql_access_token_takes_precedence_over_authentication() -> None: + creds = _mssql_credentials( + "ActiveDirectoryServicePrincipal", + azure_tenant_id="t", + azure_client_id="c", + azure_client_secret="s", + access_token="explicit-token", + ) + creds.on_partial() + + assert uses_token_authentication(creds) is True + dsn = creds.get_odbc_dsn_dict() + assert "AUTHENTICATION" not in dsn + assert "UID" not in dsn + assert "PWD" not in dsn + + attrs = creds.to_odbc_attrs_before() + assert attrs is not None + assert attrs[1256][4:].decode("utf-16-le") == "explicit-token" + + +def test_mssql_azure_credential_takes_precedence_over_authentication() -> None: + creds = _mssql_credentials("ActiveDirectoryDeviceCode", azure_credential=_FakeTokenCredential()) + creds.on_partial() + + # setup_token_credential must skip the azure-identity DefaultAzureCredential machinery + assert creds.has_default_credentials() is False + assert uses_token_authentication(creds) is True + + dsn = creds.get_odbc_dsn_dict() + assert "AUTHENTICATION" not in dsn + + attrs = creds.to_odbc_attrs_before() + assert attrs is not None + assert attrs[1256][4:].decode("utf-16-le") == "fake-access-token" + + +def test_mssql_resolve_configuration_access_token_without_username_password() -> None: + creds = MsSqlCredentials() + creds.host = "sql.example.com" + creds.database = "test_db" + creds.driver = "ODBC Driver 18 for SQL Server" + creds.access_token = "explicit-token" + + resolved = resolve_configuration(creds) + + assert resolved.is_resolved() + assert uses_token_authentication(resolved) is True + assert "AUTHENTICATION" not in resolved.get_odbc_dsn_dict() + assert resolved.to_odbc_attrs_before()[1256][4:].decode("utf-16-le") == "explicit-token" + + +def test_mssql_resolve_configuration_azure_credential_without_username_password() -> None: + creds = MsSqlCredentials() + creds.host = "sql.example.com" + creds.database = "test_db" + creds.driver = "ODBC Driver 18 for SQL Server" + creds.azure_credential = _FakeTokenCredential() + + resolved = resolve_configuration(creds) + + assert resolved.is_resolved() + assert uses_token_authentication(resolved) is True + assert "AUTHENTICATION" not in resolved.get_odbc_dsn_dict() diff --git a/tests/load/mssql/test_mssql_docker_auth.py b/tests/load/mssql/test_mssql_docker_auth.py new file mode 100644 index 0000000000..5a3f196566 --- /dev/null +++ b/tests/load/mssql/test_mssql_docker_auth.py @@ -0,0 +1,61 @@ +"""Integration tests for the mssql destination against a local SQL Server. + +Start the server with `docker compose up` in this directory (see docker-compose.yml). +Tests skip automatically when the server is unreachable, so they are safe to run anywhere. + +These exercise the real `PyOdbcMsSqlClient.open_connection` code path. SQL Server in Docker +only supports SQL login (no Entra ID), so token/Azure AD authentication is validated against a +real Azure SQL / Fabric instance instead, via the parametrized `tests/load` suite. +""" + +import pytest + +from dlt.common.configuration import resolve_configuration +from dlt.common.schema import Schema +from dlt.destinations.impl.mssql.configuration import MsSqlClientConfiguration, MsSqlCredentials + +# mark all tests as essential, do not remove +pytestmark = pytest.mark.essential + +DOCKER_HOST = "localhost" +DOCKER_DATABASE = "master" +DOCKER_USERNAME = "sa" +DOCKER_PASSWORD = "Strong!Passw0rd" + + +def _docker_credentials() -> MsSqlCredentials: + creds = MsSqlCredentials() + creds.host = DOCKER_HOST + creds.database = DOCKER_DATABASE + creds.username = DOCKER_USERNAME + creds.password = DOCKER_PASSWORD + creds.connect_timeout = 5 + # local container uses a self-signed certificate + creds.query = {"trustservercertificate": "yes", "encrypt": "no"} + return resolve_configuration(creds) + + +def _sql_client(creds: MsSqlCredentials): + from dlt.destinations import mssql + + config = MsSqlClientConfiguration(credentials=creds)._bind_dataset_name("dataset") + client = mssql().client(Schema("schema"), config) + return client.sql_client + + +def test_mssql_docker_sql_login_opens_connection() -> None: + """SQL login (no Azure AD) opens a real connection - attrs_before is skipped.""" + creds = _docker_credentials() + assert creds.to_odbc_attrs_before() is None + + sql_client = _sql_client(creds) + try: + sql_client.open_connection() + except Exception as ex: # noqa: BLE001 + pytest.skip(f"Local SQL Server not reachable (run `docker compose up`): {ex}") + + try: + rows = sql_client.execute_sql("SELECT 1") + assert rows[0][0] == 1 + finally: + sql_client.close_connection()