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

sadpandajoe 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 0484f9c2091 fix(database): sync schema_access permissions on import 
(#43950)
0484f9c2091 is described below

commit 0484f9c20918041c15eff75a186d4ee3696a13c5
Author: Joe Li <[email protected]>
AuthorDate: Wed Sep 9 11:50:20 2026 -0700

    fix(database): sync schema_access permissions on import (#43950)
    
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 superset/commands/database/importers/v1/utils.py   |  31 +++-
 superset/commands/database/utils.py                |  27 +++-
 .../databases/importers/v1/command_test.py         |  52 +++++++
 .../databases/commands/importers/v1/import_test.py | 162 +++++++++++++++++++++
 tests/unit_tests/databases/commands/utils_test.py  | 128 +++++++++++++++-
 5 files changed, 391 insertions(+), 9 deletions(-)

diff --git a/superset/commands/database/importers/v1/utils.py 
b/superset/commands/database/importers/v1/utils.py
index 5357500c197..b8637de3da7 100644
--- a/superset/commands/database/importers/v1/utils.py
+++ b/superset/commands/database/importers/v1/utils.py
@@ -32,7 +32,7 @@ from superset.commands.exceptions import ImportFailedError
 from superset.constants import PASSWORD_MASK
 from superset.databases.ssh_tunnel.models import SSHTunnel
 from superset.databases.utils import make_url_safe
-from superset.db_engine_specs.exceptions import SupersetDBAPIConnectionError
+from superset.db_engine_specs.exceptions import SupersetDBAPIError
 from superset.exceptions import (
     OAuth2RedirectError,
     SupersetSecurityException,
@@ -96,6 +96,21 @@ def _refuse_stored_secret_reuse(existing: Database, config: 
dict[str, Any]) -> N
         )
 
 
+def _sync_permissions_best_effort(database: Database) -> None:
+    """
+    Sync catalog/schema permissions for ``database``, tolerating a transient
+    or OAuth2 failure rather than letting it fail the import.
+    """
+    try:
+        add_permissions(database)
+    except (SupersetDBAPIError, OAuth2RedirectError) as ex:
+        # ``add_permissions()`` calls ``get_all_catalog_names()`` outside of
+        # its own per-catalog error handling, so any DBAPI error mapped from
+        # that initial catalog discovery -- not just a connection failure --
+        # must be tolerated here too, or it fails the whole import.
+        logger.warning(ex.message)
+
+
 def import_database(  # noqa: C901
     config: dict[str, Any],
     overwrite: bool = False,
@@ -108,6 +123,15 @@ def import_database(  # noqa: C901
     existing = 
db.session.query(Database).filter_by(uuid=config["uuid"]).first()
     if existing:
         if not overwrite or not can_write:
+            if can_write:
+                # Chart/dataset/saved-query/dashboard bundles that reference
+                # an already-imported database reach this branch; without
+                # this, a schema added to the live connection since the
+                # database was first imported would never get a first-time
+                # grant through this path either. ``add_permissions()`` does
+                # a live, uncached metadata scan, so this can be slow for
+                # cross-catalog-enabled engines -- see its own comment.
+                _sync_permissions_best_effort(existing)
             return existing
         config["id"] = existing.id
         # Stored secrets must not be rebound to a different endpoint: without
@@ -184,9 +208,6 @@ def import_database(  # noqa: C901
             recursive=False,
         )
 
-    try:
-        add_permissions(database)
-    except (SupersetDBAPIConnectionError, OAuth2RedirectError) as ex:
-        logger.warning(ex.message)
+    _sync_permissions_best_effort(database)
 
     return database
diff --git a/superset/commands/database/utils.py 
b/superset/commands/database/utils.py
index 0d40516419c..a4be6fce731 100644
--- a/superset/commands/database/utils.py
+++ b/superset/commands/database/utils.py
@@ -36,6 +36,7 @@ from superset.constants import PASSWORD_MASK
 from superset.databases.ssh_tunnel.models import SSHTunnel
 from superset.databases.utils import make_url_safe
 from superset.db_engine_specs.base import GenericDBException
+from superset.exceptions import OAuth2RedirectError
 from superset.models.core import Database
 from superset.security.manager import SupersetSecurityManager
 from superset.utils import json
@@ -147,6 +148,29 @@ def ping(engine: Engine) -> bool:
             return engine.dialect.do_ping(conn)
 
 
+def _get_all_schema_names_with_retry(
+    database: Database, catalog: str | None
+) -> set[str]:
+    """
+    Retry the live schema-listing call once before giving up on a catalog.
+
+    Some catalogs are visible but not listable (eg the ``rdsadmin`` catalog on
+    AWS RDS), but the exception caught by the caller doesn't distinguish that
+    from a one-off transient hiccup (eg schema metadata not yet visible right
+    after it was created). A single retry lets a schema that needs a
+    first-time grant survive a fluke without tolerating a persistently
+    unlistable catalog for any longer than before.
+    """
+    try:
+        return database.get_all_schema_names(catalog=catalog, cache=False)
+    except OAuth2RedirectError:
+        # Not transient: retrying would just kick off a second, redundant
+        # OAuth2 authorization redirect for the same request.
+        raise
+    except GenericDBException:  # pylint: disable=broad-except
+        return database.get_all_schema_names(catalog=catalog, cache=False)
+
+
 def add_permissions(database: Database) -> None:
     """
     Add DAR for catalogs and schemas.
@@ -179,7 +203,8 @@ def add_permissions(database: Database) -> None:
 
     for catalog in catalogs:
         try:
-            for schema in database.get_all_schema_names(catalog=catalog, 
cache=False):
+            schemas = _get_all_schema_names_with_retry(database, catalog)
+            for schema in schemas:
                 security_manager.add_permission_view_menu(
                     "schema_access",
                     security_manager.get_schema_perm(
diff --git a/tests/unit_tests/commands/databases/importers/v1/command_test.py 
b/tests/unit_tests/commands/databases/importers/v1/command_test.py
index bb4228af86f..66e547e39d4 100644
--- a/tests/unit_tests/commands/databases/importers/v1/command_test.py
+++ b/tests/unit_tests/commands/databases/importers/v1/command_test.py
@@ -133,3 +133,55 @@ def test_import_database_with_password_in_config(
     uuid = configs["databases/examples.yaml"]["uuid"]
     database = db.session.query(Database).filter_by(uuid=uuid).one()
     assert database.password == "yaml_password"  # noqa: S105
+
+
+def test_transient_schema_listing_failure_is_recovered_by_retry(
+    mocker: MockerFixture,
+    session: Session,
+) -> None:
+    """
+    A single transient failure while listing schemas for a connection must
+    not permanently discard the schema_access grant for a schema that
+    appeared on the live connection since the last successful sync. Before
+    this fix, ``add_permissions()`` gave up on a catalog after one failed
+    attempt at ``get_all_schema_names()`` -- even though the exception
+    caught (``GenericDBException``, a bare alias for the built-in
+    ``Exception``) does not distinguish a genuinely unlistable catalog from
+    a one-off connector hiccup -- so a schema that needed a first-time grant
+    during the failing attempt was never granted, silently, while the
+    import still reported success.
+    """
+    from superset import db, security_manager
+    from superset.commands.database.importers.v1 import ImportDatabasesCommand
+    from superset.db_engine_specs.sqlite import SqliteEngineSpec
+    from superset.models.core import Database
+
+    mocker.patch.object(security_manager, "can_access", return_value=True)
+    add_permission_view_menu = mocker.spy(security_manager, 
"add_permission_view_menu")
+
+    engine = db.session.get_bind()
+    Database.metadata.create_all(engine)  # pylint: disable=no-member
+
+    configs = copy.deepcopy(databases_config)
+
+    # First import (existing=None): only "old_schema" exists on the live
+    # target connection.
+    mocker.patch.object(
+        SqliteEngineSpec, "get_schema_names", return_value={"old_schema"}
+    )
+    ImportDatabasesCommand._import(copy.deepcopy(configs))
+    add_permission_view_menu.reset_mock()
+
+    # A new schema now exists, but the first attempt to list schemas for
+    # this connection hits a transient failure (e.g. a driver hiccup right
+    # after the schema was created). Re-importing with overwrite=True --
+    # the only way to legitimately re-import an existing connection -- must
+    # still end up granting the new schema once the retry succeeds.
+    mocker.patch.object(
+        SqliteEngineSpec,
+        "get_schema_names",
+        side_effect=[Exception("transient driver error"), {"old_schema", 
"new_schema"}],
+    )
+    ImportDatabasesCommand._import(copy.deepcopy(configs), overwrite=True)
+
+    add_permission_view_menu.assert_any_call("schema_access", 
"[examples].[new_schema]")
diff --git a/tests/unit_tests/databases/commands/importers/v1/import_test.py 
b/tests/unit_tests/databases/commands/importers/v1/import_test.py
index 27c2e4c6f69..94ca05d251a 100644
--- a/tests/unit_tests/databases/commands/importers/v1/import_test.py
+++ b/tests/unit_tests/databases/commands/importers/v1/import_test.py
@@ -344,6 +344,129 @@ def 
test_import_database_with_masked_encrypted_extra_existing_db(
     assert encrypted["credentials_info"]["private_key"] != PASSWORD_MASK
 
 
+def test_import_database_existing_no_overwrite_backfills_permissions(
+    mocker: MockerFixture,
+    session: Session,
+) -> None:
+    """
+    ``import_database(config, overwrite=False)`` is called unconditionally by
+    the chart, dataset, and saved-query bundle importers
+    (``superset/commands/chart/importers/v1/__init__.py``,
+    ``superset/commands/dataset/importers/v1/__init__.py``, and
+    ``superset/commands/query/importers/v1/__init__.py``), and by the
+    dashboard importer 
(``superset/commands/dashboard/importers/v1/__init__.py``)
+    whenever both ``overwrite``/``overwrite_all`` aren't simultaneously
+    ``True`` -- the common case -- whenever the bundle references a
+    database, including one that already exists in the target, eg because a
+    prior import already created it. Before this fix, the early return for
+    an existing, non-overwritten database skipped ``add_permissions()``
+    entirely, so a schema added to the live connection since that database
+    was first imported would never get a first-time grant through this path
+    either.
+    """
+    from superset import security_manager
+    from superset.commands.database.importers.v1.utils import import_database
+    from superset.models.core import Database
+    from tests.integration_tests.fixtures.importexport import database_config
+
+    mocker.patch.object(security_manager, "can_access", return_value=True)
+    mock_add_permissions = mocker.patch(
+        "superset.commands.database.importers.v1.utils.add_permissions"
+    )
+
+    engine = db.session.get_bind()
+    Database.metadata.create_all(engine)  # pylint: disable=no-member
+
+    config = copy.deepcopy(database_config)
+    existing = import_database(config)
+    mock_add_permissions.reset_mock()
+
+    again = copy.deepcopy(database_config)
+    result = import_database(again, overwrite=False)
+
+    assert result.id == existing.id
+    mock_add_permissions.assert_called_once_with(existing)
+
+
+def test_import_database_existing_no_overwrite_no_permission_skips_backfill(
+    mocker: MockerFixture,
+    session: Session,
+) -> None:
+    """
+    The permission backfill added for the existing/no-overwrite branch must
+    stay behind the same ``can_write`` gate as every other permission-view
+    creation in ``import_database()`` -- a principal without database-write
+    access must not trigger it just by importing a
+    chart/dataset/saved-query/dashboard bundle that happens to reference an
+    existing database.
+    """
+    from superset import security_manager
+    from superset.commands.database.importers.v1.utils import import_database
+    from superset.models.core import Database
+    from tests.integration_tests.fixtures.importexport import database_config
+
+    mocker.patch.object(security_manager, "can_access", return_value=True)
+    mock_add_permissions = mocker.patch(
+        "superset.commands.database.importers.v1.utils.add_permissions"
+    )
+
+    engine = db.session.get_bind()
+    Database.metadata.create_all(engine)  # pylint: disable=no-member
+
+    config = copy.deepcopy(database_config)
+    existing = import_database(config)
+    mock_add_permissions.reset_mock()
+
+    mocker.patch.object(security_manager, "can_access", return_value=False)
+
+    again = copy.deepcopy(database_config)
+    result = import_database(again, overwrite=False)
+
+    assert result.id == existing.id
+    mock_add_permissions.assert_not_called()
+
+
+def 
test_import_database_existing_no_overwrite_backfill_oauth2_redirect_is_nonfatal(
+    mocker: MockerFixture,
+    session: Session,
+) -> None:
+    """
+    The permission backfill on the existing/no-overwrite branch must tolerate
+    an ``OAuth2RedirectError`` from ``add_permissions()`` the same way the
+    fresh-import path already does (see
+    ``test_import_database_oauth2_redirect_is_nonfatal``) -- logged, not
+    propagated, so the import still returns the existing database normally.
+    """
+    from superset import security_manager
+    from superset.commands.database.importers.v1.utils import import_database
+    from superset.exceptions import OAuth2RedirectError
+    from superset.models.core import Database
+    from tests.integration_tests.fixtures.importexport import database_config
+
+    mocker.patch.object(security_manager, "can_access", return_value=True)
+    mock_add_permissions = mocker.patch(
+        "superset.commands.database.importers.v1.utils.add_permissions"
+    )
+
+    engine = db.session.get_bind()
+    Database.metadata.create_all(engine)  # pylint: disable=no-member
+
+    config = copy.deepcopy(database_config)
+    existing = import_database(config)
+    mock_add_permissions.reset_mock()
+    mock_add_permissions.side_effect = OAuth2RedirectError(
+        url="https://oauth.example.com/authorize";,
+        tab_id="abc-123",
+        redirect_uri="https://superset.example.com/callback";,
+    )
+
+    again = copy.deepcopy(database_config)
+    result = import_database(again, overwrite=False)
+
+    assert result.id == existing.id
+    mock_add_permissions.assert_called_once_with(existing)
+
+
 def test_import_database_oauth2_redirect_is_nonfatal(
     mocker: MockerFixture,
     session: Session,
@@ -378,6 +501,45 @@ def test_import_database_oauth2_redirect_is_nonfatal(
     mock_add_perms.assert_called_once_with(database)
 
 
+def test_import_database_dbapi_error_during_backfill_is_nonfatal(
+    mocker: MockerFixture,
+    session: Session,
+) -> None:
+    """
+    ``add_permissions()`` calls ``get_all_catalog_names()`` for catalog
+    discovery outside of its own per-catalog error handling, so a mapped
+    DBAPI error from that call -- not just a connection failure -- must be
+    tolerated the same way an ``OAuth2RedirectError`` already is, instead of
+    failing the whole import.
+    """
+    from superset import security_manager
+    from superset.commands.database.importers.v1.utils import import_database
+    from superset.db_engine_specs.exceptions import 
SupersetDBAPIProgrammingError
+    from superset.models.core import Database
+    from tests.integration_tests.fixtures.importexport import database_config
+
+    mocker.patch.object(security_manager, "can_access", return_value=True)
+    mock_add_permissions = mocker.patch(
+        "superset.commands.database.importers.v1.utils.add_permissions"
+    )
+
+    engine = db.session.get_bind()
+    Database.metadata.create_all(engine)  # pylint: disable=no-member
+
+    config = copy.deepcopy(database_config)
+    existing = import_database(config)
+    mock_add_permissions.reset_mock()
+    mock_add_permissions.side_effect = SupersetDBAPIProgrammingError(
+        "permission denied for catalog discovery"
+    )
+
+    again = copy.deepcopy(database_config)
+    result = import_database(again, overwrite=False)
+
+    assert result.id == existing.id
+    mock_add_permissions.assert_called_once_with(existing)
+
+
 def test_import_datasources_cli_encrypts_password(
     mocker: MockerFixture,
     session: Session,
diff --git a/tests/unit_tests/databases/commands/utils_test.py 
b/tests/unit_tests/databases/commands/utils_test.py
index 266c4e5d864..d131f0a0b15 100644
--- a/tests/unit_tests/databases/commands/utils_test.py
+++ b/tests/unit_tests/databases/commands/utils_test.py
@@ -78,14 +78,20 @@ def test_add_permissions_get_default_catalog(mocker: 
MockerFixture):
 
 def test_add_permissions_handle_failures(mocker: MockerFixture) -> None:
     """
-    Test adding permissions to a database when it's created in case
-    the request to get all schemas for one fo the catalogs fail.
+    Test adding permissions to a database when it's created in case the
+    request to get all schemas for one of the catalogs keeps failing --
+    each failing catalog is retried once before being given up on.
     """
     database = mocker.MagicMock()
     database.database_name = "my_db"
     database.db_engine_spec.supports_catalog = True
     database.get_all_catalog_names.return_value = ["catalog1", "catalog2", 
"catalog3"]
-    database.get_all_schema_names.side_effect = [["schema1"], Exception, 
["schema3"]]
+    database.get_all_schema_names.side_effect = [
+        ["schema1"],
+        Exception,
+        Exception,
+        ["schema3"],
+    ]
     add_permission_view_menu = mocker.patch(
         "superset.commands.database.importers.v1.utils.security_manager."
         "add_permission_view_menu"
@@ -102,3 +108,119 @@ def test_add_permissions_handle_failures(mocker: 
MockerFixture) -> None:
             mocker.call("schema_access", "[my_db].[catalog3].[schema3]"),
         ]
     )
+
+
+def test_add_permissions_retries_transient_failure(mocker: MockerFixture) -> 
None:
+    """
+    A catalog whose schema listing fails only once (eg a transient driver
+    hiccup) must still get its schema permissions granted via the retry,
+    instead of being permanently skipped like a genuinely unlistable
+    catalog would be.
+    """
+    database = mocker.MagicMock()
+    database.database_name = "my_db"
+    database.db_engine_spec.supports_catalog = True
+    database.get_all_catalog_names.return_value = ["catalog1", "catalog2"]
+    database.get_all_schema_names.side_effect = [
+        ["schema1"],
+        Exception,
+        ["schema2"],
+    ]
+    add_permission_view_menu = mocker.patch(
+        "superset.commands.database.importers.v1.utils.security_manager."
+        "add_permission_view_menu"
+    )
+
+    add_permissions(database)
+
+    add_permission_view_menu.assert_has_calls(
+        [
+            mocker.call("catalog_access", "[my_db].[catalog1]"),
+            mocker.call("catalog_access", "[my_db].[catalog2]"),
+            mocker.call("schema_access", "[my_db].[catalog1].[schema1]"),
+            mocker.call("schema_access", "[my_db].[catalog2].[schema2]"),
+        ]
+    )
+
+
+def test_add_permissions_does_not_retry_oauth2_redirect(mocker: MockerFixture) 
-> None:
+    """
+    An ``OAuth2RedirectError`` from the schema-listing call is a control-flow
+    signal, not a transient failure: retrying it would just kick off a
+    second, redundant OAuth2 authorization redirect for the same catalog.
+    """
+    from superset.exceptions import OAuth2RedirectError
+
+    database = mocker.MagicMock()
+    database.database_name = "my_db"
+    database.db_engine_spec.supports_catalog = True
+    database.get_all_catalog_names.return_value = ["catalog1", "catalog2"]
+    oauth2_error = OAuth2RedirectError(
+        url="https://oauth.example.com/authorize";,
+        tab_id="abc-123",
+        redirect_uri="https://superset.example.com/callback";,
+    )
+    database.get_all_schema_names.side_effect = [oauth2_error, ["schema2"]]
+    add_permission_view_menu = mocker.patch(
+        "superset.commands.database.importers.v1.utils.security_manager."
+        "add_permission_view_menu"
+    )
+
+    add_permissions(database)
+
+    assert database.get_all_schema_names.call_count == 2
+    add_permission_view_menu.assert_has_calls(
+        [
+            mocker.call("catalog_access", "[my_db].[catalog1]"),
+            mocker.call("catalog_access", "[my_db].[catalog2]"),
+            mocker.call("schema_access", "[my_db].[catalog2].[schema2]"),
+        ]
+    )
+    # [catalog1] must never be retried: the OAuth2 redirect aborts it after
+    # a single attempt, same as a genuinely unlistable catalog would be.
+    assert add_permission_view_menu.call_count == 3
+
+
+def test_add_permissions_tolerates_failure_creating_permission_view(
+    mocker: MockerFixture,
+) -> None:
+    """
+    A failure while granting a schema's permission (as opposed to while
+    listing the catalog's schemas) must be tolerated the same way it was
+    before the schema-listing retry was introduced: the rest of that
+    catalog is abandoned, but the next catalog is still processed. The
+    retry is scoped to the schema-listing call only, so it must not narrow
+    this pre-existing tolerance.
+    """
+    database = mocker.MagicMock()
+    database.database_name = "my_db"
+    database.db_engine_spec.supports_catalog = True
+    database.get_all_catalog_names.return_value = ["catalog1", "catalog2"]
+    database.get_all_schema_names.side_effect = [
+        ["schema1a", "schema1b"],
+        ["schema2"],
+    ]
+    add_permission_view_menu = mocker.patch(
+        "superset.commands.database.importers.v1.utils.security_manager."
+        "add_permission_view_menu"
+    )
+    add_permission_view_menu.side_effect = [
+        None,  # catalog_access [catalog1]
+        None,  # catalog_access [catalog2]
+        Exception,  # schema_access [catalog1].[schema1a] -- fails
+        None,  # schema_access [catalog2].[schema2]
+    ]
+
+    add_permissions(database)
+
+    add_permission_view_menu.assert_has_calls(
+        [
+            mocker.call("catalog_access", "[my_db].[catalog1]"),
+            mocker.call("catalog_access", "[my_db].[catalog2]"),
+            mocker.call("schema_access", "[my_db].[catalog1].[schema1a]"),
+            mocker.call("schema_access", "[my_db].[catalog2].[schema2]"),
+        ]
+    )
+    # [catalog1].[schema1b] must never be attempted: the exception raised
+    # while granting [schema1a] aborts the rest of catalog1, same as before.
+    assert add_permission_view_menu.call_count == 4

Reply via email to