Skip to content

Commit 461a594

Browse files
paulteehanclaudepre-commit-ci[bot]
authored
feat: Databricks OAuth (M2M + Azure service principal) auth [PLATL-820] (#2781)
* feat(databricks): OAuth (M2M + Azure service principal) auth for soda-databricks Add an auth_type discriminator to the Databricks connector with two OAuth client-credentials modes — databricks-oauth-m2m and azure-service-principal — alongside the existing personal-access-token auth. OAuth modes build a Databricks SDK credentials_provider and strip credential fields from sql.connect kwargs; PAT stays the default when auth_type is absent. PLATL-820 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(databricks): resolve SonarCloud S5332 clear-text-HTTP finding in test _with_https used a literal 'http://' in chained startswith calls, which SonarCloud flagged as 'Using HTTP protocol is insecure' (S5332) — the sole condition failing the Quality Gate on PR #2781 (B Security Rating on New Code); also clears the paired chained-startswith smell. Replace with a scheme-agnostic '://' membership test; behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(databricks): correct OAuth credentials_provider shape + fail-loud (review) Address review on PR #2781: - Blocker: credentials_provider was one level too shallow. ExternalAuthProvider calls credentials_provider() once to get the header factory, then calls that per request; passing the SDK factory directly made the per-request call raise 'dict object is not callable', so no OAuth (M2M or Azure SP) connection could be established. Wrap the SDK header factory in a zero-arg lambda. - Raise a clear ValueError when the SDK yields no header factory instead of silently degrading to a credential-less PAT connect. - Cap databricks-sdk to >=0.19,<1 (provider import paths move across majors). - Tests now drive provider()() to a headers dict (catching the shape bug) and cover the None->error path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): regenerate uv.lock for the databricks-sdk dependency Fixes the `uv lock --check` failure in the pre-commit & lockfile CI job. PLATL-820 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(databricks): address review — sdk floor, drop provisional note PR #2781 review (Niels-b): - Bump databricks-sdk floor to >=0.117,<1 (was >=0.19): 0.19 predates the credentials-provider surface the OAuth path imports and no CI job exercises the floor; uv.lock resolves 0.119.0 and the fix was verified against 0.117. Regenerated uv.lock (uv lock --check). - Drop the 'provisional / confirm with BE' note and dangling implementation-plan.md reference on the auth_type literals — the cross-repo contract is confirmed (soda-library#759 + Cloud form); reduced to a plain contract statement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(databricks): set databricks-sdk floor to >=0.19,<1 (was 0.117) Revert the 0.117 floor. Verified against the SDK source at tag v0.19.0 that the OAuth surface we use is already present at 0.19.0 — oauth_service_principal, azure_service_principal, and Config(client_id/client_secret/azure_*). So 0.117 (a 2026-06 release) excluded ~2 years of compatible releases for no benefit and risked resolver conflicts. Keep the <1 major cap (the genuinely useful guard). Regenerated uv.lock (still resolves 0.119.0 at the top). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 700c9a5 commit 461a594

7 files changed

Lines changed: 559 additions & 31 deletions

File tree

soda-databricks/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ authors = [
1010
dependencies = [
1111
"soda-core==4.16.0",
1212
"databricks-sql-connector",
13+
"databricks-sdk>=0.19,<1",
1314
]
1415

1516
[project.entry-points."soda.plugins.data_source.databricks"]

soda-databricks/src/soda_databricks/common/data_sources/databricks_data_source_connection.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
DataSourceConnectionProperties,
1414
)
1515
from soda_databricks.model.data_source.databricks_connection_properties import (
16+
DatabricksAzureServicePrincipal,
1617
DatabricksConnectionProperties,
18+
DatabricksOAuthM2M,
1719
)
1820

1921
logger: logging.Logger = soda_logger
@@ -27,11 +29,77 @@ def _create_connection(
2729
self,
2830
config: DatabricksConnectionProperties,
2931
):
32+
connection_kwargs = config.to_connection_kwargs()
33+
34+
# OAuth modes: the SDK builds a credentials_provider callable (handling token
35+
# acquisition + refresh). The credential fields are stripped from connection_kwargs
36+
# by the properties class, so they never reach sql.connect as plain kwargs.
37+
credentials_provider = self._build_credentials_provider(config, connection_kwargs)
38+
if credentials_provider is not None:
39+
return sql.connect(
40+
user_agent_entry="Soda Core",
41+
credentials_provider=credentials_provider,
42+
**connection_kwargs,
43+
)
44+
45+
# Token (PAT) auth: access_token flows through connection_kwargs unchanged.
3046
return sql.connect(
3147
user_agent_entry="Soda Core",
32-
**config.to_connection_kwargs(),
48+
**connection_kwargs,
49+
)
50+
51+
@staticmethod
52+
def _build_credentials_provider(config: DatabricksConnectionProperties, connection_kwargs: dict):
53+
"""Return an SDK credentials_provider callable for OAuth modes, else None.
54+
55+
The Databricks SDK derives the token endpoint from the workspace host and handles
56+
refresh. ``connection_kwargs['server_hostname']`` has had any scheme stripped by the
57+
properties layer, so re-add ``https://`` for the SDK ``Config``.
58+
"""
59+
if not isinstance(config, (DatabricksOAuthM2M, DatabricksAzureServicePrincipal)):
60+
return None
61+
62+
from databricks.sdk.core import Config
63+
from databricks.sdk.credentials_provider import (
64+
azure_service_principal,
65+
oauth_service_principal,
3366
)
3467

68+
host = f"https://{connection_kwargs['server_hostname']}"
69+
70+
if isinstance(config, DatabricksOAuthM2M):
71+
sdk_config = Config(
72+
host=host,
73+
client_id=config.client_id,
74+
client_secret=config.client_secret.get_secret_value(),
75+
)
76+
header_factory = oauth_service_principal(sdk_config)
77+
auth_desc = "OAuth (M2M)"
78+
else:
79+
# DatabricksAzureServicePrincipal — Entra ID (Azure AD) service principal.
80+
sdk_config = Config(
81+
host=host,
82+
azure_client_id=config.azure_client_id,
83+
azure_client_secret=config.azure_client_secret.get_secret_value(),
84+
azure_tenant_id=config.azure_tenant_id,
85+
)
86+
header_factory = azure_service_principal(sdk_config)
87+
auth_desc = "Azure service principal"
88+
89+
# The SDK returns None when OIDC discovery yields no token endpoint. Fail loudly here
90+
# instead of returning None — otherwise the caller would silently degrade to a
91+
# credential-less PAT connect for a config the user explicitly marked OAuth.
92+
if header_factory is None:
93+
raise ValueError(
94+
f"Databricks {auth_desc} authentication setup failed: the SDK could not resolve "
95+
f"an OIDC token endpoint for host '{host}'. Verify the workspace host and credentials."
96+
)
97+
98+
# databricks-sql-connector's ExternalAuthProvider calls credentials_provider() once to
99+
# obtain the header factory, then invokes that per request. So credentials_provider must
100+
# be a zero-arg callable that RETURNS the SDK header factory, not the factory itself.
101+
return lambda: header_factory
102+
35103
def _fetch_session_timezone(self) -> tzinfo:
36104
with self.connection.cursor() as cursor:
37105
cursor.execute("SELECT current_timezone()")

soda-databricks/src/soda_databricks/model/data_source/databricks_connection_properties.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,47 @@ def to_connection_kwargs(self) -> dict:
3939

4040
class DatabricksTokenAuth(DatabricksSharedConnectionProperties):
4141
access_token: SecretStr = Field(..., description="Personal access token")
42+
43+
44+
class DatabricksOAuthM2M(DatabricksSharedConnectionProperties):
45+
"""Databricks-managed OAuth machine-to-machine (service principal) auth.
46+
47+
The token endpoint is derived from ``host`` by the Databricks SDK, so no
48+
``token_url`` is required. The connection layer builds a ``credentials_provider``
49+
from these fields via ``oauth_service_principal`` — they must NOT be emitted as
50+
plain ``sql.connect`` kwargs, hence the ``to_connection_kwargs`` override below.
51+
"""
52+
53+
client_id: str = Field(..., description="Databricks OAuth service-principal client ID")
54+
client_secret: SecretStr = Field(..., description="Databricks OAuth service-principal client secret")
55+
56+
# Consumed by the connection layer to build the credentials_provider, never passed to sql.connect.
57+
_credential_fields: ClassVar[tuple] = ("client_id", "client_secret")
58+
59+
def to_connection_kwargs(self) -> dict:
60+
connection_kwargs = super().to_connection_kwargs()
61+
for field_name in self._credential_fields:
62+
connection_kwargs.pop(field_name, None)
63+
return connection_kwargs
64+
65+
66+
class DatabricksAzureServicePrincipal(DatabricksSharedConnectionProperties):
67+
"""Microsoft Entra ID (Azure AD) service-principal auth for Azure Databricks.
68+
69+
Distinct from Databricks-managed OAuth M2M: the SP lives in an Entra app
70+
registration and the token comes from ``login.microsoftonline.com``, so a tenant
71+
ID is required. The connection layer builds a ``credentials_provider`` from these
72+
fields; they must NOT be emitted as plain ``sql.connect`` kwargs.
73+
"""
74+
75+
azure_client_id: str = Field(..., description="Entra ID (Azure AD) service-principal application/client ID")
76+
azure_client_secret: SecretStr = Field(..., description="Entra ID (Azure AD) service-principal client secret")
77+
azure_tenant_id: str = Field(..., description="Entra ID (Azure AD) directory/tenant ID")
78+
79+
_credential_fields: ClassVar[tuple] = ("azure_client_id", "azure_client_secret", "azure_tenant_id")
80+
81+
def to_connection_kwargs(self) -> dict:
82+
connection_kwargs = super().to_connection_kwargs()
83+
for field_name in self._credential_fields:
84+
connection_kwargs.pop(field_name, None)
85+
return connection_kwargs

soda-databricks/src/soda_databricks/model/data_source/databricks_data_source.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,24 @@
44
from pydantic import Field, field_validator
55
from soda_core.model.data_source.data_source import DataSourceBase
66
from soda_databricks.model.data_source.databricks_connection_properties import (
7+
DatabricksAzureServicePrincipal,
78
DatabricksConnectionProperties,
9+
DatabricksOAuthM2M,
810
DatabricksTokenAuth,
911
)
1012

13+
# Explicit auth_type discriminator values (Trino/Snowflake/BigQuery style). These literals
14+
# are the cross-repo contract: they match soda-library#759 and the Cloud connection form.
15+
AUTH_TYPE_TOKEN = "personal-access-token"
16+
AUTH_TYPE_OAUTH_M2M = "databricks-oauth-m2m"
17+
AUTH_TYPE_AZURE_SP = "azure-service-principal"
18+
19+
_AUTH_TYPE_TO_PROPERTIES = {
20+
AUTH_TYPE_TOKEN: DatabricksTokenAuth,
21+
AUTH_TYPE_OAUTH_M2M: DatabricksOAuthM2M,
22+
AUTH_TYPE_AZURE_SP: DatabricksAzureServicePrincipal,
23+
}
24+
1125

1226
class DatabricksDataSource(DataSourceBase, abc.ABC):
1327
type: Literal["databricks"] = Field("databricks")
@@ -18,6 +32,28 @@ class DatabricksDataSource(DataSourceBase, abc.ABC):
1832
@field_validator("connection_properties", mode="before")
1933
@classmethod
2034
def infer_connection_type(cls, value):
35+
# Already a resolved properties object (e.g. constructed in code) — pass through.
36+
if isinstance(value, DatabricksConnectionProperties):
37+
return value
38+
if not isinstance(value, dict):
39+
raise ValueError("Could not infer Databricks connection type from input")
40+
41+
# Copy so the discriminator can be stripped without mutating caller input; also
42+
# prevents auth_type leaking into sql.connect kwargs (base model has extra='allow').
43+
value = dict(value)
44+
auth_type = value.pop("auth_type", None)
45+
46+
if auth_type is not None:
47+
properties_class = _AUTH_TYPE_TO_PROPERTIES.get(auth_type)
48+
if properties_class is None:
49+
supported = ", ".join(sorted(_AUTH_TYPE_TO_PROPERTIES))
50+
raise ValueError(f"Unknown Databricks auth_type '{auth_type}'. Supported: {supported}")
51+
return properties_class(**value)
52+
53+
# Backward compatibility: no explicit auth_type → infer PAT from field presence.
2154
if "access_token" in value:
2255
return DatabricksTokenAuth(**value)
23-
raise ValueError("Could not infer Databricks connection type from input")
56+
raise ValueError(
57+
"Could not infer Databricks connection type: provide 'auth_type' "
58+
f"(one of {', '.join(sorted(_AUTH_TYPE_TO_PROPERTIES))}) or 'access_token'."
59+
)

soda-databricks/tests/data_sources/test_databricks.py

Lines changed: 139 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,55 +19,166 @@
1919
DATABRICKS_HTTP_PATH = os.getenv("DATABRICKS_HTTP_PATH")
2020
DATABRICKS_TOKEN = os.getenv("DATABRICKS_TOKEN")
2121
DATABRICKS_CATALOG = os.getenv("DATABRICKS_CATALOG", "unity_catalog")
22-
DATABRICKS_HOSTNAME_WITH_HTTPS = (
23-
f"https://{DATABRICKS_HOST}"
24-
if not (DATABRICKS_HOST.startswith("https://") or DATABRICKS_HOST.startswith("http://"))
25-
else DATABRICKS_HOST
22+
# OAuth service-principal creds (client-credentials M2M)
23+
DATABRICKS_CLIENT_ID = os.getenv("DATABRICKS_CLIENT_ID")
24+
DATABRICKS_CLIENT_SECRET = os.getenv("DATABRICKS_CLIENT_SECRET")
25+
# Azure Entra ID service-principal creds
26+
DATABRICKS_AZURE_CLIENT_ID = os.getenv("DATABRICKS_AZURE_CLIENT_ID")
27+
DATABRICKS_AZURE_CLIENT_SECRET = os.getenv("DATABRICKS_AZURE_CLIENT_SECRET")
28+
DATABRICKS_AZURE_TENANT_ID = os.getenv("DATABRICKS_AZURE_TENANT_ID")
29+
30+
31+
def _with_https(host: str | None) -> str | None:
32+
if host and "://" not in host:
33+
return f"https://{host}"
34+
return host
35+
36+
37+
DATABRICKS_HOSTNAME_WITH_HTTPS = _with_https(DATABRICKS_HOST)
38+
39+
# Which live-credential sets are available. Live cases are only added when their creds are
40+
# present, so the module imports and the no-credential (config-validation) cases below still
41+
# run in any environment.
42+
_has_pat = all([DATABRICKS_HOST, DATABRICKS_HTTP_PATH, DATABRICKS_TOKEN])
43+
_has_oauth_m2m = all([DATABRICKS_HOST, DATABRICKS_HTTP_PATH, DATABRICKS_CLIENT_ID, DATABRICKS_CLIENT_SECRET])
44+
_has_azure_sp = all(
45+
[
46+
DATABRICKS_HOST,
47+
DATABRICKS_HTTP_PATH,
48+
DATABRICKS_AZURE_CLIENT_ID,
49+
DATABRICKS_AZURE_CLIENT_SECRET,
50+
DATABRICKS_AZURE_TENANT_ID,
51+
]
2652
)
2753

2854
# define test cases and expected behavior (passing unless otherwise specified)
55+
# Config-validation cases run everywhere (no creds/live connection): they confirm auth_type
56+
# dispatch and required-field validation through the full YAML -> DataSourceImpl path.
2957
test_connections: list[TestConnection] = [
30-
TestConnection( # correct connection, should work
31-
test_name="correct_connection",
32-
connection_yaml_str=f"""
58+
TestConnection( # unknown discriminator must be rejected at parse time
59+
test_name="unknown_auth_type_rejected",
60+
connection_yaml_str="""
3361
type: databricks
3462
name: DATABRICKS_TEST
3563
connection:
36-
host: {DATABRICKS_HOST}
37-
http_path: {DATABRICKS_HTTP_PATH}
38-
access_token: {DATABRICKS_TOKEN}
39-
catalog: {DATABRICKS_CATALOG}
64+
host: abc.cloud.databricks.com
65+
http_path: /sql/1.0/endpoints/abc
66+
auth_type: not-a-real-mode
4067
""",
68+
valid_yaml=False,
69+
expected_yaml_error="Unknown Databricks auth_type",
4170
),
42-
TestConnection( # confirm session configuration is applied
43-
test_name="applies_session_configuration",
44-
connection_yaml_str=f"""
71+
TestConnection( # OAuth M2M without its required secret must be rejected at parse time
72+
test_name="oauth_m2m_missing_client_secret_rejected",
73+
connection_yaml_str="""
4574
type: databricks
4675
name: DATABRICKS_TEST
4776
connection:
48-
host: {DATABRICKS_HOST}
49-
http_path: {DATABRICKS_HTTP_PATH}
50-
access_token: {DATABRICKS_TOKEN}
51-
catalog: {DATABRICKS_CATALOG}
52-
session_configuration: {{"foo":"bar"}}
77+
host: abc.cloud.databricks.com
78+
http_path: /sql/1.0/endpoints/abc
79+
auth_type: databricks-oauth-m2m
80+
client_id: some-client-id
5381
""",
54-
query_should_succeed=False,
55-
expected_query_error="Configuration foo is not available.",
82+
valid_yaml=False,
83+
expected_yaml_error="client_secret",
5684
),
57-
TestConnection( # correct connection, should work
58-
test_name="connection_with_https_prefix",
59-
connection_yaml_str=f"""
85+
TestConnection( # Azure service principal without its tenant id must be rejected at parse time
86+
test_name="azure_service_principal_missing_tenant_rejected",
87+
connection_yaml_str="""
6088
type: databricks
6189
name: DATABRICKS_TEST
6290
connection:
63-
host: {DATABRICKS_HOSTNAME_WITH_HTTPS}
64-
http_path: {DATABRICKS_HTTP_PATH}
65-
access_token: {DATABRICKS_TOKEN}
66-
catalog: {DATABRICKS_CATALOG}
91+
host: adb-123.azuredatabricks.net
92+
http_path: /sql/1.0/endpoints/abc
93+
auth_type: azure-service-principal
94+
azure_client_id: some-client-id
95+
azure_client_secret: some-secret
6796
""",
97+
valid_yaml=False,
98+
expected_yaml_error="azure_tenant_id",
6899
),
69100
]
70101

102+
if _has_pat:
103+
test_connections += [
104+
TestConnection( # correct connection, should work
105+
test_name="correct_connection",
106+
connection_yaml_str=f"""
107+
type: databricks
108+
name: DATABRICKS_TEST
109+
connection:
110+
host: {DATABRICKS_HOST}
111+
http_path: {DATABRICKS_HTTP_PATH}
112+
access_token: {DATABRICKS_TOKEN}
113+
catalog: {DATABRICKS_CATALOG}
114+
""",
115+
),
116+
TestConnection( # confirm session configuration is applied
117+
test_name="applies_session_configuration",
118+
connection_yaml_str=f"""
119+
type: databricks
120+
name: DATABRICKS_TEST
121+
connection:
122+
host: {DATABRICKS_HOST}
123+
http_path: {DATABRICKS_HTTP_PATH}
124+
access_token: {DATABRICKS_TOKEN}
125+
catalog: {DATABRICKS_CATALOG}
126+
session_configuration: {{"foo":"bar"}}
127+
""",
128+
query_should_succeed=False,
129+
expected_query_error="Configuration foo is not available.",
130+
),
131+
TestConnection( # correct connection, should work
132+
test_name="connection_with_https_prefix",
133+
connection_yaml_str=f"""
134+
type: databricks
135+
name: DATABRICKS_TEST
136+
connection:
137+
host: {DATABRICKS_HOSTNAME_WITH_HTTPS}
138+
http_path: {DATABRICKS_HTTP_PATH}
139+
access_token: {DATABRICKS_TOKEN}
140+
catalog: {DATABRICKS_CATALOG}
141+
""",
142+
),
143+
]
144+
145+
if _has_oauth_m2m:
146+
test_connections.append(
147+
TestConnection( # live Databricks-managed OAuth M2M (service principal)
148+
test_name="oauth_m2m_connection",
149+
connection_yaml_str=f"""
150+
type: databricks
151+
name: DATABRICKS_TEST
152+
connection:
153+
host: {DATABRICKS_HOST}
154+
http_path: {DATABRICKS_HTTP_PATH}
155+
auth_type: databricks-oauth-m2m
156+
client_id: {DATABRICKS_CLIENT_ID}
157+
client_secret: {DATABRICKS_CLIENT_SECRET}
158+
catalog: {DATABRICKS_CATALOG}
159+
""",
160+
)
161+
)
162+
163+
if _has_azure_sp:
164+
test_connections.append(
165+
TestConnection( # live Azure Entra ID service principal
166+
test_name="azure_service_principal_connection",
167+
connection_yaml_str=f"""
168+
type: databricks
169+
name: DATABRICKS_TEST
170+
connection:
171+
host: {DATABRICKS_HOST}
172+
http_path: {DATABRICKS_HTTP_PATH}
173+
auth_type: azure-service-principal
174+
azure_client_id: {DATABRICKS_AZURE_CLIENT_ID}
175+
azure_client_secret: {DATABRICKS_AZURE_CLIENT_SECRET}
176+
azure_tenant_id: {DATABRICKS_AZURE_TENANT_ID}
177+
catalog: {DATABRICKS_CATALOG}
178+
""",
179+
)
180+
)
181+
71182

72183
# run tests. parameterization means each test case will show up as an individual test
73184
@pytest.mark.parametrize("test_connection", test_connections, ids=[tc.test_name for tc in test_connections])

0 commit comments

Comments
 (0)