Skip to content

mssql: delegate TokenCredential authentication to mssql-python #11

Description

@sdebruyn

Context

The current credential path eagerly calls azure_credential.get_token(), manually builds the ODBC access-token struct, and passes it through attrs_before:

  • # ODBC attribute used to inject a pre-acquired Entra ID access token, kept for a future explicit
    # access-token feature. Not used by any authentication method today.
    # 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"
    # Entra ID authentication methods supported by mssql-python's `Authentication=` connection
    # option. mssql-python performs the sign-in for all of them; dlt only builds the DSN.
    SUPPORTED_AUTHENTICATION = frozenset(
    {
    "ActiveDirectoryServicePrincipal",
    "ActiveDirectoryPassword",
    "ActiveDirectoryIntegrated",
    "ActiveDirectoryInteractive",
    "ActiveDirectoryMsi",
    "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 build_token_attrs_before(credentials: Any) -> dict[int, bytes] | None:
    """Return `attrs_before` with a directly injected Entra ID access token, or None."""
    if credentials.access_token:
    token = str(credentials.access_token)
    elif credentials.azure_credential:
    token = credentials.azure_credential.get_token(SQL_TOKEN_SCOPE).token
    else:
    return None
    encoded_token = token.encode("utf-16-le")
    token_struct = struct.pack(f"<I{len(encoded_token)}s", len(encoded_token), encoded_token)
    return {SQL_COPT_SS_ACCESS_TOKEN: token_struct}
  • access_token: Optional[TSecretStrValue] = None
    """Pre-acquired Entra ID access token, injected as-is. Takes precedence over `azure_credential` and `authentication`"""
    azure_credential: Annotated[Optional[Any], NotResolved()] = None
    """A `TokenCredential` injected at runtime, e.g. `DefaultAzureCredential()`. Takes precedence over `authentication` but not over `access_token`"""
  • 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 `attrs_before` with a directly injected Entra ID access token, or None."""
    return build_token_attrs_before(self)
  • def open_connection(self) -> mssql_python.Connection:
    # mssql-python bundles its own driver, so the connection string carries no DRIVER, and it
    # signs in for every supported Entra ID authentication method itself from the
    # `Authentication=` DSN keyword — dlt injects no `attrs_before` for those.
    #
    # mssql-python auto-enables connection pooling (default: 100 connections, 600s idle
    # timeout) on the first connection any process opens, unless the application calls
    # `mssql_python.pooling()` first — which dlt does not do, since these defaults are
    # already sane for our workload. The pool matches purely on connection-string text, so
    # our credentials building a stable DSN is what makes reuse actually happen.
    self._conn = mssql_python.connect(
    self.credentials.to_odbc_dsn(),
    autocommit=True,
    attrs_before=self.credentials.to_odbc_attrs_before(), # type: ignore[arg-type]
    timeout=self.credentials.connect_timeout,
    )

mssql-python v1.13.0 / PR #603 adds a public token_provider= parameter accepting any synchronous object with .get_token(scope). It centralizes token acquisition, validation, expiry capture, error translation, token struct construction, and fresh-token acquisition for bulk-copy operations.

Our azure_credential objects already implement this protocol, including FabNotebookUtilsCredential. Continuing to serialize those credentials in dlt duplicates driver code and prevents the driver from retaining the provider for later operations.

This depends on #10.

Proposed change

  1. Pass MsSqlCredentials.azure_credential to mssql_python.connect(..., token_provider=...).
  2. Keep access_token on the existing attrs_before[SQL_COPT_SS_ACCESS_TOKEN] path:
    • it is explicitly a pre-acquired token rather than a provider;
    • it preserves the v1.13 escape hatch for sovereign-cloud tokens, because token_provider uses the Azure commercial SQL scope.
  3. Split the current helper so only access_token needs UTF-16LE/length-prefix packing; remove manual get_token(SQL_TOKEN_SCOPE) and its dlt-owned scope constant from the azure_credential path.
  4. Continue omitting Authentication=, UID, and PWD from the DSN when either explicit token mechanism is selected.
  5. Keep the existing precedence explicit: access_token wins over azure_credential, which wins over authentication.

Do not claim that token_provider makes token-hash pools expiry-refreshable. In v1.13 custom providers are still keyed by the token hash; token rotation opens a new pool bucket. The concrete value here is a smaller dlt authentication surface, driver-owned validation/lifecycle metadata, and correct token reacquisition for the new bulk-copy API.

Acceptance criteria

  • azure_credential is passed unchanged as token_provider and dlt does not call get_token() while building credentials or connection arguments.
  • access_token still produces the exact binary attrs_before value and never sets token_provider.
  • authentication=ActiveDirectory* continues to use only the DSN and never sets either explicit token argument.
  • FabNotebookUtilsCredential("sql"), DefaultAzureCredential, and a minimal custom get_token(scope) provider are covered.
  • Tests cover precedence and prove that token_provider, Authentication=, and access-token attrs_before are never sent together.
  • Driver acquisition failures retain the original credential error as their cause and are mapped through the existing dlt database exception boundary.
  • Documentation calls out that token_provider supports only the Azure commercial SQL scope; sovereign clouds require access_token.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions