This is an automated email from the ASF dual-hosted git repository.
aminghadersohi 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 fd2740bec1e fix(charts): avoid loading semantic provider metadata
before denying chart data access (#45060)
fd2740bec1e is described below
commit fd2740bec1edf5603d8b78f2abce3ab7f261cbfd
Author: Mike Bridge <[email protected]>
AuthorDate: Sat Oct 10 16:27:16 2026 -0600
fix(charts): avoid loading semantic provider metadata before denying chart
data access (#45060)
Co-authored-by: Mike Bridge <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
superset/charts/data/api.py | 7 +-
superset/common/query_context_factory.py | 42 +-
superset/config.py | 6 +
superset/security/manager.py | 4 +
superset/views/api.py | 4 +-
.../charts/semantic_metadata_authz_tests.py | 640 +++++++++++++++++++++
tests/unit_tests/security/manager_test.py | 281 +++++++++
7 files changed, 981 insertions(+), 3 deletions(-)
diff --git a/superset/charts/data/api.py b/superset/charts/data/api.py
index 01e3657d761..1dfdf5c394e 100644
--- a/superset/charts/data/api.py
+++ b/superset/charts/data/api.py
@@ -49,6 +49,7 @@ from superset.commands.chart.exceptions import (
)
from superset.common.chart_data import ChartDataResultFormat,
ChartDataResultType
from superset.common.chart_data_timing import ChartDataExecutionResult
+from superset.common.query_context_factory import QueryContextFactory
from superset.connectors.sqla.models import BaseDatasource
from superset.constants import CACHE_DISABLED_TIMEOUT
from superset.daos.exceptions import DatasourceNotFound
@@ -750,7 +751,11 @@ class ChartDataRestApi(ChartRestApi):
"""
try:
- return ChartDataQueryContextSchema().load(form_data)
+ schema: ChartDataQueryContextSchema = ChartDataQueryContextSchema()
+ schema.query_context_factory = QueryContextFactory(
+ authorize_semantic_before_metadata=True
+ )
+ return schema.load(form_data)
except KeyError as ex:
raise ValidationError("Request is incorrect") from ex
except ValueError as ex:
diff --git a/superset/common/query_context_factory.py
b/superset/common/query_context_factory.py
index 31affea616b..2a4c66f3d8a 100644
--- a/superset/common/query_context_factory.py
+++ b/superset/common/query_context_factory.py
@@ -27,7 +27,9 @@ from superset.common.query_object_factory import
QueryObjectFactory
from superset.daos.chart import ChartDAO
from superset.daos.datasource import DatasourceDAO
from superset.explorables.base import Explorable
+from superset.extensions import security_manager
from superset.models.slice import Slice
+from superset.security.manager import STOCK_RAISE_FOR_ACCESS
from superset.superset_typing import Column
from superset.utils.core import DatasourceDict, DatasourceType, is_adhoc_column
@@ -35,15 +37,29 @@ if TYPE_CHECKING:
from superset.connectors.sqla.models import BaseDatasource
+def _uses_stock_raise_for_access() -> bool:
+ """Check the resolved method against the original stock function.
+
+ The semantic preflight passes empty queries. Instance wrappers and operator
+ reassignment of the base-class method must keep the completed-context path.
+ """
+ return (
+ getattr(security_manager.raise_for_access, "__func__", None)
+ is STOCK_RAISE_FOR_ACCESS
+ )
+
+
def create_query_object_factory() -> QueryObjectFactory:
return QueryObjectFactory(current_app.config, DatasourceDAO())
class QueryContextFactory: # pylint: disable=too-few-public-methods
_query_object_factory: QueryObjectFactory
+ _authorize_semantic_before_metadata: bool
- def __init__(self) -> None:
+ def __init__(self, authorize_semantic_before_metadata: bool = False) ->
None:
self._query_object_factory = create_query_object_factory()
+ self._authorize_semantic_before_metadata =
authorize_semantic_before_metadata
def create( # pylint: disable=too-many-arguments
self,
@@ -72,6 +88,30 @@ class QueryContextFactory: # pylint:
disable=too-few-public-methods
result_type = result_type or ChartDataResultType.FULL
result_format = result_format or ChartDataResultFormat.JSON
+ if (
+ self._authorize_semantic_before_metadata
+ and datasource_model_instance is not None
+ and DatasourceType(datasource["type"]) ==
DatasourceType.SEMANTIC_VIEW
+ ):
+ # Guest dashboard and payload checks need the completed query
context,
+ # and an operator EXTRA_RAISE_FOR_ACCESS_BYPASS hook or a custom
+ # security manager's raise_for_access may read the request's
queries;
+ # keep their authorization path and timing unchanged.
+ if (
+ not security_manager.is_guest_user()
+ and not current_app.config.get("EXTRA_RAISE_FOR_ACCESS_BYPASS")
+ and _uses_stock_raise_for_access()
+ ):
+ QueryContext(
+ datasource=datasource_model_instance,
+ queries=[],
+ slice_=slice_,
+ form_data=form_data,
+ result_type=result_type,
+ result_format=result_format,
+ cache_values={},
+ ).raise_for_access()
+
# The server pagination var is extracted from form data as the
# row limit for server pagination is more
# This particular flag server_pagination only exists for table viz type
diff --git a/superset/config.py b/superset/config.py
index 2ab845461bb..53f86824d2f 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -248,6 +248,9 @@ SUPERSET_DASHBOARD_PERIODICAL_REFRESH_WARNING_MESSAGE = None
SUPERSET_DASHBOARD_MANUAL_REFRESH_STAGGER_MS: int = 0
SUPERSET_DASHBOARD_POSITION_DATA_LIMIT = 65535
+# A manager that overrides raise_for_access receives the complete query
context,
+# so it turns off the semantic-view chart-data check that otherwise denies
before
+# provider metadata is loaded.
CUSTOM_SECURITY_MANAGER = None
SQLALCHEMY_TRACK_MODIFICATIONS = False
@@ -3473,6 +3476,9 @@ class ExtraAccessQueryFilters(TypedDict, total=False):
# Additional query filters for chart/dashboard list views.
EXTRA_ACCESS_QUERY_FILTERS: ExtraAccessQueryFilters = {}
# Bypass raise_for_access for specific assets. Return True to skip checks.
+# The hook receives the complete query context, so setting it turns off the
+# semantic-view chart-data check that otherwise denies before provider metadata
+# is loaded.
EXTRA_RAISE_FOR_ACCESS_BYPASS: Callable[..., bool] | None = None
# Resolve additional editor subjects for a resource. Also used for editorship
# checks and lockout-prevention logic.
diff --git a/superset/security/manager.py b/superset/security/manager.py
index 1161ce30389..b24d8bf1583 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -6786,3 +6786,7 @@ class SupersetSecurityManager( # pylint:
disable=too-many-public-methods
"User Registrations",
]:
security_menu.childs.remove(item)
+
+
+# Keep the original function identity before operator configuration can
replace it.
+STOCK_RAISE_FOR_ACCESS: Callable[..., None] =
SupersetSecurityManager.raise_for_access
diff --git a/superset/views/api.py b/superset/views/api.py
index e1c36bf71f3..b5d8532c9b0 100644
--- a/superset/views/api.py
+++ b/superset/views/api.py
@@ -138,5 +138,7 @@ class Api(BaseSupersetView):
# pylint: disable=import-outside-toplevel
from superset.common.query_context_factory import
QueryContextFactory
- self.query_context_factory = QueryContextFactory()
+ self.query_context_factory = QueryContextFactory(
+ authorize_semantic_before_metadata=True
+ )
return self.query_context_factory
diff --git a/tests/integration_tests/charts/semantic_metadata_authz_tests.py
b/tests/integration_tests/charts/semantic_metadata_authz_tests.py
new file mode 100644
index 00000000000..f2e34b1d232
--- /dev/null
+++ b/tests/integration_tests/charts/semantic_metadata_authz_tests.py
@@ -0,0 +1,640 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Check whether denied chart-data requests call semantic provider metadata."""
+
+from collections.abc import Callable
+from typing import Any, Literal
+from unittest.mock import Mock, patch, PropertyMock
+
+import pytest
+import sqlalchemy as sa
+from flask import current_app, g, Response
+
+from superset.common.query_context import QueryContext
+from superset.connectors.sqla.models import SqlaTable
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.exceptions import SupersetSecurityException
+from superset.extensions import db, security_manager
+from superset.models.dashboard import Dashboard
+from superset.security.manager import SupersetSecurityManager
+from superset.semantic_layers.models import SemanticLayer, SemanticView
+from superset.utils import json
+from tests.integration_tests.base_tests import SupersetTestCase
+from tests.integration_tests.conftest import with_feature_flags
+from tests.integration_tests.fixtures.birth_names_dashboard import (
+ load_birth_names_dashboard_with_slices, # noqa: F401
+ load_birth_names_data, # noqa: F401
+)
+
+
+class TestSemanticMetadataAuthorization(SupersetTestCase):
+ """Exercise the chart-data route with a provider that records metadata
calls."""
+
+ def test_denied_chart_data_skips_provider_metadata(self) -> None:
+ """A denied request should not load dimensions or metrics."""
+ self.login("gamma")
+ layer: SemanticLayer = SemanticLayer(name="authz-metadata-layer",
type="test")
+ view: SemanticView = SemanticView(
+ name="authz-metadata-view", semantic_layer=layer
+ )
+ db.session.add(view)
+ db.session.commit()
+ provider: Mock = Mock()
+ provider.get_dimensions.return_value = set()
+ provider.get_metrics.return_value = set()
+ try:
+ with patch.object(
+ SemanticView,
+ "implementation",
+ new_callable=PropertyMock,
+ return_value=provider,
+ ):
+ response: Response = self.client.post(
+ "/api/v1/chart/data",
+ json={
+ "datasource": {"id": view.id, "type": "semantic_view"},
+ "queries": [{"columns": [], "metrics": []}],
+ },
+ )
+ assert response.status_code == 403, response.json
+ provider.get_dimensions.assert_not_called()
+ provider.get_metrics.assert_not_called()
+ finally:
+ db.session.rollback()
+ db.session.delete(view)
+ db.session.delete(layer)
+ db.session.commit()
+
+ def test_denied_legacy_query_api_skips_provider_metadata(self) -> None:
+ """The legacy ``/api/v1/query/`` route also denies before metadata."""
+ self.login("gamma")
+ layer: SemanticLayer = SemanticLayer(name="legacy-metadata-layer",
type="test")
+ view: SemanticView = SemanticView(
+ name="legacy-metadata-view", semantic_layer=layer
+ )
+ db.session.add(view)
+ db.session.commit()
+ provider: Mock = Mock()
+ provider.get_dimensions.return_value = set()
+ provider.get_metrics.return_value = set()
+ try:
+ with patch.object(
+ SemanticView,
+ "implementation",
+ new_callable=PropertyMock,
+ return_value=provider,
+ ):
+ response: Response = self.client.post(
+ "/api/v1/query/",
+ data={
+ "query_context": json.dumps(
+ {
+ "datasource": {
+ "id": view.id,
+ "type": "semantic_view",
+ },
+ "queries": [{"columns": [], "metrics": []}],
+ }
+ )
+ },
+ )
+ assert response.status_code == 403, response.json
+ provider.get_dimensions.assert_not_called()
+ provider.get_metrics.assert_not_called()
+ finally:
+ db.session.rollback()
+ db.session.delete(view)
+ db.session.delete(layer)
+ db.session.commit()
+
+ def test_allowed_chart_data_uses_provider_metadata(self) -> None:
+ """An entitled role still builds and validates a semantic query."""
+ self.login("gamma")
+ layer: SemanticLayer = SemanticLayer(name="allowed-metadata-layer",
type="test")
+ view: SemanticView = SemanticView(
+ name="allowed-metadata-view", semantic_layer=layer
+ )
+ db.session.add(view)
+ db.session.commit()
+ provider: Mock = Mock()
+ provider.get_dimensions.return_value = set()
+ provider.get_metrics.return_value = set()
+ original_can_access: Callable[[str, str], bool] =
security_manager.can_access
+
+ def can_access(permission_name: str, view_name: str) -> bool:
+ """Give Gamma this view's datasource grant for the request."""
+ if permission_name == "datasource_access" and view_name ==
view.perm:
+ return True
+ return original_can_access(permission_name, view_name)
+
+ access_spy: Mock
+ try:
+ with (
+ patch.object(
+ SemanticView,
+ "implementation",
+ new_callable=PropertyMock,
+ return_value=provider,
+ ),
+ patch.object(security_manager, "can_access",
side_effect=can_access),
+ patch.object(
+ QueryContext,
+ "raise_for_access",
+ autospec=True,
+ side_effect=QueryContext.raise_for_access,
+ ) as access_spy,
+ ):
+ response: Response = self.client.post(
+ "/api/v1/chart/data",
+ json={
+ "datasource": {"id": view.id, "type": "semantic_view"},
+ "queries": [{"columns": [], "metrics": []}],
+ "result_type": "query",
+ },
+ )
+ assert response.status_code == 200, response.json
+ provider.get_dimensions.assert_called()
+ # The preflight and the final check each decide on a query context.
+ assert access_spy.call_count == 2
+ finally:
+ db.session.rollback()
+ db.session.delete(view)
+ db.session.delete(layer)
+ db.session.commit()
+
+ def test_payload_based_access_bypass_still_sees_queries(self) -> None:
+ """An operator bypass hook keeps receiving the request's real
queries."""
+ self.login("gamma")
+ layer: SemanticLayer = SemanticLayer(name="bypass-metadata-layer",
type="test")
+ view: SemanticView = SemanticView(
+ name="bypass-metadata-view", semantic_layer=layer
+ )
+ db.session.add(view)
+ db.session.commit()
+ provider: Mock = Mock()
+ provider.get_dimensions.return_value = set()
+ provider.get_metrics.return_value = set()
+
+ def bypass(**kwargs: object) -> bool:
+ """Grant only when the request's queries are visible to the
hook."""
+ query_context: object = kwargs["query_context"]
+ return bool(getattr(query_context, "queries", None))
+
+ try:
+ with (
+ patch.dict(
+ current_app.config, {"EXTRA_RAISE_FOR_ACCESS_BYPASS":
bypass}
+ ),
+ patch.object(
+ SemanticView,
+ "implementation",
+ new_callable=PropertyMock,
+ return_value=provider,
+ ),
+ ):
+ response: Response = self.client.post(
+ "/api/v1/chart/data",
+ json={
+ "datasource": {"id": view.id, "type": "semantic_view"},
+ "queries": [{"columns": [], "metrics": []}],
+ "result_type": "query",
+ },
+ )
+ assert response.status_code == 200, response.json
+ finally:
+ db.session.rollback()
+ db.session.delete(view)
+ db.session.delete(layer)
+ db.session.commit()
+
+ def test_overridden_access_check_still_sees_queries(self) -> None:
+ """A custom security manager's raise_for_access keeps receiving the
+ request's real queries, so the preflight does not run for it."""
+ self.login("gamma")
+ layer: SemanticLayer = SemanticLayer(
+ name="override-metadata-layer", type="test"
+ )
+ view: SemanticView = SemanticView(
+ name="override-metadata-view", semantic_layer=layer
+ )
+ db.session.add(view)
+ db.session.commit()
+ provider: Mock = Mock()
+ provider.get_dimensions.return_value = set()
+ provider.get_metrics.return_value = set()
+ manager: SupersetSecurityManager =
security_manager._get_current_object() # noqa: SLF001
+ stock_class: type[SupersetSecurityManager] = type(manager)
+ original_can_access: Callable[[str, str], bool] =
security_manager.can_access
+
+ class QueryCheckingSecurityManager(stock_class): # type:
ignore[valid-type, misc]
+ """Authorizes per requested query, like a column or metric
check."""
+
+ def raise_for_access(self, **kwargs: Any) -> None:
+ query_context: QueryContext | None =
kwargs.get("query_context")
+ if query_context is not None and not query_context.queries:
+ raise SupersetSecurityException(
+ SupersetError(
+ message="No requested queries to authorize",
+
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
+ level=ErrorLevel.ERROR,
+ )
+ )
+ super().raise_for_access(**kwargs)
+
+ def can_access(permission_name: str, view_name: str) -> bool:
+ """Give Gamma this view's datasource grant for the request."""
+ if permission_name == "datasource_access" and view_name ==
view.perm:
+ return True
+ return original_can_access(permission_name, view_name)
+
+ # patch.object cannot restore __class__, so swap and restore it here.
+ manager.__class__ = QueryCheckingSecurityManager
+ try:
+ with (
+ patch.object(
+ SemanticView,
+ "implementation",
+ new_callable=PropertyMock,
+ return_value=provider,
+ ),
+ patch.object(security_manager, "can_access",
side_effect=can_access),
+ ):
+ response: Response = self.client.post(
+ "/api/v1/chart/data",
+ json={
+ "datasource": {"id": view.id, "type": "semantic_view"},
+ "queries": [{"columns": [], "metrics": []}],
+ "result_type": "query",
+ },
+ )
+ assert response.status_code == 200, response.json
+ finally:
+ manager.__class__ = stock_class
+ db.session.rollback()
+ db.session.delete(view)
+ db.session.delete(layer)
+ db.session.commit()
+
+ @with_feature_flags(ENABLE_VIEWERS=True)
+ def test_denied_viewer_filter_skips_provider_metadata(self) -> None:
+ """A denied ordinary dashboard filter must not load provider
metadata."""
+ self.login("gamma")
+ layer: SemanticLayer = SemanticLayer(name="viewer-metadata-layer",
type="test")
+ view: SemanticView = SemanticView(
+ name="viewer-metadata-view", semantic_layer=layer
+ )
+ db.session.add(view)
+ db.session.flush()
+ metadata: str = json.dumps(
+ {
+ "native_filter_configuration": [
+ {
+ "id": "filter-1",
+ "targets": [
+ {
+ "datasetId": view.id + 1,
+ "datasourceType": "semantic_view",
+ }
+ ],
+ }
+ ]
+ }
+ )
+ dashboard_id: int = db.session.execute(
+ sa.insert(Dashboard.__table__).values(
+ dashboard_title="viewer-metadata-dashboard",
+ published=True,
+ json_metadata=metadata,
+ )
+ ).inserted_primary_key[0]
+ db.session.commit()
+ provider: Mock = Mock()
+ provider.get_dimensions.return_value = set()
+ provider.get_metrics.return_value = set()
+ provider.features = frozenset()
+ provider.selection_identity_version = None
+ provider.uid.return_value = "viewer-metadata-view"
+ try:
+ with (
+ patch.dict(current_app.config, {"VIEWER_PROMISCUOUS_MODE":
True}),
+ patch.object(
+ SemanticView,
+ "implementation",
+ new_callable=PropertyMock,
+ return_value=provider,
+ ),
+ patch.object(security_manager, "is_viewer", return_value=True),
+ ):
+ response: Response = self.client.post(
+ "/api/v1/chart/data",
+ json={
+ "datasource": {"id": view.id, "type": "semantic_view"},
+ "queries": [{"columns": [], "metrics": []}],
+ "form_data": {
+ "dashboardId": dashboard_id,
+ "type": "NATIVE_FILTER",
+ "native_filter_id": "filter-1",
+ },
+ },
+ )
+ assert response.status_code == 403, response.json
+ provider.get_dimensions.assert_not_called()
+ provider.get_metrics.assert_not_called()
+ finally:
+ db.session.rollback()
+ db.session.execute(
+ sa.delete(Dashboard.__table__).where(Dashboard.id ==
dashboard_id)
+ )
+ db.session.delete(view)
+ db.session.delete(layer)
+ db.session.commit()
+
+ @with_feature_flags(ENABLE_VIEWERS=True)
+ def test_allowed_viewer_filter_forwards_form_data(self) -> None:
+ """A dashboard-only native filter grant survives the metadata
preflight."""
+ self.login("gamma")
+ layer: SemanticLayer = SemanticLayer(
+ name="allowed-viewer-metadata-layer", type="test"
+ )
+ view: SemanticView = SemanticView(
+ name="allowed-viewer-metadata-view", semantic_layer=layer
+ )
+ db.session.add(view)
+ db.session.flush()
+ metadata: str = json.dumps(
+ {
+ "native_filter_configuration": [
+ {
+ "id": "filter-1",
+ "targets": [
+ {
+ "datasetId": view.id,
+ "datasourceType": "semantic_view",
+ }
+ ],
+ }
+ ]
+ }
+ )
+ dashboard_id: int = db.session.execute(
+ sa.insert(Dashboard.__table__).values(
+ dashboard_title="allowed-viewer-metadata-dashboard",
+ published=True,
+ json_metadata=metadata,
+ )
+ ).inserted_primary_key[0]
+ db.session.commit()
+ provider: Mock = Mock()
+ provider.get_dimensions.return_value = set()
+ provider.get_metrics.return_value = set()
+ provider.features = frozenset()
+ provider.selection_identity_version = None
+ provider.uid.return_value = "allowed-viewer-metadata-view"
+ access_spy: Mock
+ try:
+ with (
+ patch.dict(current_app.config, {"VIEWER_PROMISCUOUS_MODE":
True}),
+ patch.object(
+ SemanticView,
+ "implementation",
+ new_callable=PropertyMock,
+ return_value=provider,
+ ),
+ patch.object(security_manager, "is_viewer", return_value=True),
+ patch.object(
+ QueryContext,
+ "raise_for_access",
+ autospec=True,
+ side_effect=QueryContext.raise_for_access,
+ ) as access_spy,
+ ):
+ response: Response = self.client.post(
+ "/api/v1/chart/data",
+ json={
+ "datasource": {"id": view.id, "type": "semantic_view"},
+ "queries": [{"columns": [], "metrics": []}],
+ "result_type": "query",
+ "form_data": {
+ "dashboardId": dashboard_id,
+ "type": "NATIVE_FILTER",
+ "native_filter_id": "filter-1",
+ },
+ },
+ )
+ assert response.status_code == 200, response.json
+ provider.get_dimensions.assert_called()
+ assert access_spy.call_count == 2
+ finally:
+ db.session.rollback()
+ db.session.execute(
+ sa.delete(Dashboard.__table__).where(Dashboard.id ==
dashboard_id)
+ )
+ db.session.delete(view)
+ db.session.delete(layer)
+ db.session.commit()
+
+ @with_feature_flags(EMBEDDED_SUPERSET=True)
+ def test_guest_dashboard_filter_access_is_unchanged(self) -> None:
+ """A guest can still use a dashboard-scoped semantic native filter."""
+ self.login("gamma")
+ layer: SemanticLayer = SemanticLayer(name="guest-metadata-layer",
type="test")
+ view: SemanticView = SemanticView(
+ name="guest-metadata-view", semantic_layer=layer
+ )
+ db.session.add(view)
+ db.session.flush()
+ metadata: str = json.dumps(
+ {
+ "native_filter_configuration": [
+ {
+ "id": "filter-1",
+ "targets": [
+ {
+ "datasetId": view.id,
+ "datasourceType": "semantic_view",
+ "column": {"name": "category"},
+ }
+ ],
+ }
+ ]
+ }
+ )
+ dashboard_id: int = db.session.execute(
+ sa.insert(Dashboard.__table__).values(
+ dashboard_title="guest-metadata-dashboard",
+ published=True,
+ json_metadata=metadata,
+ )
+ ).inserted_primary_key[0]
+ db.session.commit()
+ provider: Mock = Mock()
+ provider.get_dimensions.return_value = set()
+ provider.get_metrics.return_value = set()
+ provider.features = frozenset()
+ provider.selection_identity_version = None
+ provider.uid.return_value = "guest-metadata-view"
+ access_spy: Mock
+ try:
+ with (
+ patch.object(
+ SemanticView,
+ "implementation",
+ new_callable=PropertyMock,
+ return_value=provider,
+ ),
+ patch.object(security_manager, "is_guest_user",
return_value=True),
+ patch.object(security_manager, "has_guest_access",
return_value=True),
+ patch.object(
+ QueryContext,
+ "raise_for_access",
+ autospec=True,
+ side_effect=QueryContext.raise_for_access,
+ ) as access_spy,
+ ):
+ g.user.rls = []
+ response: Response = self.client.post(
+ "/api/v1/chart/data",
+ json={
+ "datasource": {"id": view.id, "type": "semantic_view"},
+ "queries": [{"columns": [], "metrics": []}],
+ "result_type": "query",
+ "form_data": {
+ "dashboardId": dashboard_id,
+ "type": "NATIVE_FILTER",
+ "native_filter_id": "filter-1",
+ },
+ },
+ )
+ assert response.status_code == 200, response.json
+ provider.get_dimensions.assert_called()
+ assert access_spy.call_count == 1
+ finally:
+ db.session.rollback()
+ db.session.execute(
+ sa.delete(Dashboard.__table__).where(Dashboard.id ==
dashboard_id)
+ )
+ db.session.delete(view)
+ db.session.delete(layer)
+ db.session.commit()
+
+ def test_instance_wrapped_access_check_still_sees_queries(self) -> None:
+ """An instance wrapper receives only the completed query context."""
+ self._assert_wrapped_access_check_still_sees_queries("instance")
+
+ def test_class_wrapped_access_check_still_sees_queries(self) -> None:
+ """A reassigned base-class method receives only the completed
context."""
+ self._assert_wrapped_access_check_still_sees_queries("class")
+
+ def _assert_wrapped_access_check_still_sees_queries(
+ self, override_target: Literal["instance", "class"]
+ ) -> None:
+ """Instance and base-class wrappers receive only the completed
context."""
+ self.login("gamma")
+ layer: SemanticLayer = SemanticLayer(name="wrapped-metadata-layer",
type="test")
+ view: SemanticView = SemanticView(
+ name="wrapped-metadata-view", semantic_layer=layer
+ )
+ db.session.add(view)
+ db.session.commit()
+ provider: Mock = Mock()
+ provider.get_dimensions.return_value = set()
+ provider.get_metrics.return_value = set()
+ manager: SupersetSecurityManager =
security_manager._get_current_object() # noqa: SLF001
+ original_check: Callable[..., None] = manager.raise_for_access
+ original_can_access: Callable[[str, str], bool] = manager.can_access
+ query_counts: list[int] = []
+
+ def wrapped_access(**kwargs: Any) -> None:
+ """Model a configured wrapper that inspects the first requested
query."""
+ context: QueryContext | None = kwargs.get("query_context")
+ if context is not None:
+ query_counts.append(len(context.queries))
+ assert context.queries[0] is not None
+ original_check(**kwargs)
+
+ def class_wrapped_access(
+ manager_self: SupersetSecurityManager, **kwargs: Any
+ ) -> None:
+ """Bind the configured base-class wrapper like an ordinary
method."""
+ assert manager_self is manager
+ wrapped_access(**kwargs)
+
+ def can_access(permission_name: str, view_name: str) -> bool:
+ """Grant Gamma this semantic datasource for the request."""
+ if permission_name == "datasource_access" and view_name ==
view.perm:
+ return True
+ return original_can_access(permission_name, view_name)
+
+ target: SupersetSecurityManager | type[SupersetSecurityManager] = (
+ manager if override_target == "instance" else
SupersetSecurityManager
+ )
+ replacement: Callable[..., None] = (
+ wrapped_access if override_target == "instance" else
class_wrapped_access
+ )
+ try:
+ with (
+ patch.object(target, "raise_for_access", replacement),
+ patch.object(manager, "can_access", side_effect=can_access),
+ patch.object(
+ SemanticView,
+ "implementation",
+ new_callable=PropertyMock,
+ return_value=provider,
+ ),
+ ):
+ response: Response = self.client.post(
+ "/api/v1/chart/data",
+ json={
+ "datasource": {"id": view.id, "type": "semantic_view"},
+ "queries": [{"columns": [], "metrics": []}],
+ "result_type": "query",
+ },
+ )
+ assert response.status_code == 200, response.json
+ assert query_counts == [1]
+ finally:
+ db.session.rollback()
+ db.session.delete(view)
+ db.session.delete(layer)
+ db.session.commit()
+
+ @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
+ def test_sql_dataset_chart_data_is_unchanged(self) -> None:
+ """The semantic preflight does not affect SQL chart-data requests."""
+ self.login("admin")
+ dataset: SqlaTable | None = (
+
db.session.query(SqlaTable).filter_by(table_name="birth_names").first()
+ )
+ assert dataset is not None
+ access_spy: Mock
+ with patch.object(
+ QueryContext,
+ "raise_for_access",
+ autospec=True,
+ side_effect=QueryContext.raise_for_access,
+ ) as access_spy:
+ response: Response = self.client.post(
+ "/api/v1/chart/data",
+ json={
+ "datasource": {"id": dataset.id, "type": "table"},
+ "queries": [{"columns": ["name"], "metrics": []}],
+ "result_type": "query",
+ },
+ )
+ assert response.status_code == 200, response.json
+ # Admin passes either way, so pin that a SQL dataset gets no preflight.
+ assert access_spy.call_count == 1
diff --git a/tests/unit_tests/security/manager_test.py
b/tests/unit_tests/security/manager_test.py
index b5cf80eb966..6101dc4a685 100644
--- a/tests/unit_tests/security/manager_test.py
+++ b/tests/unit_tests/security/manager_test.py
@@ -41,6 +41,7 @@ from werkzeug.exceptions import NotFound
from superset.common.chart_data import ChartDataResultType
from superset.common.query_object import QueryObject
from superset.connectors.sqla.models import Database, SqlaTable
+from superset.errors import SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.extensions import appbuilder
from superset.models.slice import Slice
@@ -5627,6 +5628,286 @@ def
test_reset_password_self_service_commits_cleared_flag(
assert mock_commit.call_count == 2
[email protected]("granted", [True, False])
+def test_raise_for_access_never_reads_queries_for_non_guest(
+ mocker: MockerFixture,
+ app_context: None,
+ granted: bool,
+) -> None:
+ """
+ Pin the invariant the chart-data semantic preflight relies on: for a
+ non-guest caller, ``raise_for_access`` decides from the datasource and form
+ data alone and never reads ``query_context.queries``.
+ """
+ sm: SupersetSecurityManager = SupersetSecurityManager(appbuilder)
+ mocker.patch.object(sm, "is_guest_user", return_value=False)
+ mocker.patch.object(sm, "can_access", return_value=granted)
+ mocker.patch.object(sm, "can_access_schema", return_value=False)
+ mocker.patch.object(sm, "_semantic_layer_grant_allows", return_value=False)
+ mocker.patch.object(sm, "is_editor", return_value=False)
+
+ query_context: MagicMock = mocker.MagicMock()
+ # A consistent saved chart, so a payload comparison would reach the queries
+ # rather than stopping at a slice mismatch.
+ query_context.slice_.id = 42
+ query_context.slice_.query_context = None
+ query_context.slice_.params_dict = {}
+ query_context.form_data = {"slice_id": 42}
+ type(query_context).queries = mocker.PropertyMock(
+ side_effect=AssertionError("raise_for_access read
query_context.queries")
+ )
+
+ if granted:
+ sm.raise_for_access(query_context=query_context)
+ else:
+ with pytest.raises(SupersetSecurityException):
+ sm.raise_for_access(query_context=query_context)
+
+
+_DASHBOARD_ID: int = 7
+_VIEW_ID: int = 1
+_OTHER_VIEW_ID: int = 2
+
+
+def _chart(mocker: MockerFixture, chart_id: int, datasource_id: int) ->
MagicMock:
+ """A saved chart on the given semantic view."""
+ chart: MagicMock = mocker.MagicMock(
+ id=chart_id, datasource_id=datasource_id,
datasource_type="semantic_view"
+ )
+ return chart
+
+
[email protected](
+ "path",
+ ["native-filter", "chart", "multilayer-child", "drill-to-detail",
"drill-by"],
+)
+def test_raise_for_access_dashboard_paths_never_read_queries_for_non_guest(
+ mocker: MockerFixture,
+ app_context: None,
+ path: str,
+) -> None:
+ """
+ Extend the non-guest invariant to the dashboard, viewer and drill branches.
+
+ Each case passes every earlier check on its branch and is refused only by
+ that branch's last check, which a spy proves was reached, so a read of
+ ``query_context.queries`` anywhere along the branch would fail the test.
+ """
+ from superset.models.dashboard import Dashboard
+ from superset.security import manager as manager_module
+
+ sm: SupersetSecurityManager = SupersetSecurityManager(appbuilder)
+ mocker.patch.object(sm, "is_guest_user", return_value=False)
+ mocker.patch.object(sm, "can_access", return_value=False)
+ mocker.patch.object(sm, "can_access_schema", return_value=False)
+ mocker.patch.object(sm, "_semantic_layer_grant_allows", return_value=False)
+ mocker.patch.object(sm, "is_editor", return_value=False)
+ mocker.patch.object(sm, "is_viewer", return_value=True)
+ mocker.patch.object(sm, "can_access_dashboard", return_value=True)
+ mocker.patch.object(sm, "_validate_child_in_parent_multilayer",
return_value=True)
+ mocker.patch("superset.is_feature_enabled", return_value=True)
+ mocker.patch.dict(current_app.config, {"VIEWER_PROMISCUOUS_MODE": True})
+ matches: MagicMock = mocker.patch.object(
+ manager_module,
+ "_datasource_matches",
+ wraps=manager_module._datasource_matches,
+ )
+
+ datasource: MagicMock = mocker.MagicMock(
+ id=_VIEW_ID, type="semantic_view", perm="[view](id:1)"
+ )
+ datasource.has_drill_by_columns.return_value = False
+ own_chart: MagicMock = _chart(mocker, 42, _VIEW_ID)
+ foreign_chart: MagicMock = _chart(mocker, 42, _OTHER_VIEW_ID)
+ parent_chart: MagicMock = _chart(mocker, 43, _OTHER_VIEW_ID)
+ native_filters: list[dict[str, Any]] = [
+ {
+ "id": "f",
+ "targets": [
+ {"datasetId": _OTHER_VIEW_ID, "datasourceType":
"semantic_view"}
+ ],
+ }
+ ]
+
+ form_data: dict[str, Any]
+ charts: dict[int, MagicMock]
+ if path == "native-filter":
+ form_data = {
+ "dashboardId": _DASHBOARD_ID,
+ "type": "NATIVE_FILTER",
+ "native_filter_id": "f",
+ }
+ charts = {}
+ elif path == "chart":
+ form_data = {"dashboardId": _DASHBOARD_ID, "slice_id": 42}
+ charts = {42: foreign_chart}
+ elif path == "multilayer-child":
+ form_data = {
+ "dashboardId": _DASHBOARD_ID,
+ "slice_id": 42,
+ "parent_slice_id": 43,
+ }
+ charts = {42: foreign_chart, 43: parent_chart}
+ elif path == "drill-to-detail":
+ form_data = {"dashboardId": _DASHBOARD_ID}
+ charts = {}
+ else:
+ form_data = {
+ "dashboardId": _DASHBOARD_ID,
+ "slice_id": 0,
+ "chart_id": 42,
+ "groupby": ["a"],
+ }
+ charts = {42: own_chart}
+
+ dashboard: MagicMock = mocker.MagicMock(
+ id=_DASHBOARD_ID,
+ json_metadata=json.dumps({"native_filter_configuration":
native_filters}),
+ slices=list(charts.values()),
+ )
+ dashboard.has_member_datasource.return_value = False
+
+ def query(model: Any) -> MagicMock:
+ """Resolve ``Model.id == <value>`` lookups against the fixtures."""
+ rows: dict[int, MagicMock] = (
+ {_DASHBOARD_ID: dashboard} if model is Dashboard else charts
+ )
+ chain: MagicMock = mocker.MagicMock()
+ chain.filter.side_effect = lambda clause: mocker.MagicMock(
+ one_or_none=lambda: rows.get(clause.right.value)
+ )
+ return chain
+
+ mocker.patch.object(
+ SupersetSecurityManager,
+ "session",
+ new_callable=mocker.PropertyMock,
+ return_value=mocker.MagicMock(query=query),
+ )
+
+ query_context: MagicMock = mocker.MagicMock()
+ query_context.datasource = datasource
+ query_context.form_data = form_data
+ type(query_context).queries = mocker.PropertyMock(
+ side_effect=AssertionError("raise_for_access read
query_context.queries")
+ )
+
+ excinfo: pytest.ExceptionInfo[SupersetSecurityException]
+ with pytest.raises(SupersetSecurityException) as excinfo:
+ sm.raise_for_access(query_context=query_context)
+
+ assert (
+ excinfo.value.error.error_type
+ == SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR
+ )
+ # Prove each case reached its branch's final check.
+ if path == "drill-by":
+ datasource.has_drill_by_columns.assert_called_once_with(["a"])
+ elif path == "drill-to-detail":
+ dashboard.has_member_datasource.assert_called_once_with(datasource)
+ else:
+ matches.assert_any_call(datasource, _OTHER_VIEW_ID, "semantic_view")
+
+
[email protected]("dashboard_granted", [True, False])
[email protected]("path", ["drill-to-detail", "drill-by"])
+def
test_raise_for_access_dashboard_final_check_never_reads_queries_for_non_guest(
+ mocker: MockerFixture,
+ app_context: None,
+ path: str,
+ dashboard_granted: bool,
+) -> None:
+ """
+ Carry the non-guest invariant through a successful drill to the shared
final
+ ``can_access_dashboard`` check.
+
+ The drill's membership or dimension check passes, so the final dashboard
+ check alone decides the outcome; a spy proves it was reached, and a read of
+ ``query_context.queries`` on the way there or around it fails the test.
+ """
+ from superset.models.dashboard import Dashboard
+
+ sm: SupersetSecurityManager = SupersetSecurityManager(appbuilder)
+ mocker.patch.object(sm, "is_guest_user", return_value=False)
+ mocker.patch.object(sm, "can_access", return_value=False)
+ mocker.patch.object(sm, "can_access_schema", return_value=False)
+ mocker.patch.object(sm, "_semantic_layer_grant_allows", return_value=False)
+ mocker.patch.object(sm, "is_editor", return_value=False)
+ mocker.patch.object(sm, "is_viewer", return_value=True)
+ final_check: MagicMock = mocker.patch.object(
+ sm, "can_access_dashboard", return_value=dashboard_granted
+ )
+ mocker.patch("superset.is_feature_enabled", return_value=True)
+ mocker.patch.dict(current_app.config, {"VIEWER_PROMISCUOUS_MODE": True})
+
+ datasource: MagicMock = mocker.MagicMock(
+ id=_VIEW_ID, type="semantic_view", perm="[view](id:1)"
+ )
+ datasource.has_drill_by_columns.return_value = True
+
+ form_data: dict[str, Any]
+ charts: dict[int, MagicMock]
+ if path == "drill-to-detail":
+ form_data = {"dashboardId": _DASHBOARD_ID}
+ charts = {}
+ else:
+ form_data = {
+ "dashboardId": _DASHBOARD_ID,
+ "slice_id": 0,
+ "chart_id": 42,
+ "groupby": ["a"],
+ }
+ charts = {42: _chart(mocker, 42, _VIEW_ID)}
+
+ dashboard: MagicMock = mocker.MagicMock(
+ id=_DASHBOARD_ID, json_metadata=None, slices=list(charts.values())
+ )
+ dashboard.has_member_datasource.return_value = True
+
+ def query(model: Any) -> MagicMock:
+ """Resolve ``Model.id == <value>`` lookups against the fixtures."""
+ rows: dict[int, MagicMock] = (
+ {_DASHBOARD_ID: dashboard} if model is Dashboard else charts
+ )
+ chain: MagicMock = mocker.MagicMock()
+ chain.filter.side_effect = lambda clause: mocker.MagicMock(
+ one_or_none=lambda: rows.get(clause.right.value)
+ )
+ return chain
+
+ mocker.patch.object(
+ SupersetSecurityManager,
+ "session",
+ new_callable=mocker.PropertyMock,
+ return_value=mocker.MagicMock(query=query),
+ )
+
+ query_context: MagicMock = mocker.MagicMock()
+ query_context.datasource = datasource
+ query_context.form_data = form_data
+ type(query_context).queries = mocker.PropertyMock(
+ side_effect=AssertionError("raise_for_access read
query_context.queries")
+ )
+
+ if dashboard_granted:
+ sm.raise_for_access(query_context=query_context)
+ else:
+ excinfo: pytest.ExceptionInfo[SupersetSecurityException]
+ with pytest.raises(SupersetSecurityException) as excinfo:
+ sm.raise_for_access(query_context=query_context)
+ assert (
+ excinfo.value.error.error_type
+ == SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR
+ )
+
+ # The drill succeeded, so the final dashboard check made the decision.
+ if path == "drill-by":
+ datasource.has_drill_by_columns.assert_called_once_with(["a"])
+ else:
+ dashboard.has_member_datasource.assert_called_once_with(datasource)
+ final_check.assert_called_once_with(dashboard)
+
+
def test_dataset_delete_uses_stored_permission_identity(
mocker: MockerFixture, app_context: None
) -> None: