From 6c2fef29cb7a781b2e451b2731cfd8a889dc6299 Mon Sep 17 00:00:00 2001 From: htamakos Date: Tue, 25 Aug 2026 13:22:14 +0900 Subject: [PATCH] feat(snowflake): Add support for OAuth 2.0 authentication (#36856) Co-authored-by: Evan Rusackas Co-authored-by: Evan Rusackas Co-authored-by: Claude Co-authored-by: Joe Li --- .../databases/DatabaseModal/ExtraOptions.tsx | 7 +- superset/db_engine_specs/snowflake.py | 189 ++++++++++- .../db_engine_specs/test_bigquery.py | 25 +- .../db_engine_specs/test_snowflake.py | 303 +++++++++++++++++- tests/unit_tests/sql_lab_test.py | 143 +++++---- 5 files changed, 592 insertions(+), 75 deletions(-) diff --git a/superset-frontend/src/features/databases/DatabaseModal/ExtraOptions.tsx b/superset-frontend/src/features/databases/DatabaseModal/ExtraOptions.tsx index 60038e5f911..cb5931bc58d 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/ExtraOptions.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/ExtraOptions.tsx @@ -522,7 +522,7 @@ const ExtraOptions = ({ onChange={onInputChange} > {t( - 'Impersonate logged in user (Presto, Trino, Drill, Hive, Databricks, and Google Sheets)', + 'Impersonate logged in user (Presto, Trino, Drill, Hive, Databricks, Snowflake and Google Sheets)', )} diff --git a/superset/db_engine_specs/snowflake.py b/superset/db_engine_specs/snowflake.py index 6025bb7adc0..c0641ad3334 100644 --- a/superset/db_engine_specs/snowflake.py +++ b/superset/db_engine_specs/snowflake.py @@ -20,21 +20,23 @@ import logging import re from datetime import datetime from re import Pattern -from typing import Any, Callable, Optional, TYPE_CHECKING, TypedDict +from typing import Any, Callable, cast, Optional, TYPE_CHECKING, TypedDict from urllib import parse from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization -from flask import current_app as app +from flask import current_app as app, has_request_context from flask_babel import gettext as __ from marshmallow import fields, Schema from sqlalchemy import text, types from sqlalchemy.engine.reflection import Inspector from sqlalchemy.engine.url import URL +from sqlalchemy.exc import DatabaseError as SqlalchemyDatabaseError from sqlalchemy.sql.elements import ColumnElement +from superset import is_feature_enabled, security_manager from superset.constants import TimeGrain from superset.databases.utils import make_url_safe from superset.db_engine_specs.base import ( @@ -44,13 +46,63 @@ from superset.db_engine_specs.base import ( ) from superset.db_engine_specs.postgres import PostgresBaseEngineSpec from superset.errors import ErrorLevel, SupersetError, SupersetErrorType +from superset.exceptions import OAuth2TokenRefreshError from superset.models.sql_lab import Query +from superset.superset_typing import ( + OAuth2ClientConfig, + OAuth2State, +) from superset.utils import json from superset.utils.core import get_user_agent, QuerySource +from superset.utils.oauth2 import encode_oauth2_state, generate_code_challenge if TYPE_CHECKING: from superset.models.core import Database +try: + from snowflake.connector.errors import DatabaseError +except ImportError: + # Use a distinct sentinel type when snowflake is not installed to avoid + # matching unrelated exception types (using `Exception` would be too broad). + class _SnowflakeDatabaseError(Exception): + """Sentinel type to stand in for snowflake.connector.errors.DatabaseError.""" + + pass + + DatabaseError = _SnowflakeDatabaseError + + +class CustomSnowflakeAuthErrorMeta(type): + """ + Metaclass whose ``__instancecheck__`` matches Snowflake's invalid/expired + OAuth access-token error, so ``CustomSnowflakeAuthError`` can be used as the + ``oauth2_exception`` that triggers the OAuth2 re-auth dance. + + This is only honored via ``isinstance()`` (the path used by + ``BaseEngineSpec.needs_oauth2()``); ``except`` clauses do not call + ``__instancecheck__``, so it must not be relied on for exception catching. + """ + + def __instancecheck__(cls, instance: object) -> bool: + """ + Match Snowflake's invalid/expired OAuth token error, whether it arrives + wrapped by SQLAlchemy (e.g. ``Engine``-based execution) or as the raw + DBAPI exception — ``BaseEngineSpec.execute()`` runs against a bare + cursor and never wraps it, so both shapes must be handled here. + """ + orig: object = instance + if isinstance(instance, SqlalchemyDatabaseError): + orig = cast(SqlalchemyDatabaseError, instance).orig + + return isinstance(orig, DatabaseError) and "Invalid OAuth access token" in str( + orig + ) + + +class CustomSnowflakeAuthError(DatabaseError, metaclass=CustomSnowflakeAuthErrorMeta): + """Snowflake OAuth error type matched via the metaclass above (see note there).""" + + # Regular expressions to catch custom errors OBJECT_DOES_NOT_EXIST_REGEX = re.compile( r"Object (?P.*?) does not exist or not authorized." @@ -160,6 +212,7 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec): encrypted_extra_sensitive_fields = { "$.auth_params.privatekey_body": "Private Key Body", "$.auth_params.privatekey_pass": "Private Key Password", + "$.oauth2_client_info.secret": "OAuth2 Client Secret", } _time_grain_expressions = { @@ -198,6 +251,126 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec): ), } + # OAuth 2.0 support + supports_oauth2: bool = True + # `CustomSnowflakeAuthError` is only matched via `isinstance()` (see the + # metaclass docstring above), so it's paired with `OAuth2TokenRefreshError` + # (a real subclass) to keep `refresh_oauth2_token`'s `except` clause working. + oauth2_exception: type[Exception] | tuple[type[Exception], ...] = ( + CustomSnowflakeAuthError, + OAuth2TokenRefreshError, + ) + + @classmethod + def is_oauth2_enabled(cls) -> bool: + """ + Return whether OAuth2 authentication is enabled. + """ + + # When alerts or reports connect to the database in the background, + # OAuth2 authentication fails; therefore, OAuth2 authentication is disabled + # for background execution. + if not has_request_context(): + return False + + return ( + cls.supports_oauth2 + and cls.engine_name in app.config["DATABASE_OAUTH2_CLIENTS"] + ) + + @classmethod + def get_oauth2_config(cls) -> OAuth2ClientConfig | None: + """ + Build the DB engine spec level OAuth2 client config. + """ + if not cls.is_oauth2_enabled(): + return None + + return super().get_oauth2_config() + + @classmethod + def impersonate_user( + cls, + database: Database, + username: str | None, + user_token: str | None, + url: URL, + engine_kwargs: dict[str, Any], + ) -> tuple[URL, dict[str, Any]]: + """ + Modify URL and/or engine kwargs to impersonate a different user. + """ + connect_args: dict[str, Any] = engine_kwargs.setdefault("connect_args", {}) + + # When test_connection is executed (i.e., when validate_default_parameters is + # set to True in connect_args), authentication via OAuth is not performed. + # + # ``database.is_oauth2_enabled()`` returns True for a database-level OAuth2 + # client (``encrypted_extra.oauth2_client_info``) regardless of request + # context, unlike the app-config-based check in ``is_oauth2_enabled()`` + # above. Background executions (alerts/reports) have no per-user token, so + # ``has_request_context()`` must be checked explicitly here too, or OAuth + # gets switched on with no token to send. + if ( + not connect_args.get("validate_default_parameters", False) + and has_request_context() + and database.is_oauth2_enabled() + ): + url = url.update_query_dict({"authenticator": "oauth"}) + connect_args["authenticator"] = "oauth" + + if user_token: + if username is not None: + if is_feature_enabled("IMPERSONATE_WITH_EMAIL_PREFIX"): + # ``Database._get_sqla_engine()`` has already looked + # up the login and substituted the email prefix into + # ``username`` before calling this method when this + # flag is on. Looking it up again here as if it were + # still the login would fail whenever the two differ, + # leaving the default/service-account username paired + # with this user's OAuth token. Use it as given. + url = url.set(username=username) + else: + user = security_manager.find_user(username=username) + if user and user.email: + url = url.set(username=user.email) + + url = url.update_query_dict({"token": user_token}) + + return url, engine_kwargs + + @classmethod + def get_oauth2_authorization_uri( + cls, + config: OAuth2ClientConfig, + state: OAuth2State, + code_verifier: str | None = None, # pylint: disable=unused-argument + ) -> str: + """ + Return URI for initial OAuth2 request. + """ + uri = config["authorization_request_uri"] + # When calling the Snowflake OAuth authorization endpoint for a custom client, + # specify only the query parameters documented in the URL below. + # Adding unsupported parameters + # (e.g., `prompt` as used in BaseEngineSpec.get_oauth2_authorization_uri) + # will cause an error. + # https://docs.snowflake.com/user-guide/oauth-custom#query-parameters + params: dict[str, str] = { + "scope": config["scope"], + "response_type": "code", + "state": encode_oauth2_state(state), + "redirect_uri": config["redirect_uri"], + "client_id": config["id"], + } + + # Add PKCE parameters (RFC 7636) if code_verifier is provided + if code_verifier: + params["code_challenge"] = generate_code_challenge(code_verifier) + params["code_challenge_method"] = "S256" + + return parse.urljoin(uri, "?" + parse.urlencode(params)) + @staticmethod def get_extra_params( database: Database, source: QuerySource | None = None @@ -448,6 +621,18 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec): database: "Database", params: dict[str, Any], ) -> None: + # To use OAuth authentication, a database connection must first be created using + # another authenticator (typically key-pair authentication) + # with “Impersonate logged in user” enabled. + # Key-pair authentication is used for connection tests, + # while OAuth authentication is used when executing actual queries, + # such as in SQL Lab or dashboards. + # Therefore, when using OAuth authentication, the key-pair authentication + # settings are not loaded, and the connection is established using OAuth only. + connect_args: dict[str, Any] = params.get("connect_args") or {} + if connect_args.get("authenticator") == "oauth": + return + if not database.encrypted_extra: return try: diff --git a/tests/unit_tests/db_engine_specs/test_bigquery.py b/tests/unit_tests/db_engine_specs/test_bigquery.py index a1afed11b98..a35eaa0a893 100644 --- a/tests/unit_tests/db_engine_specs/test_bigquery.py +++ b/tests/unit_tests/db_engine_specs/test_bigquery.py @@ -715,12 +715,33 @@ def _patch_bq_fetch_deps( mocker: MockerFixture, max_mb: int = 200 ) -> tuple[mock.MagicMock, mock.MagicMock]: """Helper to patch Flask g and current_app for BigQuery fetch_data tests.""" - flask_g = mocker.patch("superset.db_engine_specs.bigquery.g") - app = mocker.patch("superset.db_engine_specs.bigquery.current_app") + # `new_callable=mock.MagicMock` is pinned explicitly rather than relying on + # ``mocker.patch``'s auto-detection of the mock class. That detection + # inspects whatever object currently sits at the patched attribute, so if + # an earlier test in the same session ever leaves an ``AsyncMock`` there + # (e.g. an improperly torn-down patch), every subsequent patch of the same + # attribute -- even ones created fresh here -- would also become an + # ``AsyncMock``, since ``AsyncMock`` classifies its own non-dunder child + # attributes as ``AsyncMock`` too. Pinning the callable sidesteps that + # self-perpetuating class inference entirely. + flask_g = mocker.patch( + "superset.db_engine_specs.bigquery.g", new_callable=mock.MagicMock + ) + app = mocker.patch( + "superset.db_engine_specs.bigquery.current_app", new_callable=mock.MagicMock + ) # Make current_app truthy and .config.get() return a plain int app.__bool__ = mock.Mock(return_value=True) app.config = mock.MagicMock() app.config.get = mock.Mock(return_value=max_mb) + # ``fetch_data`` only records ``g.bq_memory_limited*`` when + # ``has_request_context()`` is true. Outside of a real Flask request + # (as in these unit tests) that's always false, so without patching it + # the assignments never happen and the mocked ``g`` attributes stay + # unset child mocks instead of the expected booleans/counts. + mocker.patch( + "superset.db_engine_specs.bigquery.has_request_context", return_value=True + ) return flask_g, app diff --git a/tests/unit_tests/db_engine_specs/test_snowflake.py b/tests/unit_tests/db_engine_specs/test_snowflake.py index 3f166b22650..d41c8cbc4b7 100644 --- a/tests/unit_tests/db_engine_specs/test_snowflake.py +++ b/tests/unit_tests/db_engine_specs/test_snowflake.py @@ -23,9 +23,11 @@ from unittest import mock import pytest from pytest_mock import MockerFixture -from sqlalchemy.engine.url import make_url +from sqlalchemy.engine.url import make_url, URL +from superset.app import SupersetApp from superset.errors import ErrorLevel, SupersetError, SupersetErrorType +from superset.superset_typing import OAuth2ClientConfig from superset.utils import json from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm from tests.unit_tests.fixtures.common import dttm # noqa: F401 @@ -350,6 +352,30 @@ def test_mask_encrypted_extra() -> None: ) +def test_mask_encrypted_extra_oauth2_client_secret() -> None: + """ + The database-level OAuth2 client secret must be masked in + ``masked_encrypted_extra``, matching the other engine specs supporting + the same ``oauth2_client_info`` path (gsheets, trino) -- otherwise a + database editor can read it back unmasked. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + config = json.dumps( + { + "auth_method": "oauth2", + "oauth2_client_info": {"id": "client-id", "secret": "my-secret"}, + } + ) + + assert SnowflakeEngineSpec.mask_encrypted_extra(config) == json.dumps( + { + "auth_method": "oauth2", + "oauth2_client_info": {"id": "client-id", "secret": "XXXXXXXXXX"}, + } + ) + + def test_mask_encrypted_extra_no_fields() -> None: """ Test that the private key is masked when the database is edited. @@ -462,3 +488,278 @@ def test_unmask_encrypted_extra() -> None: }, } ) + + +@pytest.fixture +def oauth2_config() -> OAuth2ClientConfig: + """ + Config for Snowflake OAuth2. + """ + return { + "id": "snowflake-oauth2-client-id", + "secret": "snowflake-oauth2-client-secret", + "scope": "refresh_token", + "redirect_uri": "http://localhost:8088/api/v1/database/oauth2/", + "authorization_request_uri": "https://snowflake.oauth2.example/oauth/authorize", + "token_request_uri": "https://snowflake.oauth2.example/oauth/token-request", + "request_content_type": "data", + } + + +def test_get_oauth2_token( + mocker: MockerFixture, + oauth2_config: OAuth2ClientConfig, +) -> None: + """ + Test `get_oauth2_token`. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + + requests: mock.MagicMock = mocker.patch("superset.db_engine_specs.base.requests") + requests.post().json.return_value = { + "access_token": "access-token", + "expires_in": 3600, + "scope": "scope", + "token_type": "Bearer", + "refresh_token": "refresh-token", + } + + assert SnowflakeEngineSpec.get_oauth2_token(oauth2_config, "code") == { + "access_token": "access-token", + "expires_in": 3600, + "scope": "scope", + "token_type": "Bearer", + "refresh_token": "refresh-token", + } + requests.post.assert_called_with( + "https://snowflake.oauth2.example/oauth/token-request", + data={ + "code": "code", + "client_id": "snowflake-oauth2-client-id", + "client_secret": "snowflake-oauth2-client-secret", + "redirect_uri": "http://localhost:8088/api/v1/database/oauth2/", + "grant_type": "authorization_code", + }, + timeout=30.0, + ) + + +def test_impersonate_user(app: SupersetApp, mocker: MockerFixture) -> None: + """ + Test that Snowflake supports user impersonation. + + Impersonation only applies within a request context (see + ``test_impersonate_user_outside_request_context`` below for the + background-execution case), so these assertions run inside one. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + from superset.models.core import Database + + database: Database = Database(sqlalchemy_uri="snowflake://abc") + + mocker.patch( + "superset.db_engine_specs.snowflake.SnowflakeEngineSpec.is_oauth2_enabled", + return_value=True, + ) + + with app.test_request_context("/some/place/"): + assert SnowflakeEngineSpec.impersonate_user( + database=database, + username=None, + user_token=None, + url=make_url("snowflake://user:pass@account/database_name/default"), + engine_kwargs={ + "connect_args": { + "validate_default_parameters": True, + }, + }, + ) == ( + make_url("snowflake://user:pass@account/database_name/default"), + {"connect_args": {"validate_default_parameters": True}}, + ) + + assert SnowflakeEngineSpec.impersonate_user( + database=database, + username=None, + user_token=None, + url=make_url("snowflake://user:pass@account/database_name/default"), + engine_kwargs={}, + ) == ( + make_url( + "snowflake://user:pass@account/database_name/default?authenticator=oauth" + ), + {"connect_args": {"authenticator": "oauth"}}, + ) + + mocker.patch( + "superset.db_engine_specs.snowflake.is_feature_enabled", + return_value=True, + ) + + mocker.patch( + "superset.security_manager.find_user", + return_value=mocker.MagicMock(email="impersonated_user@example.com"), + ) + assert SnowflakeEngineSpec.impersonate_user( + database=database, + username="impersonated_user", + user_token="test_token", # noqa: S106 + url=make_url("snowflake://user:pass@account/database_name/default"), + engine_kwargs={}, + ) == ( + make_url( + "snowflake://impersonated_user:pass@account/database_name/default?authenticator=oauth&token=test_token" + ), + {"connect_args": {"authenticator": "oauth"}}, + ) + + +def test_impersonate_user_email_prefix_uses_username_directly( + app: SupersetApp, mocker: MockerFixture +) -> None: + """ + With IMPERSONATE_WITH_EMAIL_PREFIX enabled, ``Database._get_sqla_engine()`` + has already substituted the email prefix for the login username before + calling ``impersonate_user`` -- the value it passes in is no longer a + lookupable login. Re-looking it up as a username (the pre-fix behavior) + fails whenever the login differs from the prefix, silently leaving the + default/service-account username paired with the impersonated user's + OAuth token instead of failing loudly. The fixed code must use the given + value directly and must not call ``find_user`` at all in this branch. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + from superset.models.core import Database + + database: Database = Database(sqlalchemy_uri="snowflake://abc") + + mocker.patch( + "superset.db_engine_specs.snowflake.SnowflakeEngineSpec.is_oauth2_enabled", + return_value=True, + ) + mocker.patch( + "superset.db_engine_specs.snowflake.is_feature_enabled", + return_value=True, + ) + find_user = mocker.patch("superset.security_manager.find_user") + + with app.test_request_context("/some/place/"): + # "jdoe" is the email prefix Database._get_sqla_engine() already + # derived; the login it derived it from ("jdoe123", say) is gone by + # this point and must not be re-derived here. + result = SnowflakeEngineSpec.impersonate_user( + database=database, + username="jdoe", + user_token="test_token", # noqa: S106 + url=make_url("snowflake://user:pass@account/database_name/default"), + engine_kwargs={}, + ) + + assert result == ( + make_url( + "snowflake://jdoe:pass@account/database_name/default?authenticator=oauth&token=test_token" + ), + {"connect_args": {"authenticator": "oauth"}}, + ) + find_user.assert_not_called() + + +def test_impersonate_user_outside_request_context(mocker: MockerFixture) -> None: + """ + Background executions (alerts/reports) have no per-user token, so OAuth + impersonation must not engage outside a request context — even when + ``database.is_oauth2_enabled()`` returns True because of a + database-level OAuth2 client config, which (unlike the app-config-based + check) isn't itself request-context-aware. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + from superset.models.core import Database + + database: Database = Database(sqlalchemy_uri="snowflake://abc") + mocker.patch.object(Database, "is_oauth2_enabled", return_value=True) + + url: URL = make_url("snowflake://user:pass@account/database_name/default") + assert SnowflakeEngineSpec.impersonate_user( + database=database, + username=None, + user_token="test_token", # noqa: S106 + url=url, + engine_kwargs={}, + ) == (url, {"connect_args": {}}) + + +def test_custom_snowflake_auth_error_matches_raw_dbapi_exception() -> None: + """ + `BaseEngineSpec.execute()` runs against a bare DBAPI cursor, so the + exception it sees is the raw Snowflake error, never wrapped by + SQLAlchemy. `CustomSnowflakeAuthError` must still recognize it so the + OAuth2 re-auth dance triggers for SQL Lab queries. + """ + from superset.db_engine_specs.snowflake import ( + CustomSnowflakeAuthError, + DatabaseError, + ) + + raw_error: Exception = DatabaseError("250001: Invalid OAuth access token.") + assert isinstance(raw_error, CustomSnowflakeAuthError) + + +def test_custom_snowflake_auth_error_matches_sqlalchemy_wrapped_exception() -> None: + """ + Some call sites execute through SQLAlchemy's `Engine`, which wraps the + original DBAPI exception in `sqlalchemy.exc.DatabaseError.orig`. + `CustomSnowflakeAuthError` must keep matching this shape too. + """ + from sqlalchemy.exc import DatabaseError as SqlalchemyDatabaseError + + from superset.db_engine_specs.snowflake import ( + CustomSnowflakeAuthError, + DatabaseError, + ) + + wrapped_error: SqlalchemyDatabaseError = SqlalchemyDatabaseError( + statement="SELECT 1", + params=None, + orig=DatabaseError("250001: Invalid OAuth access token."), + ) + assert isinstance(wrapped_error, CustomSnowflakeAuthError) + + +def test_custom_snowflake_auth_error_does_not_match_unrelated_errors() -> None: + """ + Other Snowflake DB errors, and non-Snowflake exceptions, must not be + mistaken for an expired OAuth token. + """ + from superset.db_engine_specs.snowflake import ( + CustomSnowflakeAuthError, + DatabaseError, + ) + + assert not isinstance( + DatabaseError("Object FOO does not exist."), CustomSnowflakeAuthError + ) + assert not isinstance( + ValueError("Invalid OAuth access token."), CustomSnowflakeAuthError + ) + + +def test_snowflake_oauth2_exception_catches_refresh_token_error() -> None: + """ + `refresh_oauth2_token()` catches failures from the (unoverridden) base + `get_oauth2_fresh_token()` with `except db_engine_spec.oauth2_exception`. + That base method raises `OAuth2TokenRefreshError`, which isn't related to + `CustomSnowflakeAuthError` by real subclassing, so `oauth2_exception` must + include it directly -- an `except` clause never triggers the metaclass's + `__instancecheck__`, unlike `isinstance()`. + """ + from superset.db_engine_specs.snowflake import SnowflakeEngineSpec + from superset.exceptions import OAuth2TokenRefreshError + + try: + raise OAuth2TokenRefreshError("refresh token revoked") + except SnowflakeEngineSpec.oauth2_exception: + pass + else: + pytest.fail( + "OAuth2TokenRefreshError must be caught by " + "SnowflakeEngineSpec.oauth2_exception" + ) diff --git a/tests/unit_tests/sql_lab_test.py b/tests/unit_tests/sql_lab_test.py index bb9a2c1cbb8..008a81441c8 100644 --- a/tests/unit_tests/sql_lab_test.py +++ b/tests/unit_tests/sql_lab_test.py @@ -465,86 +465,93 @@ def test_get_sql_results_oauth2(mocker: MockerFixture, app) -> None: """ Test that `get_sql_results` works with OAuth2. """ + # Pushed/popped manually (rather than via a ``with`` block) so the + # ``finally`` below still pops it if an assertion fails, preventing the + # request context from leaking into later tests in the same session. app_context = app.test_request_context() app_context.push() - mocker.patch( - "superset.db_engine_specs.base.uuid4", - return_value=UUID("fb11f528-6eba-4a8a-837e-6b0d39ee9187"), - ) - mocker.patch( - "superset.db_engine_specs.base.generate_code_verifier", - return_value="xkBPVZoFChVcy3VZ2l5u7d0FZPTU-olO7HtsAOok2IUGigyoZ62tG_oldy2xg9_HdqPKrWUmKZLmU-CUqz_SQ", - ) - mocker.patch("superset.daos.key_value.KeyValueDAO.delete_expired_entries") - mocker.patch("superset.daos.key_value.KeyValueDAO.create_entry") - mocker.patch("superset.db_engine_specs.base.db.session.commit") + try: + mocker.patch( + "superset.db_engine_specs.base.uuid4", + return_value=UUID("fb11f528-6eba-4a8a-837e-6b0d39ee9187"), + ) + mocker.patch( + "superset.db_engine_specs.base.generate_code_verifier", + return_value="xkBPVZoFChVcy3VZ2l5u7d0FZPTU-olO7HtsAOok2IUGigyoZ62tG_oldy2xg9_HdqPKrWUmKZLmU-CUqz_SQ", + ) + mocker.patch("superset.daos.key_value.KeyValueDAO.delete_expired_entries") + mocker.patch("superset.daos.key_value.KeyValueDAO.create_entry") + mocker.patch("superset.db_engine_specs.base.db.session.commit") - g = mocker.patch("superset.db_engine_specs.base.g") - g.user = mocker.MagicMock() - g.user.id = 42 + g = mocker.patch("superset.db_engine_specs.base.g") + g.user = mocker.MagicMock() + g.user.id = 42 - database = Database( - id=1, - database_name="my_db", - sqlalchemy_uri="sqlite://", - encrypted_extra=json.dumps(oauth2_client_info), - ) - database.db_engine_spec.oauth2_exception = OAuth2Error - get_sqla_engine = mocker.patch.object(database, "get_sqla_engine") - get_sqla_engine().__enter__().raw_connection.side_effect = OAuth2Error( - "OAuth2 required" - ) + database = Database( + id=1, + database_name="my_db", + sqlalchemy_uri="sqlite://", + encrypted_extra=json.dumps(oauth2_client_info), + ) + database.db_engine_spec.oauth2_exception = OAuth2Error + get_sqla_engine = mocker.patch.object(database, "get_sqla_engine") + get_sqla_engine().__enter__().raw_connection.side_effect = OAuth2Error( + "OAuth2 required" + ) - # `limit` and `select_as_cta_used` must match the real `Query` model's - # defaults (nullable Integer -> None, Boolean default=False) so that - # `apply_limit` -- called unconditionally before the mocked OAuth2 error - # is ever reached -- doesn't try to compare an unconfigured MagicMock - # against an int. - query = mocker.MagicMock( - select_as_cta=False, - select_as_cta_used=False, - limit=None, - database=database, - ) - mocker.patch("superset.sql_lab.get_query", return_value=query) + # `limit` and `select_as_cta_used` must match the real `Query` model's + # defaults (nullable Integer -> None, Boolean default=False) so that + # `apply_limit` -- called unconditionally before the mocked OAuth2 error + # is ever reached -- doesn't try to compare an unconfigured MagicMock + # against an int. + query = mocker.MagicMock( + select_as_cta=False, + select_as_cta_used=False, + limit=None, + database=database, + ) + mocker.patch("superset.sql_lab.get_query", return_value=query) - payload = get_sql_results(query_id=1, rendered_query="SELECT 1") - assert payload["status"] == QueryStatus.FAILED - assert payload["error"] == "You don't have permission to access the data." - assert len(payload["errors"]) == 1 + payload = get_sql_results(query_id=1, rendered_query="SELECT 1") + assert payload["status"] == QueryStatus.FAILED + assert payload["error"] == "You don't have permission to access the data." + assert len(payload["errors"]) == 1 - error = payload["errors"][0] - assert error["message"] == "You don't have permission to access the data." - assert error["error_type"] == SupersetErrorType.OAUTH2_REDIRECT - assert error["level"] == ErrorLevel.WARNING - assert error["extra"]["tab_id"] == "fb11f528-6eba-4a8a-837e-6b0d39ee9187" - assert ( - error["extra"]["redirect_uri"] == "http://example.com/api/v1/database/oauth2/" - ) + error = payload["errors"][0] + assert error["message"] == "You don't have permission to access the data." + assert error["error_type"] == SupersetErrorType.OAUTH2_REDIRECT + assert error["level"] == ErrorLevel.WARNING + assert error["extra"]["tab_id"] == "fb11f528-6eba-4a8a-837e-6b0d39ee9187" + assert ( + error["extra"]["redirect_uri"] + == "http://example.com/api/v1/database/oauth2/" + ) - # Parse the OAuth2 authorization URL and verify components individually, - # since the JWT state and PKCE code_challenge are computed deterministically - # from mocked inputs but their exact encoding depends on library internals. - url = urlparse(error["extra"]["url"]) - assert url.scheme == "https" - assert url.netloc == "abcd1234.snowflakecomputing.com" - assert url.path == "/oauth/authorize" + # Parse the OAuth2 authorization URL and verify components individually, + # since the JWT state and PKCE code_challenge are computed deterministically + # from mocked inputs but their exact encoding depends on library internals. + url = urlparse(error["extra"]["url"]) + assert url.scheme == "https" + assert url.netloc == "abcd1234.snowflakecomputing.com" + assert url.path == "/oauth/authorize" - params = parse_qs(url.query) - assert params["scope"] == ["refresh_token session:role:USERADMIN"] - assert params["response_type"] == ["code"] - assert params["redirect_uri"] == ["http://example.com/api/v1/database/oauth2/"] - assert params["client_id"] == ["my_client_id"] - assert params["code_challenge_method"] == ["S256"] + params = parse_qs(url.query) + assert params["scope"] == ["refresh_token session:role:USERADMIN"] + assert params["response_type"] == ["code"] + assert params["redirect_uri"] == ["http://example.com/api/v1/database/oauth2/"] + assert params["client_id"] == ["my_client_id"] + assert params["code_challenge_method"] == ["S256"] - # Verify PKCE code_challenge matches the mocked code_verifier - from superset.utils.oauth2 import generate_code_challenge + # Verify PKCE code_challenge matches the mocked code_verifier + from superset.utils.oauth2 import generate_code_challenge - expected_code_challenge = generate_code_challenge( - "xkBPVZoFChVcy3VZ2l5u7d0FZPTU-olO7HtsAOok2IUGigyoZ62tG_oldy2xg9_HdqPKrWUmKZLmU-CUqz_SQ" - ) - assert params["code_challenge"] == [expected_code_challenge] + expected_code_challenge = generate_code_challenge( + "xkBPVZoFChVcy3VZ2l5u7d0FZPTU-olO7HtsAOok2IUGigyoZ62tG_oldy2xg9_HdqPKrWUmKZLmU-CUqz_SQ" + ) + assert params["code_challenge"] == [expected_code_challenge] + finally: + app_context.pop() def test_apply_rls(mocker: MockerFixture) -> None: