diff --git a/README.md b/README.md index 0085d5765..785124171 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,82 @@ your_profile_name: token: [dapiXXXXXXXXXXXXXXXXXXXXXXX] ``` +### Authentication + +The adapter supports all [Databricks unified authentication](https://docs.databricks.com/dev-tools/auth/unified-auth.html) methods. For a full setup walkthrough, see the **[Connect to Databricks](https://docs.getdbt.com/docs/core/connect-data-platform/databricks-setup)** guide on docs.getdbt.com. + +The method is selected automatically based on which fields are present in your profile. Priority order (first match wins): + +| Method | Required profile fields | +|---|---| +| Personal Access Token (PAT) | `token` | +| Azure service principal | `azure_client_id` + `azure_client_secret` | +| Any explicit SDK auth type | `auth_type` (see values below) | +| OAuth user-to-machine (browser) | `client_id` only (no `client_secret`), or _(none of the above — opens browser)_ | +| OAuth M2M / legacy Azure SP | `client_secret` without `auth_type` _(deprecated — set `auth_type` explicitly)_ | + +#### `auth_type` values + +Set `auth_type` in your profile to delegate entirely to the Databricks SDK for that auth method: + +| `auth_type` value | Description | +|---|---| +| `oauth` | U2M browser login (legacy dbt alias for `external-browser`) | +| `oauth-m2m` | Service principal via OAuth M2M; requires `client_id` + `client_secret` | +| `azure-cli` | Azure CLI (`az login`) | +| `azure-msi` | Azure Managed Service Identity | +| `databricks-cli` | Databricks CLI credential chain | +| `google-credentials` | Google service account | +| `metadata-service` | Databricks metadata service | + +#### Auth-specific profile fields + +```nofmt +# OAuth M2M / service principal +client_id: ... +client_secret: ... + +# Azure service principal (explicit) +azure_client_id: ... +azure_client_secret: ... + +# Azure common options +azure_tenant_id: ... +azure_environment: ... # e.g. usgovernment +azure_workspace_resource_id: ... + +# Azure MSI (user-assigned identity) +auth_type: azure-msi +azure_client_id: ... # omit for system-assigned + +# Databricks CLI +auth_type: databricks-cli +databricks_cli_profile: ... # optional: named profile in ~/.databrickscfg + +# Google +auth_type: google-credentials +google_credentials: ... # path to service account JSON +google_service_account: ... + +# Metadata service / OIDC +metadata_service_url: ... +oidc_token_env: ... +oidc_token_filepath: ... + +# Escape hatch: any extra Databricks SDK Config kwarg not listed above +databricks_sdk_parameters: + some_sdk_field: value +``` + +New auth methods added to the Databricks Python SDK are available automatically via `auth_type` + `databricks_sdk_parameters` without requiring an adapter update. + +> **Deprecated:** Omitting `auth_type` when using `client_secret` triggers a legacy heuristic that guesses between `oauth-m2m` and `azure-client-secret` based on the secret format. This produces a deprecation warning at runtime. Migrate by setting `auth_type` explicitly: +> ```yaml +> auth_type: oauth-m2m # for Databricks OAuth M2M service principals +> # or +> auth_type: azure-client-secret # for Azure AD service principals +> ``` + ### Documentation For comprehensive documentation on Databricks-specific features, configurations, and capabilities: diff --git a/dbt/adapters/databricks/api_client.py b/dbt/adapters/databricks/api_client.py index bc4d54df6..7349309e8 100644 --- a/dbt/adapters/databricks/api_client.py +++ b/dbt/adapters/databricks/api_client.py @@ -29,7 +29,6 @@ from dbt.adapters.databricks import utils from dbt.adapters.databricks.__version__ import version from dbt.adapters.databricks.credentials import ( - DatabricksCredentialManager, DatabricksCredentials, ) from dbt.adapters.databricks.logging import logger @@ -945,7 +944,7 @@ def __init__( use_user_folder: bool = False, polling_interval: int = DEFAULT_POLLING_INTERVAL, ): - workspace_client = DatabricksCredentialManager.create_from(credentials).api_client + workspace_client = credentials.authenticate().api_client self.libraries = LibraryApi(workspace_client) self.clusters = ClusterApi(workspace_client, self.libraries) self.command_contexts = CommandContextApi(workspace_client, self.clusters, self.libraries) diff --git a/dbt/adapters/databricks/credentials.py b/dbt/adapters/databricks/credentials.py index 7e8af786b..3d1584ec2 100644 --- a/dbt/adapters/databricks/credentials.py +++ b/dbt/adapters/databricks/credentials.py @@ -2,12 +2,11 @@ import json import re from collections.abc import Iterable -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Callable, Optional, cast from dbt.adapters.contracts.connection import Credentials from dbt_common.exceptions import DbtConfigError, DbtValidationError -from mashumaro import DataClassDictMixin from requests import PreparedRequest from requests.auth import AuthBase @@ -21,9 +20,7 @@ EXTRACT_CLUSTER_ID_FROM_HTTP_PATH_REGEX = re.compile(r"/?sql/protocolv1/o/\d+/(.*)") DBT_DATABRICKS_HTTP_SESSION_HEADERS = "DBT_DATABRICKS_HTTP_SESSION_HEADERS" -REDIRECT_URL = "http://localhost:8020" CLIENT_ID = "dbt-databricks" -SCOPES = ["all-apis", "offline_access"] MAX_NT_PASSWORD_SIZE = 1280 # When using an Azure App Registration with the SPA platform, the refresh token will @@ -37,16 +34,43 @@ class DatabricksCredentials(Credentials): schema: Optional[str] = None # type: ignore[assignment] host: Optional[str] = None http_path: Optional[str] = None + + # ---- authentication ---- + # PAT token: Optional[str] = None + # OAuth / M2M client_id: Optional[str] = None client_secret: Optional[str] = None + # Azure azure_client_id: Optional[str] = None azure_client_secret: Optional[str] = None - oauth_redirect_url: Optional[str] = None + azure_tenant_id: Optional[str] = None + azure_environment: Optional[str] = None + azure_use_msi: Optional[bool] = None + azure_workspace_resource_id: Optional[str] = None + # Google / GCP + google_credentials: Optional[str] = None + google_service_account: Optional[str] = None + # Metadata service / OIDC + metadata_service_url: Optional[str] = None + oidc_token_env: Optional[str] = None + oidc_token_filepath: Optional[str] = None + # Basic auth + username: Optional[str] = None + password: Optional[str] = None + # Databricks CLI / config file + databricks_cli_profile: Optional[str] = None # maps to SDK's 'profile' + config_file: Optional[str] = None + # Auth type selector (supports all Databricks SDK auth_type values; + # 'oauth' is a legacy dbt alias for 'external-browser'). + auth_type: Optional[str] = None + # Escape hatch: any additional Databricks SDK Config kwargs not modelled above. + databricks_sdk_parameters: Optional[dict[str, Any]] = None + + # ---- connection / dbt ---- oauth_scopes: Optional[list[str]] = None session_properties: Optional[dict[str, Any]] = None connection_parameters: Optional[dict[str, Any]] = None - auth_type: Optional[str] = None # Named compute resources specified in the profile. Used for # creating a connection when a model specifies a compute resource. @@ -130,16 +154,12 @@ def __post_init__(self) -> None: if "_socket_timeout" not in connection_parameters: connection_parameters["_socket_timeout"] = 600 self.connection_parameters = connection_parameters - self._credentials_manager = DatabricksCredentialManager.create_from(self) + self._credentials_manager = DatabricksCredentialManager(credentials=self) def validate_creds(self) -> None: for key in ["host", "http_path"]: if not getattr(self, key): raise DbtConfigError(f"The config '{key}' is required to connect to Databricks") - if not self.token and self.auth_type != "oauth": - raise DbtConfigError( - "The config `auth_type: oauth` is required when not using access token" - ) if not self.client_id and self.client_secret: raise DbtConfigError( @@ -155,6 +175,69 @@ def validate_creds(self) -> None: "must be both present or both absent" ) + def to_sdk_config_kwargs(self) -> dict[str, Any]: + """Return kwargs suitable for passing to databricks.sdk.core.Config. + + This is the single place to update when adding support for a new SDK + auth field: add the field to DatabricksCredentials above, then add + one entry here. databricks_sdk_parameters is merged last so users + can override or supply anything not explicitly modelled. + + Auth-type inference (applied when auth_type is not explicitly set): + - 'oauth' is a legacy dbt alias; translated to 'external-browser'. + - azure_client_id + azure_client_secret -> 'azure-client-secret' + - client_id present, no client_secret -> 'external-browser' + When auth_type resolves to 'external-browser' and no client_id is + provided, the dbt-databricks registered OAuth app id is used. + """ + # Resolve effective auth_type, translating the legacy dbt alias. + auth_type = self.auth_type + if auth_type == "oauth": + auth_type = "external-browser" + + # Safety nets: infer auth_type from credential fields when not set. + if not auth_type: + if self.azure_client_id and self.azure_client_secret: + auth_type = "azure-client-secret" + elif self.client_id and not self.client_secret: + auth_type = "external-browser" + elif not self.token and not self.client_id and not self.azure_client_id: + # No credentials provided at all → preserve legacy external-browser fallback. + auth_type = "external-browser" + + pairs: dict[str, Any] = { + "host": self.host, + "token": self.token, + "auth_type": auth_type, + "client_id": self.client_id, + "client_secret": self.client_secret, + "azure_client_id": self.azure_client_id, + "azure_client_secret": self.azure_client_secret, + "azure_tenant_id": self.azure_tenant_id, + "azure_environment": self.azure_environment, + "azure_use_msi": self.azure_use_msi, + "azure_workspace_resource_id": self.azure_workspace_resource_id, + "google_credentials": self.google_credentials, + "google_service_account": self.google_service_account, + "metadata_service_url": self.metadata_service_url, + "oidc_token_env": self.oidc_token_env, + "oidc_token_filepath": self.oidc_token_filepath, + "username": self.username, + "password": self.password, + "profile": self.databricks_cli_profile, + "config_file": self.config_file, + "scopes": self.oauth_scopes, + } + kwargs = {k: v for k, v in pairs.items() if v is not None} + + # Default to the dbt-databricks OAuth app when doing external-browser + # and the user hasn't provided their own client_id. + if auth_type == "external-browser" and "client_id" not in kwargs: + kwargs["client_id"] = CLIENT_ID + + kwargs.update(self.databricks_sdk_parameters or {}) + return kwargs + @classmethod def get_invocation_env(cls) -> Optional[str]: invocation_env = GlobalState.get_invocation_env() @@ -267,118 +350,97 @@ def __call__(self, r: PreparedRequest) -> PreparedRequest: @dataclass -class DatabricksCredentialManager(DataClassDictMixin): - host: str - client_id: str - client_secret: str - azure_client_id: Optional[str] = None - azure_client_secret: Optional[str] = None - oauth_redirect_url: str = REDIRECT_URL - oauth_scopes: list[str] = field(default_factory=lambda: SCOPES) - token: Optional[str] = None - auth_type: Optional[str] = None - - @classmethod - def create_from(cls, credentials: DatabricksCredentials) -> "DatabricksCredentialManager": - return DatabricksCredentialManager( - host=credentials.host or "", - token=credentials.token, - client_id=credentials.client_id or CLIENT_ID, - client_secret=credentials.client_secret or "", - azure_client_id=credentials.azure_client_id, - azure_client_secret=credentials.azure_client_secret, - oauth_redirect_url=credentials.oauth_redirect_url or REDIRECT_URL, - oauth_scopes=credentials.oauth_scopes or SCOPES, - auth_type=credentials.auth_type, - ) - - def authenticate_with_pat(self) -> Config: - return Config( - host=self.host, - token=self.token, - ) - - def authenticate_with_oauth_m2m(self) -> Config: - return Config( - host=self.host, - client_id=self.client_id, - client_secret=self.client_secret, - auth_type="oauth-m2m", - ) +class DatabricksCredentialManager: + """Wraps DatabricksCredentials and resolves a databricks.sdk.core.Config. + + Dispatch (first match wins): + 1. client_secret present with no explicit auth_type and no azure_client_secret + -> legacy heuristic (oauth-m2m vs legacy-azure-client-secret) + 2. everything else -> Config(**credentials.to_sdk_config_kwargs()) + to_sdk_config_kwargs() encodes all auth-type inference and the + 'oauth' alias translation, so PAT (token), Azure SP, explicit + auth_type, env-var discovery, and future SDK methods all go + through this single path. + """ - def authenticate_with_external_browser(self) -> Config: - return Config( - host=self.host, - client_id=self.client_id, - client_secret=self.client_secret, - auth_type="external-browser", - ) + credentials: DatabricksCredentials + + def _resolve_client_secret_heuristic(self) -> Config: + """Try oauth-m2m and legacy-azure-client-secret in heuristic order. + + DEPRECATED. Only reached for profiles that set client_secret without an + explicit auth_type and without dedicated azure_client_* fields — a + legacy configuration predating Databricks unified auth. Note that + extra fields (azure_tenant_id, oauth_scopes, etc.) are intentionally + not forwarded here to preserve identical legacy behaviour; set + auth_type explicitly to get the full field set forwarded. + """ + creds = self.credentials + client_id = creds.client_id or CLIENT_ID + + def _oauth_m2m() -> Config: + return Config( + host=creds.host, + client_id=client_id, + client_secret=creds.client_secret, + auth_type="oauth-m2m", + ) - def legacy_authenticate_with_azure_client_secret(self) -> Config: - return Config( - host=self.host, - azure_client_id=self.client_id, - azure_client_secret=self.client_secret, - auth_type="azure-client-secret", - ) + def _legacy_azure() -> Config: + return Config( + host=creds.host, + azure_client_id=client_id, + azure_client_secret=creds.client_secret, + auth_type="azure-client-secret", + ) - def authenticate_with_azure_client_secret(self) -> Config: - return Config( - host=self.host, - azure_client_id=self.azure_client_id, - azure_client_secret=self.azure_client_secret, - auth_type="azure-client-secret", - ) + # Secrets starting with "dose" are Databricks OAuth secrets. + if creds.client_secret.startswith("dose"): + auth_sequence = [("oauth-m2m", _oauth_m2m), ("legacy-azure-client-secret", _legacy_azure)] + else: + auth_sequence = [("legacy-azure-client-secret", _legacy_azure), ("oauth-m2m", _oauth_m2m)] + + exceptions = [] + for i, (name, fn) in enumerate(auth_sequence): + try: + config = fn() + if name == "legacy-azure-client-secret": + logger.warning( + "You are using Azure Service Principal, " + "please use 'azure_client_id' and 'azure_client_secret' instead." + ) + return config + except Exception as e: + exceptions.append((name, e)) + next_name = auth_sequence[i + 1][0] if i + 1 < len(auth_sequence) else None + if next_name: + logger.warning( + f"Failed to authenticate with {name}, " + f"trying {next_name} next. Error: {e}" + ) + else: + logger.error( + f"Failed to authenticate with {name}. " + f"No more authentication methods to try. Error: {e}" + ) + raise Exception(f"All authentication methods failed. Details: {exceptions}") + raise RuntimeError("unreachable") def __post_init__(self) -> None: - if not hasattr(self, "_config"): - self._config: Optional[Config] = None - if self._config is not None: - return - - if self.token: - self._config = self.authenticate_with_pat() - elif self.azure_client_id and self.azure_client_secret: - self._config = self.authenticate_with_azure_client_secret() - elif not self.client_secret: - self._config = self.authenticate_with_external_browser() + self._config: Optional[Config] = None + creds = self.credentials + + # Legacy heuristic: client_secret without auth_type predates Databricks unified auth. + # Deprecated — set auth_type explicitly (e.g. 'oauth-m2m' or 'azure-client-secret'). + if creds.client_secret and not creds.auth_type and not creds.azure_client_secret: + logger.warning( + "Implicit auth selection from 'client_secret' alone is deprecated. " + "Please set 'auth_type' explicitly (e.g. auth_type: oauth-m2m or " + "auth_type: azure-client-secret) to silence this warning." + ) + self._config = self._resolve_client_secret_heuristic() else: - auth_methods = { - "oauth-m2m": self.authenticate_with_oauth_m2m, - "legacy-azure-client-secret": self.legacy_authenticate_with_azure_client_secret, - } - - # If the secret starts with dose, high chance is it is a databricks secret - if self.client_secret.startswith("dose"): - auth_sequence = ["oauth-m2m", "legacy-azure-client-secret"] - else: - auth_sequence = ["legacy-azure-client-secret", "oauth-m2m"] - - exceptions = [] - for i, auth_type in enumerate(auth_sequence): - try: - # The Config constructor will implicitly init auth and throw if failed - self._config = auth_methods[auth_type]() - if auth_type == "legacy-azure-client-secret": - logger.warning( - "You are using Azure Service Principal, " - "please use 'azure_client_id' and 'azure_client_secret' instead." - ) - break # Exit loop if authentication is successful - except Exception as e: - exceptions.append((auth_type, e)) - next_auth_type = auth_sequence[i + 1] if i + 1 < len(auth_sequence) else None - if next_auth_type: - logger.warning( - f"Failed to authenticate with {auth_type}, " - f"trying {next_auth_type} next. Error: {e}" - ) - else: - logger.error( - f"Failed to authenticate with {auth_type}. " - f"No more authentication methods to try. Error: {e}" - ) - raise Exception(f"All authentication methods failed. Details: {exceptions}") + self._config = Config(**creds.to_sdk_config_kwargs()) @property def api_client(self) -> WorkspaceClient: diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index ca92a13e3..a32f171aa 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1,11 +1,19 @@ import os import tempfile from os.path import join +from unittest.mock import MagicMock, patch import keyring.backend import pytest +from dbt_common.exceptions import DbtConfigError -from dbt.adapters.databricks.credentials import DatabricksCredentials +from dbt.adapters.databricks.credentials import ( + CLIENT_ID, + DatabricksCredentials, +) + +_HOST = "my.cloud.databricks.com" +_HTTP_PATH = "/sql/1.0/warehouses/abc" @pytest.mark.skip(reason="Need to mock requests to OIDC") @@ -174,3 +182,226 @@ def delete_password(self, servicename, username): return None os.remove(file_path) + + +class TestAuthDispatch: + """Parametrized tests asserting the exact kwargs passed to databricks.sdk.core.Config. + + Covers every dispatch path supported by the legacy code. Cases whose expected + kwargs change after the refactor are explicitly marked so the delta is visible + in the commit that updates them. + """ + + @pytest.mark.parametrize( + "creds_kwargs,expected_kwargs", + [ + # ---- PAT — unchanged ---- + pytest.param( + dict(token="mytoken"), + dict(host=_HOST, token="mytoken"), + id="pat", + ), + # ---- Azure Service Principal (dedicated fields) — unchanged ---- + pytest.param( + dict(azure_client_id="az-id", azure_client_secret="az-secret"), + dict(host=_HOST, auth_type="azure-client-secret", azure_client_id="az-id", azure_client_secret="az-secret"), + id="azure_sp", + ), + # ---- Legacy heuristic: client_secret without auth_type or azure fields — unchanged ---- + # "dose" prefix identifies a Databricks OAuth secret → oauth-m2m first. + pytest.param( + dict(client_id="my-sp", client_secret="dose_secret"), + dict(host=_HOST, auth_type="oauth-m2m", client_id="my-sp", client_secret="dose_secret"), + id="legacy_heuristic_dose_prefix", + ), + # Non-dose prefix (e.g. Azure SP client secret) → legacy-azure-client-secret first. + pytest.param( + dict(client_id="my-sp", client_secret="azure_secret"), + dict(host=_HOST, auth_type="azure-client-secret", azure_client_id="my-sp", azure_client_secret="azure_secret"), + id="legacy_heuristic_nondose_prefix", + ), + # ---- OAuth external-browser — updated: empty client_secret no longer leaked ---- + # Legacy produced: {host, auth_type="external-browser", client_id=CLIENT_ID, client_secret=""} + pytest.param( + dict(auth_type="oauth"), + dict(host=_HOST, auth_type="external-browser", client_id=CLIENT_ID), + id="oauth_alias", + ), + pytest.param( + dict(auth_type="oauth", client_id="my-app"), + dict(host=_HOST, auth_type="external-browser", client_id="my-app"), + id="oauth_alias_with_custom_client_id", + ), + # Legacy passed client_secret="" explicitly; refactor preserves external-browser + # fallback but no longer leaks the empty string into Config. + pytest.param( + dict(), + dict(host=_HOST, auth_type="external-browser", client_id=CLIENT_ID), + id="no_credentials", + ), + # ---- New behaviors: client_id alone infers external-browser ---- + pytest.param( + dict(client_id="my-app"), + dict(host=_HOST, auth_type="external-browser", client_id="my-app"), + id="client_id_only_infers_external_browser", + ), + # ---- New behaviors: explicit SDK auth_type passthrough ---- + # Legacy ignored auth_type in dispatch and fell through to external-browser. + pytest.param( + dict(auth_type="azure-cli"), + dict(host=_HOST, auth_type="azure-cli"), + id="azure_cli", + ), + pytest.param( + dict(auth_type="azure-msi"), + dict(host=_HOST, auth_type="azure-msi"), + id="azure_msi", + ), + pytest.param( + dict(auth_type="azure-msi", azure_client_id="my-msi-id"), + dict(host=_HOST, auth_type="azure-msi", azure_client_id="my-msi-id"), + id="azure_msi_user_assigned_identity", + ), + pytest.param( + dict(auth_type="azure-msi", azure_tenant_id="my-tenant"), + dict(host=_HOST, auth_type="azure-msi", azure_tenant_id="my-tenant"), + id="azure_msi_with_tenant", + ), + pytest.param( + dict(auth_type="databricks-cli"), + dict(host=_HOST, auth_type="databricks-cli"), + id="databricks_cli", + ), + pytest.param( + dict(auth_type="databricks-cli", databricks_cli_profile="prod"), + dict(host=_HOST, auth_type="databricks-cli", profile="prod"), + id="databricks_cli_with_profile", + ), + pytest.param( + dict(auth_type="google-credentials", google_service_account="sa@project.iam.gserviceaccount.com"), + dict(host=_HOST, auth_type="google-credentials", google_service_account="sa@project.iam.gserviceaccount.com"), + id="google_credentials", + ), + pytest.param( + dict(auth_type="metadata-service"), + dict(host=_HOST, auth_type="metadata-service"), + id="metadata_service", + ), + # ---- New behaviors: fields forwarded that legacy dropped ---- + # explicit auth_type="oauth-m2m" now bypasses heuristic (legacy: non-dose heuristic + # would have chosen legacy-azure-client-secret for "my-secret") + pytest.param( + dict(auth_type="oauth-m2m", client_id="my-sp", client_secret="my-secret"), + dict(host=_HOST, auth_type="oauth-m2m", client_id="my-sp", client_secret="my-secret"), + id="explicit_oauth_m2m_bypasses_heuristic", + ), + # legacy: auth_type dropped when token present → {host, token} + pytest.param( + dict(token="mytoken", auth_type="azure-cli"), + dict(host=_HOST, token="mytoken", auth_type="azure-cli"), + id="token_and_auth_type_both_forwarded", + ), + # legacy: azure_client_id+secret → azure-client-secret regardless of auth_type + pytest.param( + dict(auth_type="azure-msi", azure_client_id="my-msi-id", azure_client_secret="my-secret"), + dict(host=_HOST, auth_type="azure-msi", azure_client_id="my-msi-id", azure_client_secret="my-secret"), + id="explicit_auth_type_overrides_azure_sp_inference", + ), + # legacy: azure_tenant_id not forwarded in Azure SP path + pytest.param( + dict(azure_client_id="az-id", azure_client_secret="az-secret", azure_tenant_id="my-tenant"), + dict(host=_HOST, auth_type="azure-client-secret", azure_client_id="az-id", azure_client_secret="az-secret", azure_tenant_id="my-tenant"), + id="azure_sp_with_tenant", + ), + # legacy: databricks_sdk_parameters ignored + pytest.param( + dict(auth_type="azure-cli", databricks_sdk_parameters={"azure_environment": "usgovernment"}), + dict(host=_HOST, auth_type="azure-cli", azure_environment="usgovernment"), + id="sdk_params_merged", + ), + pytest.param( + dict(token="mytoken", databricks_sdk_parameters={"extra_param": "value"}), + dict(host=_HOST, token="mytoken", extra_param="value"), + id="sdk_params_forwarded_with_pat", + ), + # ---- oauth_scopes forwarded as 'scopes' ---- + pytest.param( + dict(auth_type="oauth", oauth_scopes=["all-apis"]), + dict(host=_HOST, auth_type="external-browser", client_id=CLIENT_ID, scopes=["all-apis"]), + id="oauth_scopes_external_browser", + ), + pytest.param( + dict(auth_type="oauth-m2m", client_id="my-sp", client_secret="dose_secret", oauth_scopes=["all-apis", "offline_access"]), + dict(host=_HOST, auth_type="oauth-m2m", client_id="my-sp", client_secret="dose_secret", scopes=["all-apis", "offline_access"]), + id="oauth_scopes_m2m", + ), + ], + ) + def test_config_kwargs(self, creds_kwargs, expected_kwargs): + with patch("dbt.adapters.databricks.credentials.Config") as mock_config: + mock_config.return_value = MagicMock() + DatabricksCredentials( + host=_HOST, + http_path=_HTTP_PATH, + database="db", + schema="sch", + **creds_kwargs, + ) + assert mock_config.call_args.kwargs == expected_kwargs + + +class TestValidateCreds: + BASE = dict(host=_HOST, http_path=_HTTP_PATH) + + def _creds(self, **kwargs): + with patch("dbt.adapters.databricks.credentials.Config") as mc: + mc.return_value = MagicMock() + return DatabricksCredentials(database="db", schema="sch", **self.BASE, **kwargs) + + def test_token_valid(self): + self._creds(token="mytoken").validate_creds() + + def test_oauth_auth_type_valid(self): + """auth_type='oauth' is the documented U2M alias.""" + self._creds(auth_type="oauth").validate_creds() + + def test_host_required(self): + with patch("dbt.adapters.databricks.credentials.Config") as mc: + mc.return_value = MagicMock() + with pytest.raises(DbtConfigError, match="host"): + DatabricksCredentials( + database="db", schema="sch", http_path=_HTTP_PATH, token="t" + ).validate_creds() + + def test_http_path_required(self): + with patch("dbt.adapters.databricks.credentials.Config") as mc: + mc.return_value = MagicMock() + with pytest.raises(DbtConfigError, match="http_path"): + DatabricksCredentials( + database="db", schema="sch", host=_HOST, token="t" + ).validate_creds() + + def test_client_id_required_when_client_secret_present(self): + with pytest.raises(DbtConfigError, match="client_id"): + self._creds(auth_type="oauth", client_secret="secret").validate_creds() + + def test_azure_credentials_must_be_paired(self): + with pytest.raises(DbtConfigError, match="azure_client"): + self._creds(token="t", azure_client_id="id").validate_creds() + + # ---- New behaviors: any SDK auth_type now accepted without a token ---- + + def test_azure_cli_auth_type_valid(self): + self._creds(auth_type="azure-cli").validate_creds() + + def test_azure_msi_auth_type_valid(self): + self._creds(auth_type="azure-msi").validate_creds() + + def test_databricks_cli_auth_type_valid(self): + self._creds(auth_type="databricks-cli").validate_creds() + + def test_google_credentials_auth_type_valid(self): + self._creds(auth_type="google-credentials").validate_creds() + + def test_metadata_service_auth_type_valid(self): + self._creds(auth_type="metadata-service").validate_creds()