This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 5cf8213354c fix(databricks): keep the user's OAuth2 token and extra 
connect_args; re-auth on HTTP 401 (#44705)
5cf8213354c is described below

commit 5cf8213354c74cff577d9832671ff66e0440c308
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Thu Oct 1 12:50:32 2026 +1000

    fix(databricks): keep the user's OAuth2 token and extra connect_args; 
re-auth on HTTP 401 (#44705)
---
 superset/db_engine_specs/databricks.py             |  91 +++++++++-
 .../unit_tests/db_engine_specs/test_databricks.py  | 202 ++++++++++++++++++++-
 2 files changed, 289 insertions(+), 4 deletions(-)

diff --git a/superset/db_engine_specs/databricks.py 
b/superset/db_engine_specs/databricks.py
index b572832fb3b..ebf4a3badb0 100644
--- a/superset/db_engine_specs/databricks.py
+++ b/superset/db_engine_specs/databricks.py
@@ -288,6 +288,25 @@ class DatabricksBaseEngineSpec(BaseEngineSpec):
         return super().extract_errors(ex, context, database_name)
 
 
+# Credential connect args of databricks-sql-connector. With impersonation on, 
all
+# of them are dropped from ``extra`` and the secure extra; ``impersonate_user``
+# then writes the user's OAuth2 token as ``access_token`` (when ``extra`` set 
one),
+# so that token is the only credential the connector receives.
+DATABRICKS_SHARED_CREDENTIAL_CONNECT_ARGS = frozenset(
+    {
+        "access_token",
+        "auth_type",
+        "credentials_provider",
+        "oauth_client_id",
+        "oauth_client_secret",
+        "azure_client_id",
+        "azure_client_secret",
+        "azure_tenant_id",
+        "azure_workspace_resource_id",
+    }
+)
+
+
 class DatabricksDynamicBaseEngineSpec(BasicParametersMixin, 
DatabricksBaseEngineSpec):
     default_driver = ""
     encryption_parameters = {"ssl": "1"}
@@ -382,7 +401,21 @@ class 
DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
         if isinstance(ex, cls.oauth2_exception):
             return True
         message = str(ex).lower()
-        return any(signal in message for signal in 
cls.oauth2_auth_failure_signals)
+        if any(signal in message for signal in 
cls.oauth2_auth_failure_signals):
+            return True
+        # A rejected bearer token fails the request with HTTP 401 and a message
+        # such as "Credential was not sent or was of an unsupported type for 
this
+        # API", which no signal above matches; the connector reports the status
+        # in ``RequestError.context["http-code"]``. 403 is left out on 
purpose: it
+        # also means a missing permission, which re-authorizing cannot fix.
+        error = getattr(ex, "orig", None) or ex
+        context = getattr(error, "context", None)
+        if isinstance(context, dict):
+            try:
+                return int(context.get("http-code") or 0) == 401
+            except (TypeError, ValueError):
+                return False
+        return False
 
     @classmethod
     def get_oauth2_authorization_uri(
@@ -459,14 +492,40 @@ class 
DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
         # back to an empty string to force re-authentication when none is set.
         url = url.set(password=user_token or "")
 
+        # Drop shared credentials from ``extra`` (a PAT, OAuth M2M client, 
Azure
+        # service principal) so none of them reaches the connector next to the
+        # user's token. The secure extra is filtered the same way in
+        # ``update_params_from_encrypted_extra``.
+        connect_args = engine_kwargs.get("connect_args") or {}
+        filtered_connect_args = {
+            key: value
+            for key, value in connect_args.items()
+            if key not in DATABRICKS_SHARED_CREDENTIAL_CONNECT_ARGS
+        }
         # The Python connector passes the token via ``connect_args`` instead 
of the
         # URL password, so keep it in sync (clearing it likewise forces 
re-auth).
-        connect_args = engine_kwargs.setdefault("connect_args", {})
         if "access_token" in connect_args:
-            connect_args["access_token"] = user_token or ""
+            filtered_connect_args["access_token"] = user_token or ""
+        engine_kwargs["connect_args"] = filtered_connect_args
 
         return url, engine_kwargs
 
+    @classmethod
+    def start_oauth2_dance(cls, database: Database) -> None:
+        """
+        Start the OAuth2 dance only when the database impersonates the user.
+
+        The user's OAuth2 token only reaches the connection through
+        ``impersonate_user``. Without impersonation the connection uses the
+        shared credential, so an authorization prompt cannot fix an auth 
failure
+        (e.g. a revoked shared token returning HTTP 401). Return instead, so 
the
+        caller raises the original error for an admin to act on.
+        """
+        if not database.impersonate_user:
+            return
+
+        super().start_oauth2_dance(database)
+
     @staticmethod
     def update_params_from_encrypted_extra(
         database: Database, params: dict[str, Any]
@@ -479,6 +538,14 @@ class 
DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
         consumed by ``Database.get_oauth2_config``; it is not a Databricks 
driver
         connection argument, so it must be stripped here to avoid poisoning the
         connection when OAuth2 is configured on the database itself.
+
+        ``connect_args`` are merged key by key, so credentials kept in the 
secure
+        extra (an access token, an OAuth M2M client secret) do not discard the
+        ``connect_args`` from ``extra`` or Superset's own (e.g. the 
User-Agent).
+        With user impersonation enabled, shared credentials in the secure extra
+        are dropped (``impersonate_user`` drops the ones from ``extra``): the
+        user's OAuth2 token is the only credential, instead of being silently
+        replaced by a shared one.
         """
         if not database.encrypted_extra:
             return
@@ -488,7 +555,25 @@ class 
DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
             logger.error(ex, exc_info=True)
             raise
         encrypted_extra.pop("oauth2_client_info", None)
+        secure_connect_args = encrypted_extra.pop("connect_args", None)
         params.update(encrypted_extra)
+        if secure_connect_args is not None and not isinstance(
+            secure_connect_args, dict
+        ):
+            params["connect_args"] = secure_connect_args
+            return
+        if secure_connect_args is None and "connect_args" not in params:
+            return
+
+        secure_connect_args = secure_connect_args or {}
+        connect_args = dict(params.get("connect_args") or {})
+        if database.impersonate_user:
+            secure_connect_args = {
+                key: value
+                for key, value in secure_connect_args.items()
+                if key not in DATABRICKS_SHARED_CREDENTIAL_CONNECT_ARGS
+            }
+        params["connect_args"] = {**connect_args, **secure_connect_args}
 
     @staticmethod
     def get_extra_params(
diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py 
b/tests/unit_tests/db_engine_specs/test_databricks.py
index cd1ac465d8b..fb06011bf8f 100644
--- a/tests/unit_tests/db_engine_specs/test_databricks.py
+++ b/tests/unit_tests/db_engine_specs/test_databricks.py
@@ -20,6 +20,7 @@ from datetime import datetime
 from importlib import import_module
 from importlib.metadata import PackageNotFoundError, requires
 from typing import Any, Optional
+from unittest.mock import MagicMock
 from urllib.parse import parse_qs, urlparse
 
 import pytest
@@ -28,7 +29,7 @@ from pytest_mock import MockerFixture
 from sqlalchemy import __version__ as sqlalchemy_version, create_engine, text
 from sqlalchemy.engine.url import make_url
 
-from superset.db_engine_specs.base import OAuth2State
+from superset.db_engine_specs.base import BaseEngineSpec, OAuth2State
 from superset.db_engine_specs.databricks import (
     DatabricksNativeEngineSpec,
     DatabricksPythonConnectorEngineSpec,
@@ -509,6 +510,48 @@ def test_needs_oauth2_matches_oauth2_redirect_error(
     assert spec.needs_oauth2(ex) is True
 
 
+class _ConnectorRequestError(Exception):
+    """Stand-in for ``databricks.sql.exc.RequestError`` (message + context)."""
+
+    def __init__(self, message: str, context: dict[str, Any]) -> None:
+        super().__init__(message)
+        self.context = context
+
+
[email protected](
+    "spec",
+    [DatabricksNativeEngineSpec, DatabricksPythonConnectorEngineSpec],
+)
[email protected](
+    "http_code, expected",
+    [(401, True), ("401", True), (403, False), (None, False)],
+)
+def test_needs_oauth2_detects_http_401_from_the_connector(
+    mocker: MockerFixture,
+    spec: Any,
+    http_code: Any,
+    expected: bool,
+) -> None:
+    """
+    A rejected bearer token fails with HTTP 401 and a message that matches no
+    signal ("Credential was not sent or was of an unsupported type for this
+    API"); the connector's structured ``http-code`` triggers the re-auth, also
+    when SQLAlchemy wraps the error. 403 (a missing permission) does not.
+    """
+    g = mocker.patch("superset.db_engine_specs.databricks.g")
+    g.user = mocker.MagicMock()
+    error = _ConnectorRequestError(
+        "Error during request to server: : Credential was not sent or was of 
an "
+        "unsupported type for this API.",
+        {"method": "OpenSession", "http-code": http_code},
+    )
+    wrapped = mocker.MagicMock(orig=error)
+    wrapped.__str__ = lambda self: str(error)
+
+    assert spec.needs_oauth2(error) is expected
+    assert spec.needs_oauth2(wrapped) is expected
+
+
 def test_impersonate_user_with_token(mocker: MockerFixture) -> None:
     """
     Test impersonate_user method with OAuth2 token for 
DatabricksNativeEngineSpec.
@@ -697,6 +740,163 @@ def test_update_params_merges_when_no_oauth2_client_info(
     }
 
 
+def test_update_params_merges_connect_args(mocker: MockerFixture) -> None:
+    """
+    Secure ``connect_args`` (e.g. OAuth M2M credentials) are merged key by key
+    into the ``connect_args`` from ``extra`` instead of replacing them.
+    """
+    database: MagicMock = mocker.MagicMock()
+    database.impersonate_user = False
+    database.encrypted_extra = json.dumps(
+        {"connect_args": {"oauth_client_id": "sp", "oauth_client_secret": 
"secret"}}
+    )
+    params: dict[str, Any] = {
+        "connect_args": {
+            "_user_agent_entry": "Apache Superset",
+            "session_configuration": {"TIMEZONE": "Asia/Kolkata"},
+        }
+    }
+
+    DatabricksPythonConnectorEngineSpec.update_params_from_encrypted_extra(
+        database, params
+    )
+
+    assert params == {
+        "connect_args": {
+            "_user_agent_entry": "Apache Superset",
+            "session_configuration": {"TIMEZONE": "Asia/Kolkata"},
+            "oauth_client_id": "sp",
+            "oauth_client_secret": "secret",
+        }
+    }
+
+
+def test_update_params_impersonation_keeps_only_the_user_token(
+    mocker: MockerFixture,
+) -> None:
+    """
+    With impersonation, a shared credential in the secure extra (or in
+    ``extra``) must not replace the user's OAuth2 token set by
+    ``impersonate_user``.
+    """
+    database: MagicMock = mocker.MagicMock()
+    database.impersonate_user = True
+    database.encrypted_extra = json.dumps(
+        {"connect_args": {"access_token": "shared-pat", "http_path": "/sql/1"}}
+    )
+    engine_kwargs: dict[str, Any] = {
+        "connect_args": {"access_token": "extra-pat", "oauth_client_secret": 
"s"}
+    }
+
+    # Same order as ``Database._get_sqla_engine``.
+    _, engine_kwargs = DatabricksPythonConnectorEngineSpec.impersonate_user(
+        database=database,
+        username="user1",
+        user_token="user-token",  # noqa: S106
+        url=make_url("databricks://token:extra-pat@host:443?http_path=/sql/1"),
+        engine_kwargs=engine_kwargs,
+    )
+    DatabricksPythonConnectorEngineSpec.update_params_from_encrypted_extra(
+        database, engine_kwargs
+    )
+
+    assert engine_kwargs == {
+        "connect_args": {"access_token": "user-token", "http_path": "/sql/1"}
+    }
+
+
+def test_impersonate_user_drops_shared_credentials_without_secure_extra(
+    mocker: MockerFixture,
+) -> None:
+    """
+    Shared credentials in ``extra`` are dropped even when the database has no
+    secure extra (e.g. OAuth2 configured through ``DATABASE_OAUTH2_CLIENTS``),
+    so only the user's token reaches the connector.
+    """
+    database: MagicMock = mocker.MagicMock()
+    database.impersonate_user = True
+    database.encrypted_extra = None
+    engine_kwargs: dict[str, Any] = {
+        "connect_args": {
+            "_user_agent_entry": "Apache Superset",
+            "auth_type": "databricks-oauth",
+            "oauth_client_id": "sp",
+            "oauth_client_secret": "secret",
+        }
+    }
+
+    url, engine_kwargs = DatabricksPythonConnectorEngineSpec.impersonate_user(
+        database=database,
+        username="user1",
+        user_token="user-oauth-token",  # noqa: S106
+        url=make_url("databricks://token:@host:443?http_path=/sql/1"),
+        engine_kwargs=engine_kwargs,
+    )
+    DatabricksPythonConnectorEngineSpec.update_params_from_encrypted_extra(
+        database, engine_kwargs
+    )
+
+    assert url.password == "user-oauth-token"  # noqa: S105
+    assert engine_kwargs == {"connect_args": {"_user_agent_entry": "Apache 
Superset"}}
+
+
[email protected](
+    "spec",
+    [DatabricksNativeEngineSpec, DatabricksPythonConnectorEngineSpec],
+)
+def test_start_oauth2_dance_requires_impersonation(
+    mocker: MockerFixture,
+    spec: Any,
+) -> None:
+    """
+    Without impersonation the user's token is never used, so an auth failure
+    (e.g. a revoked shared token) does not send the user through an
+    authorization prompt; the caller raises the original error instead.
+    """
+    base_start = mocker.patch.object(BaseEngineSpec, "start_oauth2_dance")
+    database: MagicMock = mocker.MagicMock()
+    database.impersonate_user = False
+
+    spec.start_oauth2_dance(database)
+    base_start.assert_not_called()
+
+    database.impersonate_user = True
+    spec.start_oauth2_dance(database)
+    base_start.assert_called_once_with(database)
+
+
+def test_impersonation_with_shared_token_only_in_secure_extra(
+    mocker: MockerFixture,
+) -> None:
+    """
+    With the shared token only in the secure extra, the Python connector still
+    authenticates as the user: ``impersonate_user`` puts the user's token in 
the
+    URL password, which the dialect uses as ``access_token``, and the shared
+    token is dropped rather than overriding it.
+    """
+    database: MagicMock = mocker.MagicMock()
+    database.impersonate_user = True
+    database.encrypted_extra = json.dumps(
+        {"connect_args": {"access_token": "shared-pat"}}
+    )
+    engine_kwargs: dict[str, Any] = {"connect_args": {"http_path": "/sql/1"}}
+
+    # Same order as ``Database._get_sqla_engine``.
+    url, engine_kwargs = DatabricksPythonConnectorEngineSpec.impersonate_user(
+        database=database,
+        username="user1",
+        user_token="user-oauth-token",  # noqa: S106
+        url=make_url("databricks://token:@host:443?http_path=/sql/1"),
+        engine_kwargs=engine_kwargs,
+    )
+    DatabricksPythonConnectorEngineSpec.update_params_from_encrypted_extra(
+        database, engine_kwargs
+    )
+
+    assert url.password == "user-oauth-token"  # noqa: S105
+    assert engine_kwargs["connect_args"] == {"http_path": "/sql/1"}
+
+
 def test_update_params_invalid_encrypted_extra_raises(mocker: MockerFixture) 
-> None:
     """
     Malformed ``encrypted_extra`` JSON raises rather than silently connecting.

Reply via email to