# 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. # pylint: disable=invalid-name, unused-argument, redefined-outer-name import json # noqa: TID251 from types import SimpleNamespace from typing import Any, Optional import pytest from flask_appbuilder.security.sqla.models import Role, User from pytest_mock import MockerFixture from superset.common.query_object import QueryObject from superset.connectors.sqla.models import Database, SqlaTable from superset.exceptions import SupersetSecurityException from superset.extensions import appbuilder from superset.models.slice import Slice from superset.security.manager import ( _collect_sortable_identifiers, freeze_value, query_context_modified, SupersetSecurityManager, ) from superset.sql.parse import Table from superset.superset_typing import AdhocColumn, AdhocMetric from superset.utils.core import DatasourceName, override_user def test_security_manager(app_context: None) -> None: """ Test that the security manager can be built. """ sm = SupersetSecurityManager(appbuilder) assert sm @pytest.fixture def stored_metrics() -> list[AdhocMetric]: """ Return a list of metrics. """ return [ { "column": None, "expressionType": "SQL", "hasCustomLabel": False, "label": "COUNT(*) + 1", "sqlExpression": "COUNT(*) + 1", }, ] @pytest.fixture def stored_columns() -> list[AdhocColumn]: """ Return a list of columns. """ return [ { "label": "My column", "sqlExpression": "UPPER(name)", }, ] def test_raise_for_access_guest_user_ok( mocker: MockerFixture, app_context: None, stored_metrics: list[AdhocMetric], stored_columns: list[AdhocColumn], ) -> None: """ Test that guest user can submit an unmodified chart payload. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "is_guest_user", return_value=True) mocker.patch.object(sm, "can_access", return_value=True) query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.query_context = None query_context.slice_.params_dict = { "metrics": stored_metrics, "columns": stored_columns, } query_context.form_data = { "slice_id": 42, "metrics": stored_metrics, "columns": stored_columns, } query_context.queries = [QueryObject(metrics=stored_metrics)] # type: ignore sm.raise_for_access(query_context=query_context) def test_raise_for_access_guest_user_ok_subset( mocker: MockerFixture, app_context: None, stored_metrics: list[AdhocMetric], stored_columns: list[AdhocColumn], ) -> None: """ Test that guest user can submit a request of a subset of the metrics/columns. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "is_guest_user", return_value=True) mocker.patch.object(sm, "can_access", return_value=True) query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.query_context = None query_context.slice_.params_dict = { "metrics": stored_metrics, "columns": stored_columns, } query_context.form_data = { "slice_id": 42, "metrics": [], "columns": [], } query_context.queries = [QueryObject(metrics=stored_metrics)] # type: ignore sm.raise_for_access(query_context=query_context) def test_raise_for_access_guest_user_tampered_id( mocker: MockerFixture, app_context: None, stored_metrics: list[AdhocMetric], ) -> None: """ Test that guest user cannot modify the chart ID. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "is_guest_user", return_value=True) mocker.patch.object(sm, "can_access", return_value=True) query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.query_context = None query_context.slice_.params_dict = { "metrics": stored_metrics, } query_context.form_data = { "slice_id": 43, "metrics": stored_metrics, } query_context.queries = [QueryObject(metrics=stored_metrics)] # type: ignore with pytest.raises(SupersetSecurityException): sm.raise_for_access(query_context=query_context) def test_raise_for_access_guest_user_tampered_form_data_metrics( mocker: MockerFixture, app_context: None, stored_metrics: list[AdhocMetric], ) -> None: """ Test that guest user cannot modify metrics in the form data. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "is_guest_user", return_value=True) mocker.patch.object(sm, "can_access", return_value=True) query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.query_context = None query_context.slice_.params_dict = { "metrics": stored_metrics, } tampered_metrics = [ { "column": None, "expressionType": "SQL", "hasCustomLabel": False, "label": "COUNT(*) + 2", "sqlExpression": "COUNT(*) + 2", } ] query_context.form_data = { "slice_id": 42, "metrics": tampered_metrics, } with pytest.raises(SupersetSecurityException): sm.raise_for_access(query_context=query_context) def test_raise_for_access_guest_user_tampered_form_data_columns( mocker: MockerFixture, app_context: None, stored_columns: list[AdhocColumn], ) -> None: """ Test that guest user cannot modify columns in the form data. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "is_guest_user", return_value=True) mocker.patch.object(sm, "can_access", return_value=True) query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.query_context = None query_context.slice_.params_dict = { "columns": stored_columns, } tampered_columns = [ { "label": "My column", "sqlExpression": "list_secret()", "expressionType": "SQL", }, ] query_context.form_data = { "slice_id": 42, "columns": tampered_columns, } with pytest.raises(SupersetSecurityException): sm.raise_for_access(query_context=query_context) def test_raise_for_access_guest_user_tampered_form_data_groupby( mocker: MockerFixture, app_context: None, stored_columns: list[AdhocColumn], ) -> None: """ Test that guest user cannot modify groupby in the form data. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "is_guest_user", return_value=True) mocker.patch.object(sm, "can_access", return_value=True) query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.query_context = None query_context.slice_.params_dict = { "groupby": stored_columns, } tampered_columns = [ { "label": "My column", "sqlExpression": "list_secret()", "expressionType": "SQL", }, ] query_context.form_data = { "slice_id": 42, "columns": tampered_columns, } with pytest.raises(SupersetSecurityException): sm.raise_for_access(query_context=query_context) def test_raise_for_access_guest_user_tampered_queries_metrics( mocker: MockerFixture, app_context: None, stored_metrics: list[AdhocMetric], ) -> None: """ Test that guest user cannot modify metrics in the queries. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "is_guest_user", return_value=True) mocker.patch.object(sm, "can_access", return_value=True) query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.query_context = None query_context.slice_.params_dict = { "metrics": stored_metrics, } tampered_metrics = [ { "column": None, "expressionType": "SQL", "hasCustomLabel": False, "label": "COUNT(*) + 2", "sqlExpression": "COUNT(*) + 2", } ] query_context.form_data = { "slice_id": 42, "metrics": stored_metrics, } query_context.queries = [QueryObject(metrics=tampered_metrics)] # type: ignore with pytest.raises(SupersetSecurityException): sm.raise_for_access(query_context=query_context) def test_raise_for_access_guest_user_tampered_queries_columns( mocker: MockerFixture, app_context: None, stored_columns: list[AdhocColumn], ) -> None: """ Test that guest user cannot modify columns in the queries. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "is_guest_user", return_value=True) mocker.patch.object(sm, "can_access", return_value=True) query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.query_context = None query_context.slice_.params_dict = { "columns": stored_columns, } tampered_columns = [ { "label": "My column", "sqlExpression": "list_secret()", "expressionType": "SQL", } ] query_context.form_data = { "slice_id": 42, "columns": stored_columns, } query_context.queries = [QueryObject(metrics=tampered_columns)] # type: ignore with pytest.raises(SupersetSecurityException): sm.raise_for_access(query_context=query_context) def test_raise_for_access_query_default_schema( mocker: MockerFixture, app_context: None, ) -> None: """ Test that the DB default schema is used in non-qualified table names. For example, in Postgres, for the following query: > SELECT * FROM foo; We should check that the user has access to the `public` schema, regardless of the schema set in the query. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "can_access_database", return_value=False) mocker.patch.object(sm, "get_schema_perm", return_value="[PostgreSQL].[public]") mocker.patch.object(sm, "is_guest_user", return_value=False) SqlaTable = mocker.patch("superset.connectors.sqla.models.SqlaTable") # noqa: N806 SqlaTable.query_datasources_by_name.return_value = [] database = mocker.MagicMock() database.get_default_catalog.return_value = None database.get_default_schema_for_query.return_value = "public" query = mocker.MagicMock() query.catalog = None query.database = database query.sql = "SELECT * FROM ab_user" # user has access to `public` schema mocker.patch.object(sm, "can_access", return_value=True) assert ( sm.raise_for_access( # type: ignore database=None, datasource=None, query=query, query_context=None, table=None, viz=None, ) is None ) sm.can_access.assert_called_with("schema_access", "[PostgreSQL].[public]") # type: ignore # user has only access to `secret` schema mocker.patch.object(sm, "can_access", return_value=False) with pytest.raises(SupersetSecurityException) as excinfo: sm.raise_for_access( database=None, datasource=None, query=query, query_context=None, table=None, viz=None, ) assert ( str(excinfo.value) == 'You need access to the following tables: "public.ab_user", ' "'all_database_access' or 'all_datasource_access' permission" ) def test_raise_for_access_jinja_sql(mocker: MockerFixture, app_context: None) -> None: """ Test that Jinja gets rendered to SQL. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "can_access_database", return_value=False) mocker.patch.object(sm, "get_schema_perm", return_value="[PostgreSQL].[public]") mocker.patch.object(sm, "can_access", return_value=False) mocker.patch.object(sm, "is_guest_user", return_value=False) get_table_access_error_object = mocker.patch.object( sm, "get_table_access_error_object" ) SqlaTable = mocker.patch("superset.connectors.sqla.models.SqlaTable") # noqa: N806 SqlaTable.query_datasources_by_name.return_value = [] database = mocker.MagicMock() database.get_default_catalog.return_value = None database.get_default_schema_for_query.return_value = "public" query = mocker.MagicMock() query.catalog = None query.database = database query.sql = "SELECT * FROM {% if True %}ab_user{% endif %} WHERE 1=1" with pytest.raises(SupersetSecurityException): sm.raise_for_access( database=None, datasource=None, query=query, query_context=None, table=None, viz=None, ) get_table_access_error_object.assert_called_with({Table("ab_user", "public", None)}) def test_raise_for_access_chart_for_datasource_permission( mocker: MockerFixture, app_context: None, ) -> None: """ Test that the security manager can raise an exception for chart access, when the user does not have access to the chart datasource """ sm = SupersetSecurityManager(appbuilder) session = sm.session engine = session.get_bind() Slice.metadata.create_all(engine) # pylint: disable=no-member alpha = User( first_name="Alice", last_name="Doe", email="adoe@example.org", username="admin", roles=[Role(name="Alpha")], ) dataset = SqlaTable( table_name="test_table", metrics=[], main_dttm_col=None, database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"), ) session.add(dataset) session.flush() slice = Slice( id=1, datasource_id=dataset.id, datasource_type="table", datasource_name="tmp_perm_table", slice_name="slice_name", ) session.add(slice) session.flush() mocker.patch.object(sm, "can_access_datasource", return_value=False) with override_user(alpha): with pytest.raises(SupersetSecurityException) as excinfo: sm.raise_for_access( chart=slice, ) assert str(excinfo.value) == "You don't have access to this chart." mocker.patch.object(sm, "can_access_datasource", return_value=True) with override_user(alpha): sm.raise_for_access( chart=slice, ) def test_raise_for_access_chart_on_admin( app_context: None, ) -> None: """ Test that the security manager can raise an exception for chart access, when the user does not have access to the chart datasource """ from flask_appbuilder.security.sqla.models import Role, User from superset.models.slice import Slice from superset.utils.core import override_user sm = SupersetSecurityManager(appbuilder) session = sm.session engine = session.get_bind() Slice.metadata.create_all(engine) # pylint: disable=no-member admin = User( first_name="Alice", last_name="Doe", email="adoe@example.org", username="admin", roles=[Role(name="Admin")], ) slice = Slice( id=1, datasource_id=1, datasource_type="table", datasource_name="tmp_perm_table", slice_name="slice_name", ) session.add(slice) session.flush() with override_user(admin): sm.raise_for_access( chart=slice, ) def test_raise_for_access_chart_owner( app_context: None, ) -> None: """ Test that the security manager can raise an exception for chart access, when the user does not have access to the chart datasource """ sm = SupersetSecurityManager(appbuilder) session = sm.session engine = session.get_bind() Slice.metadata.create_all(engine) # pylint: disable=no-member # Check if Alpha role already exists alpha_role = session.query(Role).filter_by(name="Alpha").first() if not alpha_role: alpha_role = Role(name="Alpha") session.add(alpha_role) session.commit() # Check if user already exists alpha = session.query(User).filter_by(username="test_chart_owner_user").first() if not alpha: alpha = User( first_name="Alice", last_name="Doe", email="adoe@example.org", username="test_chart_owner_user", roles=[alpha_role], ) session.add(alpha) session.commit() else: # Ensure the user has the Alpha role if alpha_role not in alpha.roles: alpha.roles.append(alpha_role) session.commit() slice = Slice( id=1, datasource_id=1, datasource_type="table", datasource_name="tmp_perm_table", slice_name="slice_name", owners=[alpha], ) session.add(slice) with override_user(alpha): sm.raise_for_access( chart=slice, ) def test_query_context_modified( mocker: MockerFixture, stored_metrics: list[AdhocMetric], ) -> None: """ Test the `query_context_modified` function. The function is used to ensure guest users are not modifying the request payload on embedded dashboard, preventing users from modifying it to access metrics different from the ones stored in dashboard charts. """ query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.query_context = None query_context.slice_.params_dict = { "metrics": stored_metrics, } query_context.form_data = { "slice_id": 42, "metrics": stored_metrics, } query_context.queries = [QueryObject(metrics=stored_metrics)] # type: ignore assert not query_context_modified(query_context) def test_query_context_modified_tampered( mocker: MockerFixture, stored_metrics: list[AdhocMetric], ) -> None: """ Test the `query_context_modified` function when the request is tampered with. The function is used to ensure guest users are not modifying the request payload on embedded dashboard, preventing users from modifying it to access metrics different from the ones stored in dashboard charts. """ query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.query_context = None query_context.slice_.params_dict = { "metrics": stored_metrics, } tampered_metrics = [ { "column": None, "expressionType": "SQL", "hasCustomLabel": False, "label": "COUNT(*) + 2", "sqlExpression": "COUNT(*) + 2", } ] query_context.form_data = { "slice_id": 42, "metrics": tampered_metrics, } query_context.queries = [QueryObject(metrics=tampered_metrics)] # type: ignore assert query_context_modified(query_context) def _native_filter_ctx( mocker: MockerFixture, queries: list[Any], *, native_filter_id: str | None = "F1", dashboard_id: int | None = 10, dataset_id: int = 20, targets: list[Any] | None = None, control_values: dict[str, Any] | None = None, ) -> Any: """Build a native-filter query context (no slice_) + patched dashboard.""" if targets is None: targets = [{"datasetId": dataset_id, "column": {"name": "region"}}] qc = mocker.MagicMock() qc.slice_ = None qc.form_data = { "type": "NATIVE_FILTER", "native_filter_id": native_filter_id, "dashboardId": dashboard_id, } qc.datasource.data = {"id": dataset_id} qc.queries = queries dash = mocker.MagicMock() dash.json_metadata = json.dumps( { "native_filter_configuration": [ { "id": "F1", "targets": targets, "controlValues": control_values or {}, } ] } ) query_chain = mocker.patch("superset.db.session.query") query_chain.return_value.filter.return_value.one_or_none.return_value = dash return qc def test_query_context_modified_native_filter_target_column_allowed( mocker: MockerFixture, ) -> None: """A native-filter request reading only its target column is allowed.""" query = SimpleNamespace(columns=["region"], metrics=[], groupby=[]) qc = _native_filter_ctx(mocker, [query]) assert not query_context_modified(qc) def test_query_context_modified_native_filter_arbitrary_column_blocked( mocker: MockerFixture, ) -> None: """A native-filter request reading a non-target column is modified.""" query = SimpleNamespace(columns=["ssn"], metrics=[], groupby=[]) qc = _native_filter_ctx(mocker, [query]) assert query_context_modified(qc) def test_query_context_modified_native_filter_simple_metric_on_target_allowed( mocker: MockerFixture, ) -> None: """A range-style request (simple aggregate over the target column) is allowed.""" query = SimpleNamespace( columns=[], metrics=[ { "expressionType": "SIMPLE", "column": {"column_name": "region"}, "aggregate": "MIN", } ], groupby=[], ) qc = _native_filter_ctx(mocker, [query]) assert not query_context_modified(qc) def test_query_context_modified_native_filter_adhoc_metric_blocked( mocker: MockerFixture, ) -> None: """A free-form SQL metric on the native-filter path is modified.""" query = SimpleNamespace( columns=[], metrics=[{"expressionType": "SQL", "sqlExpression": "SUM(salary)"}], groupby=[], ) qc = _native_filter_ctx(mocker, [query]) assert query_context_modified(qc) def test_query_context_modified_native_filter_adhoc_column_blocked( mocker: MockerFixture, ) -> None: """An adhoc (free-form SQL) column on the native-filter path is modified.""" query = SimpleNamespace( columns=[{"sqlExpression": "ssn", "label": "x"}], metrics=[], groupby=[] ) qc = _native_filter_ctx(mocker, [query]) assert query_context_modified(qc) def test_query_context_modified_native_filter_no_filter_context_blocked( mocker: MockerFixture, ) -> None: """Without a native_filter_id / dashboardId the request fails closed.""" query = SimpleNamespace(columns=["region"], metrics=[], groupby=[]) qc = _native_filter_ctx(mocker, [query], native_filter_id=None) assert query_context_modified(qc) def test_query_context_modified_native_filter_configured_sort_metric_allowed( mocker: MockerFixture, ) -> None: """A value lookup sorted by the filter's configured saved metric is allowed.""" query = SimpleNamespace( columns=["region"], metrics=["total"], groupby=[], orderby=[["total", True]], ) qc = _native_filter_ctx(mocker, [query], control_values={"sortMetric": "total"}) assert not query_context_modified(qc) def test_query_context_modified_native_filter_arbitrary_saved_metric_blocked( mocker: MockerFixture, ) -> None: """A saved metric other than the filter's configured sort metric is modified.""" query = SimpleNamespace(columns=["region"], metrics=["salary_total"], groupby=[]) qc = _native_filter_ctx(mocker, [query], control_values={"sortMetric": "total"}) assert query_context_modified(qc) def test_query_context_modified_native_filter_orderby_arbitrary_column_blocked( mocker: MockerFixture, ) -> None: """Ordering by a non-target column on the native-filter path is modified.""" query = SimpleNamespace( columns=["region"], metrics=[], groupby=[], orderby=[["ssn", True]] ) qc = _native_filter_ctx(mocker, [query]) assert query_context_modified(qc) def test_query_context_modified_native_filter_orderby_adhoc_blocked( mocker: MockerFixture, ) -> None: """Ordering by a free-form SQL expression on the native-filter path is modified.""" query = SimpleNamespace( columns=["region"], metrics=[], groupby=[], orderby=[[{"sqlExpression": "ssn"}, True]], ) qc = _native_filter_ctx(mocker, [query]) assert query_context_modified(qc) def test_query_context_modified_chartless_non_native_filter_allowed( mocker: MockerFixture, ) -> None: """ A chartless request that is not a native filter (drill-to-detail, drill-by, samples) is validated by the datasource-access checks in raise_for_access and is not constrained here. """ qc = mocker.MagicMock() qc.slice_ = None qc.form_data = {"dashboardId": 10, "slice_id": 0, "groupby": ["ssn"]} assert not query_context_modified(qc) def test_query_context_modified_native_filter_without_type_marker_blocked( mocker: MockerFixture, ) -> None: """ A request identified by native_filter_id is constrained even when the NATIVE_FILTER type marker is absent. """ query = SimpleNamespace(columns=["ssn"], metrics=[], groupby=[]) qc = _native_filter_ctx(mocker, [query]) del qc.form_data["type"] assert query_context_modified(qc) def test_query_context_modified_mixed_chart(mocker: MockerFixture) -> None: """ Test the `query_context_modified` function for a mixed chart request. The metrics in the mixed chart are a nested dictionary (due to `columns`), and need to be serialized to JSON with the keys sorted in order to compare the request metrics with the chart metrics. """ stored_metrics = [ { "optionName": "metric_vgops097wej_g8uff99zhk7", "label": "AVG(num)", "expressionType": "SIMPLE", "column": {"column_name": "num", "type": "BIGINT(20)"}, "aggregate": "AVG", } ] # different order (remember, dicts have order!) requested_metrics = [ { "aggregate": "AVG", "column": {"column_name": "num", "type": "BIGINT(20)"}, "expressionType": "SIMPLE", "label": "AVG(num)", "optionName": "metric_vgops097wej_g8uff99zhk7", } ] query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.query_context = None query_context.slice_.params_dict = { "metrics": stored_metrics, } query_context.form_data = { "slice_id": 42, "metrics": requested_metrics, } query_context.queries = [QueryObject(metrics=requested_metrics)] # type: ignore assert not query_context_modified(query_context) def test_query_context_modified_sankey_tampered(mocker: MockerFixture) -> None: """ Test the `query_context_modified` function for a sankey chart request. """ query_context = mocker.MagicMock() query_context.queries = [ QueryObject( apply_fetch_values_predicate=False, columns=["bot_id", "channel_id"], extras={"having": "", "where": ""}, filter=[ { "col": "bot_profile__updated", "op": "TEMPORAL_RANGE", "val": "No filter", } ], from_dttm=None, granularity=None, inner_from_dttm=None, inner_to_dttm=None, is_rowcount=False, is_timeseries=False, metrics=["count"], order_desc=True, orderby=[], row_limit=10000, row_offset=0, series_columns=[], series_limit=0, series_limit_metric=None, time_shift=None, to_dttm=None, ), ] query_context.form_data = { "datasource": "12__table", "viz_type": "sankey_v2", "slice_id": 97, "url_params": {}, "source": "bot_id", "target": "channel_id", "metric": "count", "adhoc_filters": [ { "clause": "WHERE", "comparator": "No filter", "expressionType": "SIMPLE", "operator": "TEMPORAL_RANGE", "subject": "bot_profile__updated", } ], "row_limit": 10000, "color_scheme": "supersetColors", "dashboards": [11], "extra_form_data": {}, "label_colors": {}, "shared_label_colors": [], "map_label_colors": {}, "extra_filters": [], "dashboardId": 11, "force": False, "result_format": "json", "result_type": "full", } query_context.slice_.id = 97 query_context.slice_.params_dict = { "datasource": "12__table", "viz_type": "sankey_v2", "slice_id": 97, "source": "bot_id", "target": "channel_id", "metric": "count", "adhoc_filters": [ { "clause": "WHERE", "comparator": "No filter", "expressionType": "SIMPLE", "operator": "TEMPORAL_RANGE", "subject": "bot_profile__updated", } ], "row_limit": 10000, "color_scheme": "supersetColors", "extra_form_data": {}, "dashboards": [11], } query_context.slice_.query_context = json.dumps( { "datasource": {"id": 12, "type": "table"}, "force": False, "queries": [ { "filters": [ { "col": "bot_profile__updated", "op": "TEMPORAL_RANGE", "val": "No filter", } ], "extras": {"having": "", "where": ""}, "applied_time_extras": {}, "columns": [], "metrics": ["count"], "annotation_layers": [], "row_limit": 10000, "series_limit": 0, "order_desc": True, "url_params": {}, "custom_params": {}, "custom_form_data": {}, "groupby": ["bot_id", "channel_id"], } ], "form_data": { "datasource": "12__table", "viz_type": "sankey_v2", "slice_id": 97, "source": "bot_id", "target": "channel_id", "metric": "count", "adhoc_filters": [ { "clause": "WHERE", "comparator": "No filter", "expressionType": "SIMPLE", "operator": "TEMPORAL_RANGE", "subject": "bot_profile__updated", } ], "row_limit": 10000, "color_scheme": "supersetColors", "extra_form_data": {}, "dashboards": [11], "force": False, "result_format": "json", "result_type": "full", }, "result_format": "json", "result_type": "full", } ) assert not query_context_modified(query_context) def test_query_context_modified_orderby(mocker: MockerFixture) -> None: """ Test the `query_context_modified` function when the ORDER BY is modified. """ tampered_groupby: AdhocMetric = { "aggregate": "", "column": None, "expressionType": "SQL", "hasCustomLabel": False, "label": "random()", "sqlExpression": "random()", } query_context = mocker.MagicMock() query_context.queries = [ QueryObject( apply_fetch_values_predicate=False, columns=["gender"], extras={"having": "", "where": ""}, filter=[{"col": "ds", "op": "TEMPORAL_RANGE", "val": "No filter"}], from_dttm=None, granularity=None, inner_from_dttm=None, inner_to_dttm=None, is_rowcount=False, is_timeseries=False, metrics=["count"], order_desc=True, orderby=[(tampered_groupby, False)], row_limit=1000, row_offset=0, series_columns=[], series_limit=0, series_limit_metric=tampered_groupby, time_shift=None, to_dttm=None, ), ] query_context.form_data = { "datasource": "2__table", "viz_type": "table", "slice_id": 101, "url_params": { "datasource_id": "2", "datasource_type": "table", "save_action": "saveas", "slice_id": "101", }, "query_mode": "aggregate", "groupby": ["gender"], "time_grain_sqla": "P1D", "temporal_columns_lookup": {"ds": True}, "metrics": ["count"], "all_columns": [], "percent_metrics": [], "adhoc_filters": [ { "clause": "WHERE", "comparator": "No filter", "expressionType": "SIMPLE", "operator": "TEMPORAL_RANGE", "subject": "ds", } ], "timeseries_limit_metric": { "aggregate": None, "column": None, "datasourceWarning": False, "expressionType": "SQL", "hasCustomLabel": False, "label": "random()", "optionName": "metric_3kwbghgzkv9_wz84h9j1p5d", "sqlExpression": "random()", }, "order_by_cols": [], "row_limit": 1000, "server_page_length": 10, "order_desc": True, "table_timestamp_format": "smart_date", "allow_render_html": True, "show_cell_bars": True, "color_pn": True, "comparison_color_scheme": "Green", "comparison_type": "values", "extra_form_data": {}, "force": False, "result_format": "json", "result_type": "full", } query_context.slice_.id = 101 query_context.slice_.params_dict = { "datasource": "2__table", "viz_type": "table", "query_mode": "aggregate", "groupby": ["gender"], "time_grain_sqla": "P1D", "temporal_columns_lookup": {"ds": True}, "metrics": ["count"], "all_columns": [], "percent_metrics": [], "adhoc_filters": [ { "clause": "WHERE", "subject": "ds", "operator": "TEMPORAL_RANGE", "comparator": "No filter", "expressionType": "SIMPLE", } ], "order_by_cols": [], "row_limit": 1000, "server_page_length": 10, "order_desc": True, "table_timestamp_format": "smart_date", "allow_render_html": True, "show_cell_bars": True, "color_pn": True, "comparison_color_scheme": "Green", "comparison_type": "values", "extra_form_data": {}, "dashboards": [], } query_context.slice_.query_context = json.dumps( { "datasource": {"id": 2, "type": "table"}, "force": False, "queries": [ { "filters": [ {"col": "ds", "op": "TEMPORAL_RANGE", "val": "No filter"} ], "extras": {"having": "", "where": ""}, "applied_time_extras": {}, "columns": ["gender"], "metrics": ["count"], "orderby": [], "annotation_layers": [], "row_limit": 1000, "series_limit": 0, "order_desc": True, "url_params": {}, "custom_params": {}, "custom_form_data": {}, "post_processing": [], "time_offsets": [], } ], "form_data": { "datasource": "2__table", "viz_type": "table", "query_mode": "aggregate", "groupby": ["gender"], "time_grain_sqla": "P1D", "temporal_columns_lookup": {"ds": True}, "metrics": ["count"], "all_columns": [], "percent_metrics": [], "adhoc_filters": [ { "clause": "WHERE", "subject": "ds", "operator": "TEMPORAL_RANGE", "comparator": "No filter", "expressionType": "SIMPLE", } ], "order_by_cols": [], "row_limit": 1000, "server_page_length": 10, "order_desc": True, "table_timestamp_format": "smart_date", "allow_render_html": True, "show_cell_bars": True, "color_pn": True, "comparison_color_scheme": "Green", "comparison_type": "values", "extra_form_data": {}, "dashboards": [], "force": False, "result_format": "json", "result_type": "full", }, "result_format": "json", "result_type": "full", } ) assert query_context_modified(query_context) def _table_sort_query_context( mocker: MockerFixture, orderby: list[Any], *, stored_metrics: Optional[list[Any]] = None, with_stored_query_context: bool = True, ) -> Any: """ Build a minimal table-chart query context for a guest that sorts by ``orderby``. The stored chart groups by ``gender`` and aggregates ``count``. """ metrics: list[Any] = stored_metrics if stored_metrics is not None else ["count"] query_context = mocker.MagicMock() query_context.queries = [ QueryObject(columns=["gender"], metrics=metrics, orderby=orderby), ] query_context.form_data = {"slice_id": 101, "groupby": ["gender"]} query_context.slice_.id = 101 query_context.slice_.params_dict = {"groupby": ["gender"], "metrics": metrics} query_context.slice_.query_context = ( json.dumps({"queries": [{"columns": ["gender"], "metrics": metrics}]}) if with_stored_query_context else None ) return query_context def test_query_context_modified_orderby_sort_by_column(mocker: MockerFixture) -> None: """A guest sorting an embedded table by an existing column is allowed.""" query_context = _table_sort_query_context(mocker, orderby=[("gender", True)]) assert not query_context_modified(query_context) def test_query_context_modified_orderby_sort_by_metric(mocker: MockerFixture) -> None: """A guest sorting by an existing metric is allowed.""" query_context = _table_sort_query_context(mocker, orderby=[("count", False)]) assert not query_context_modified(query_context) def test_query_context_modified_orderby_sort_by_adhoc_metric( mocker: MockerFixture, ) -> None: """A guest sorting by an existing adhoc metric definition is allowed.""" adhoc_metric: AdhocMetric = { "aggregate": "SUM", "column": {"column_name": "num"}, "expressionType": "SIMPLE", "hasCustomLabel": False, "label": "SUM(num)", } query_context = _table_sort_query_context( mocker, orderby=[(adhoc_metric, False)], stored_metrics=[adhoc_metric], ) assert not query_context_modified(query_context) def test_query_context_modified_orderby_unknown_column(mocker: MockerFixture) -> None: """A guest sorting by a column the chart does not reference is rejected.""" query_context = _table_sort_query_context(mocker, orderby=[("salary", True)]) assert query_context_modified(query_context) def test_query_context_modified_orderby_empty(mocker: MockerFixture) -> None: """An empty order-by is not a modification.""" query_context = _table_sort_query_context(mocker, orderby=[]) assert not query_context_modified(query_context) def test_query_context_modified_orderby_legacy_singular_metric( mocker: MockerFixture, ) -> None: """A guest sorting by a legacy ``metric`` (singular) field is allowed.""" query_context = _table_sort_query_context( mocker, orderby=[("num_sold", False)], stored_metrics=[], with_stored_query_context=False, ) query_context.slice_.params_dict = { "columns": ["gender"], "groupby": ["gender"], "metric": "num_sold", } assert not query_context_modified(query_context) def test_query_context_modified_orderby_malformed_entry( mocker: MockerFixture, ) -> None: """A malformed order-by entry (not a ``(term, bool)`` pair) is rejected.""" query_context = _table_sort_query_context(mocker, orderby=[["gender"]]) assert query_context_modified(query_context) def test_query_context_modified_orderby_sort_by_stored_qc_only_column( mocker: MockerFixture, ) -> None: """A column present only in the stored query context is an allowed sort target.""" query_context = _table_sort_query_context(mocker, orderby=[("age", True)]) # "age" is absent from params_dict but exposed via the stored query context, # so sorting by it must be allowed. query_context.slice_.params_dict = {"groupby": ["gender"], "metrics": ["count"]} query_context.slice_.query_context = json.dumps( {"queries": [{"columns": ["gender", "age"], "metrics": ["count"]}]} ) assert not query_context_modified(query_context) def test_collect_sortable_identifiers_includes_stored_qc_all_columns( mocker: MockerFixture, ) -> None: """``all_columns`` exposed via the stored query context is a valid sort target.""" stored_chart = mocker.MagicMock() stored_chart.params_dict = {"groupby": ["gender"], "metrics": ["count"]} allowed = _collect_sortable_identifiers( stored_chart, {"queries": [{"all_columns": ["gender", "age"], "metrics": ["count"]}]}, ) assert freeze_value("age") in allowed def test_query_context_modified_time_grain_native_filter( mocker: MockerFixture, ) -> None: """ Test `query_context_modified` when a guest applies a Time Grain native filter. Reproduces https://github.com/apache/superset/issues/32768. On a chart that uses a generic x-axis, the selected time grain is baked into the ``BASE_AXIS`` adhoc column as a ``timeGrain`` property (see ``normalizeTimeColumn`` on the frontend, which copies ``extras.time_grain_sqla`` onto the column). A Time Grain native filter is a supported, read-only guest interaction: it only changes the granularity at which the *same* dimension is bucketed, never which metrics or columns are queried. Previously, because the changed time grain travels inside the ``columns`` payload, the subset comparison treated the request as tampering and ``query_context_modified`` returned ``True`` -- so guests hit "Guest user cannot modify chart payload" whenever they picked a grain other than the chart default. ``freeze_value`` now drops the guest-overridable ``timeGrain`` key before comparing, so a pure time-grain change is no longer flagged as a modification. This test guards that behavior. """ # The chart was saved with a monthly grain on its x-axis column. stored_axis_column: AdhocColumn = { "label": "order_date", "sqlExpression": "order_date", "columnType": "BASE_AXIS", "timeGrain": "P1M", } # The guest picked a daily grain via the dashboard Time Grain native filter; # `normalizeTimeColumn` rewrote the otherwise-identical column accordingly. requested_axis_column: AdhocColumn = { **stored_axis_column, "timeGrain": "P1D", } query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.params_dict = { "metrics": ["count"], } query_context.slice_.query_context = json.dumps( { "queries": [ { "columns": [stored_axis_column], "metrics": ["count"], } ], } ) # Native-filter data requests don't carry the mutated columns at the top level; # the grain change only shows up inside the query's columns. query_context.form_data = { "slice_id": 42, "metrics": ["count"], } query_context.queries = [ QueryObject( columns=[requested_axis_column], metrics=["count"], ), ] assert not query_context_modified(query_context) def test_query_context_modified_time_grain_with_tampered_column( mocker: MockerFixture, ) -> None: """ Test that relaxing the time grain comparison does not open a tamper hole. Only the ``timeGrain`` key is guest-overridable. A request that changes the grain *and* also swaps a non-overridable attribute (here ``sqlExpression``, which selects which column is queried) must still be flagged as tampering -- otherwise a guest could query an arbitrary column under cover of a Time Grain filter. """ stored_axis_column: AdhocColumn = { "label": "order_date", "sqlExpression": "order_date", "columnType": "BASE_AXIS", "timeGrain": "P1M", } # Guest changes the grain (allowed) but also rewrites the SQL expression to a # different column (not allowed) -- this must still read as a modification. tampered_axis_column: AdhocColumn = { **stored_axis_column, "sqlExpression": "secret_column", "timeGrain": "P1D", } query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.params_dict = { "metrics": ["count"], } query_context.slice_.query_context = json.dumps( { "queries": [ { "columns": [stored_axis_column], "metrics": ["count"], } ], } ) query_context.form_data = { "slice_id": 42, "metrics": ["count"], } query_context.queries = [ QueryObject( columns=[tampered_axis_column], metrics=["count"], ), ] assert query_context_modified(query_context) def test_query_context_modified_time_grain_in_orderby( mocker: MockerFixture, ) -> None: """ Test `query_context_modified` when the time grain travels inside `orderby`. Each ``orderby`` entry is an ``(column, bool)`` tuple, so a temporal x-axis adhoc column carrying the guest-overridable ``timeGrain`` is nested one level deep rather than sitting at the top level. The overridable key must still be stripped before comparing, otherwise sorting by the temporal axis would make a pure time-grain change read as tampering. """ stored_axis_column: AdhocColumn = { "label": "order_date", "sqlExpression": "order_date", "columnType": "BASE_AXIS", "timeGrain": "P1M", } requested_axis_column: AdhocColumn = { **stored_axis_column, "timeGrain": "P1D", } query_context = mocker.MagicMock() query_context.slice_.id = 42 query_context.slice_.params_dict = { "metrics": ["count"], } query_context.slice_.query_context = json.dumps( { "queries": [ { "orderby": [[stored_axis_column, True]], "metrics": ["count"], } ], } ) query_context.form_data = { "slice_id": 42, "metrics": ["count"], } query_context.queries = [ QueryObject( orderby=[(requested_axis_column, True)], metrics=["count"], ), ] assert not query_context_modified(query_context) def test_get_catalog_perm() -> None: """ Test the `get_catalog_perm` method. """ sm = SupersetSecurityManager(appbuilder) assert sm.get_catalog_perm("my_db", None) is None assert sm.get_catalog_perm("my_db", "my_catalog") == "[my_db].[my_catalog]" def test_get_schema_perm() -> None: """ Test the `get_schema_perm` method. """ sm = SupersetSecurityManager(appbuilder) assert sm.get_schema_perm("my_db", None, "my_schema") == "[my_db].[my_schema]" assert ( sm.get_schema_perm("my_db", "my_catalog", "my_schema") == "[my_db].[my_catalog].[my_schema]" ) assert sm.get_schema_perm("my_db", None, None) is None assert sm.get_schema_perm("my_db", "my_catalog", None) is None def test_raise_for_access_catalog( mocker: MockerFixture, app_context: None, ) -> None: """ Test catalog-level permissions. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "can_access_database", return_value=False) mocker.patch.object( sm, "get_catalog_perm", return_value="[PostgreSQL].[db1]", ) mocker.patch.object(sm, "is_guest_user", return_value=False) SqlaTable = mocker.patch("superset.connectors.sqla.models.SqlaTable") # noqa: N806 SqlaTable.query_datasources_by_name.return_value = [] database = mocker.MagicMock() database.get_default_catalog.return_value = "db1" database.get_default_schema_for_query.return_value = "public" query = mocker.MagicMock() query.catalog = "db1" query.database = database query.sql = "SELECT * FROM ab_user" can_access = mocker.patch.object(sm, "can_access", return_value=True) sm.raise_for_access(query=query) can_access.assert_called_with("catalog_access", "[PostgreSQL].[db1]") mocker.patch.object(sm, "can_access", return_value=False) with pytest.raises(SupersetSecurityException) as excinfo: sm.raise_for_access(query=query) assert ( str(excinfo.value) == 'You need access to the following tables: "db1.public.ab_user", ' "'all_database_access' or 'all_datasource_access' permission" ) query.sql = "SELECT * FROM db2.public.ab_user" with pytest.raises(SupersetSecurityException) as excinfo: sm.raise_for_access(query=query) assert ( str(excinfo.value) == 'You need access to the following tables: "db2.public.ab_user", ' "'all_database_access' or 'all_datasource_access' permission" ) def test_get_datasources_accessible_by_user_schema_access( mocker: MockerFixture, app_context: None, ) -> None: """ Test that `get_datasources_accessible_by_user` works with schema permissions. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "can_access_database", return_value=False) database = mocker.MagicMock() database.database_name = "db1" database.get_default_catalog.return_value = "catalog2" # False for catalog_access, True for schema_access can_access = mocker.patch.object(sm, "can_access", side_effect=[False, True]) datasource_names = [ DatasourceName("table1", "schema1", "catalog2"), DatasourceName("table2", "schema1", "catalog2"), ] assert sm.get_datasources_accessible_by_user( database, datasource_names, catalog=None, schema="schema1", ) == [ DatasourceName("table1", "schema1", "catalog2"), DatasourceName("table2", "schema1", "catalog2"), ] # Even though we passed `catalog=None,` the schema check uses the default catalog # when building the schema permission, since the DB supports catalog. can_access.assert_has_calls( [ mocker.call("catalog_access", "[db1].[catalog2]"), mocker.call("schema_access", "[db1].[catalog2].[schema1]"), ] ) def test_get_catalogs_accessible_by_user_schema_access( mocker: MockerFixture, app_context: None, ) -> None: """ Test that `get_catalogs_accessible_by_user` works with schema permissions. """ sm = SupersetSecurityManager(appbuilder) mocker.patch.object(sm, "can_access_database", return_value=False) mocker.patch.object( sm, "user_view_menu_names", side_effect=[ set(), # catalog_access {"[db1].[catalog2].[schema1]"}, # schema_access set(), # datasource_access ], ) database = mocker.MagicMock() database.database_name = "db1" database.get_default_catalog.return_value = "catalog2" catalogs = {"catalog1", "catalog2"} assert sm.get_catalogs_accessible_by_user(database, catalogs) == {"catalog2"} def test_get_rls_filters_uses_table_id_directly( mocker: MockerFixture, app_context: None, ) -> None: """ Test that get_rls_filters() uses table.id directly instead of table.data["id"]. Accessing table.data triggers the full data property chain including select_star, which requires a live database engine connection. When the DB is unreachable, this causes the entire dashboard GET endpoint to fail with a 500 error. This test ensures we use the direct .id attribute and never access .data, preventing regressions that would break dashboard loading when DBs are unavailable. """ sm = SupersetSecurityManager(appbuilder) # Create a mock table where .data raises an exception if accessed table = mocker.MagicMock() table.id = 42 type(table).data = mocker.PropertyMock( side_effect=Exception( "table.data should not be accessed - use table.id directly" ) ) # Mock user context mock_user = mocker.MagicMock() mock_user.roles = [mocker.MagicMock(id=1)] mocker.patch("superset.security.manager.g", user=mock_user) mocker.patch.object(sm, "get_user_roles", return_value=mock_user.roles) # Call get_rls_filters - if it accesses table.data, the PropertyMock will raise # If it uses table.id directly (correct behavior), it will complete successfully result = sm.get_rls_filters(table) assert isinstance(result, list) def test_get_rls_filters_returns_cached_result( mocker: MockerFixture, app_context: None, ) -> None: """ Test that get_rls_filters() returns cached results on subsequent calls for the same user and table, avoiding redundant DB queries. """ sm = SupersetSecurityManager(appbuilder) mock_user = mocker.MagicMock() mock_user.id = 1 mock_user.username = "admin" mock_user.roles = [mocker.MagicMock(id=1)] mock_g = SimpleNamespace(user=mock_user) mocker.patch("superset.security.manager.g", new=mock_g) mocker.patch("superset.security.manager.get_username", return_value="admin") mocker.patch.object(sm, "get_user_roles", return_value=mock_user.roles) table = mocker.MagicMock() table.id = 42 # First call populates the cache result1 = sm.get_rls_filters(table) # Verify cache was populated keyed by (username, table_id) assert ("admin", 42) in mock_g._rls_filter_cache # Replace session query with something that would fail if called mocker.patch.object( sm.session, "query", side_effect=AssertionError("DB should not be queried on cache hit"), ) # Second call should return cached result without querying DB result2 = sm.get_rls_filters(table) assert result1 == result2 def test_prefetch_rls_filters_populates_cache( mocker: MockerFixture, app_context: None, ) -> None: """ Test that prefetch_rls_filters() populates the cache for all provided table_ids, including empty results for tables with no matching filters. """ sm = SupersetSecurityManager(appbuilder) mock_user = mocker.MagicMock() mock_user.id = 1 mock_user.username = "admin" mock_user.roles = [mocker.MagicMock(id=10)] mock_g = SimpleNamespace(user=mock_user) mocker.patch("superset.security.manager.g", new=mock_g) mocker.patch("superset.security.manager.get_username", return_value="admin") mocker.patch.object(sm, "get_user_roles", return_value=mock_user.roles) # Mock the batch query to return filters for table 1 but not table 2 mock_query = mocker.MagicMock() mock_query.join.return_value = mock_query mock_query.filter.return_value = mock_query mock_query.all.return_value = [ (1, 100, "group_a", "id > 0"), # table_id=1 (1, 101, None, "active = 1"), # table_id=1 ] mocker.patch.object(sm.session, "query", return_value=mock_query) sm.prefetch_rls_filters([1, 2]) # Table 1 should have 2 filters with named attribute access cached = mock_g._rls_filter_cache[("admin", 1)] assert len(cached) == 2 assert cached[0].id == 100 assert cached[0].group_key == "group_a" assert cached[0].clause == "id > 0" assert cached[1].id == 101 assert cached[1].group_key is None assert cached[1].clause == "active = 1" # Table 2 should have empty list assert mock_g._rls_filter_cache[("admin", 2)] == [] def test_prefetch_rls_filters_skips_cached_ids( mocker: MockerFixture, app_context: None, ) -> None: """ Test that prefetch_rls_filters() skips table_ids already in cache and returns early when all ids are cached. """ sm = SupersetSecurityManager(appbuilder) mock_user = mocker.MagicMock() mock_user.id = 1 mock_user.username = "admin" mock_user.roles = [mocker.MagicMock(id=10)] mock_g = SimpleNamespace( user=mock_user, _rls_filter_cache={("admin", 1): [(100, "group_a", "id > 0")]}, ) mocker.patch("superset.security.manager.g", new=mock_g) mocker.patch("superset.security.manager.get_username", return_value="admin") mocker.patch.object(sm, "get_user_roles", return_value=mock_user.roles) # If it queries the DB, this will fail mocker.patch.object( sm.session, "query", side_effect=AssertionError("DB should not be queried for cached ids"), ) # All ids already cached -> should return immediately sm.prefetch_rls_filters([1]) def test_prefetch_rls_filters_no_user( mocker: MockerFixture, app_context: None, ) -> None: """ Test that prefetch_rls_filters() returns early when no user is present. """ sm = SupersetSecurityManager(appbuilder) mocker.patch("superset.security.manager.g", new=SimpleNamespace()) # Should not attempt any DB queries mocker.patch.object( sm.session, "query", side_effect=AssertionError("DB should not be queried without a user"), ) sm.prefetch_rls_filters([1, 2]) def test_get_rls_filters_cache_works_for_guest_user( mocker: MockerFixture, app_context: None, ) -> None: """ Test that get_rls_filters() caches results for guest users using the same (username, table_id) cache key as regular users. """ sm = SupersetSecurityManager(appbuilder) mock_guest = mocker.MagicMock() mock_guest.username = "guest_user" mock_guest.roles = [mocker.MagicMock(id=99)] mock_g = SimpleNamespace(user=mock_guest) mocker.patch("superset.security.manager.g", new=mock_g) mocker.patch("superset.security.manager.get_username", return_value="guest_user") mocker.patch.object(sm, "get_user_roles", return_value=mock_guest.roles) table = mocker.MagicMock() table.id = 42 # First call runs the query result1 = sm.get_rls_filters(table) # Verify cache was populated with (username, table_id) key assert ("guest_user", 42) in mock_g._rls_filter_cache # Replace session query to detect if it's called again mocker.patch.object( sm.session, "query", side_effect=AssertionError("DB should not be queried on cache hit"), ) # Second call should use cache result2 = sm.get_rls_filters(table) assert result1 == result2 def test_prefetch_rls_filters_works_for_guest_user( mocker: MockerFixture, app_context: None, ) -> None: """ Test that prefetch_rls_filters() works for guest users using the same (username, table_id) cache key as regular users. """ sm = SupersetSecurityManager(appbuilder) mock_guest = mocker.MagicMock() mock_guest.username = "guest_user" mock_guest.roles = [mocker.MagicMock(id=99)] mock_g = SimpleNamespace(user=mock_guest) mocker.patch("superset.security.manager.g", new=mock_g) mocker.patch("superset.security.manager.get_username", return_value="guest_user") mocker.patch.object(sm, "get_user_roles", return_value=mock_guest.roles) # Mock the batch query returning no filters mock_query = mocker.MagicMock() mock_query.join.return_value = mock_query mock_query.filter.return_value = mock_query mock_query.all.return_value = [] mocker.patch.object(sm.session, "query", return_value=mock_query) sm.prefetch_rls_filters([10, 20]) # Cache should be populated with (username, table_id) keys and empty lists assert mock_g._rls_filter_cache[("guest_user", 10)] == [] assert mock_g._rls_filter_cache[("guest_user", 20)] == [] def test_validate_child_in_parent_multilayer_valid( app_context: None, mocker: MockerFixture ) -> None: """Test validation succeeds for valid multi-layer child""" sm = SupersetSecurityManager(appbuilder) parent_slice = mocker.MagicMock(spec=Slice) parent_slice.params = json.dumps( {"viz_type": "deck_multi", "deck_slices": [1, 2, 3]} ) # Child 2 is in parent's deck_slices assert sm._validate_child_in_parent_multilayer( child_slice_id=2, parent_slice=parent_slice ) def test_validate_child_in_parent_multilayer_invalid_child( app_context: None, mocker: MockerFixture ) -> None: """Test validation fails for child not in parent config""" sm = SupersetSecurityManager(appbuilder) parent_slice = mocker.MagicMock(spec=Slice) parent_slice.params = json.dumps( {"viz_type": "deck_multi", "deck_slices": [1, 2, 3]} ) # Child 5 is NOT in parent's deck_slices assert not sm._validate_child_in_parent_multilayer( child_slice_id=5, parent_slice=parent_slice ) def test_validate_child_in_parent_multilayer_wrong_viz_type( app_context: None, mocker: MockerFixture ) -> None: """Test validation fails for non-multilayer charts""" sm = SupersetSecurityManager(appbuilder) parent_slice = mocker.MagicMock(spec=Slice) parent_slice.params = json.dumps( { "viz_type": "line", # Not deck_multi "deck_slices": [1, 2, 3], } ) assert not sm._validate_child_in_parent_multilayer( child_slice_id=2, parent_slice=parent_slice ) def test_validate_child_in_parent_multilayer_empty_deck_slices( app_context: None, mocker: MockerFixture ) -> None: """Test validation fails when deck_slices is empty""" sm = SupersetSecurityManager(appbuilder) parent_slice = mocker.MagicMock(spec=Slice) parent_slice.params = json.dumps({"viz_type": "deck_multi", "deck_slices": []}) assert not sm._validate_child_in_parent_multilayer( child_slice_id=1, parent_slice=parent_slice ) def test_validate_child_in_parent_multilayer_no_deck_slices( app_context: None, mocker: MockerFixture ) -> None: """Test validation fails when deck_slices is missing""" sm = SupersetSecurityManager(appbuilder) parent_slice = mocker.MagicMock(spec=Slice) parent_slice.params = json.dumps( { "viz_type": "deck_multi" # No deck_slices key } ) assert not sm._validate_child_in_parent_multilayer( child_slice_id=1, parent_slice=parent_slice ) def test_validate_child_in_parent_multilayer_malformed_json( app_context: None, mocker: MockerFixture ) -> None: """Test validation fails gracefully with malformed JSON""" sm = SupersetSecurityManager(appbuilder) parent_slice = mocker.MagicMock(spec=Slice) parent_slice.params = "not valid json {{" assert not sm._validate_child_in_parent_multilayer( child_slice_id=1, parent_slice=parent_slice ) def test_validate_child_in_parent_multilayer_null_params( app_context: None, mocker: MockerFixture ) -> None: """Test validation fails gracefully with null params""" sm = SupersetSecurityManager(appbuilder) parent_slice = mocker.MagicMock(spec=Slice) parent_slice.params = None assert not sm._validate_child_in_parent_multilayer( child_slice_id=1, parent_slice=parent_slice ) def test_user_view_menu_names_for_guest_user( mocker: MockerFixture, app_context: None, ) -> None: """ Test that user_view_menu_names resolves permissions from the guest user's roles instead of querying by user_id (which is None for guests). """ sm = SupersetSecurityManager(appbuilder) mock_role = mocker.MagicMock(spec=Role) mock_role.id = 99 mock_guest = mocker.MagicMock() mock_guest.is_anonymous = False mock_guest.roles = [mock_role] mock_g = SimpleNamespace(user=mock_guest) mocker.patch("superset.security.manager.g", new=mock_g) mocker.patch.object(sm, "is_guest_user", return_value=True) # The regression: guest path must NEVER fall through to get_user_id(). # Patching it as an error means an accidental fall-through fails loudly. mock_get_user_id = mocker.patch( "superset.security.manager.get_user_id", side_effect=AssertionError("get_user_id must not be called for guest users"), ) mock_result = [SimpleNamespace(name="[PostgreSQL].[my_table](id:1)")] mock_query = mocker.MagicMock() mock_query.join.return_value = mock_query mock_query.filter.return_value = mock_query mock_query.all.return_value = mock_result mocker.patch.object(sm.session, "query", return_value=mock_query) result = sm.user_view_menu_names("datasource_access") assert result == {"[PostgreSQL].[my_table](id:1)"} mock_get_user_id.assert_not_called() mock_query.filter.assert_called() def test_user_view_menu_names_for_guest_user_no_roles( mocker: MockerFixture, app_context: None, ) -> None: """ Test that user_view_menu_names returns empty set when guest user has no roles with valid IDs. """ sm = SupersetSecurityManager(appbuilder) mock_role = mocker.MagicMock(spec=Role) mock_role.id = None mock_guest = mocker.MagicMock() mock_guest.is_anonymous = False mock_guest.roles = [mock_role] mock_g = SimpleNamespace(user=mock_guest) mocker.patch("superset.security.manager.g", new=mock_g) mocker.patch.object(sm, "is_guest_user", return_value=True) mock_get_user_id = mocker.patch( "superset.security.manager.get_user_id", side_effect=AssertionError("get_user_id must not be called for guest users"), ) result = sm.user_view_menu_names("datasource_access") assert result == set() mock_get_user_id.assert_not_called() def test_reset_password_self_service_clears_flag( mocker: MockerFixture, app_context: None, ) -> None: """A user resetting their own password clears the forced-change flag.""" sm = SupersetSecurityManager(appbuilder) # The target user (id 5) is the same as the acting user -> self-service. mock_g = SimpleNamespace(user=SimpleNamespace(id=5)) mocker.patch("superset.security.manager.g", new=mock_g) # Avoid touching the real DB in the FAB base implementation. mocker.patch( "flask_appbuilder.security.manager.BaseSecurityManager.reset_password", return_value=None, ) mock_clear = mocker.patch( "superset.security.password_change.clear_password_must_change" ) sm.reset_password(5, "new-password") mock_clear.assert_called_once_with(5) def test_reset_password_admin_does_not_clear_flag( mocker: MockerFixture, app_context: None, ) -> None: """An admin-initiated reset must preserve the forced-change requirement. FAB's ``ResetPasswordView`` passes the target as the ``pk`` request-arg string while ``g.user`` remains the admin, so the acting user differs from the target and the flag must NOT be cleared. """ sm = SupersetSecurityManager(appbuilder) # Acting user is admin (id 1); target is a different user ("5" as a string, # as FAB passes it from request args). mock_g = SimpleNamespace(user=SimpleNamespace(id=1)) mocker.patch("superset.security.manager.g", new=mock_g) mocker.patch( "flask_appbuilder.security.manager.BaseSecurityManager.reset_password", return_value=None, ) mock_clear = mocker.patch( "superset.security.password_change.clear_password_must_change" ) sm.reset_password("5", "temp-password") mock_clear.assert_not_called() def test_reset_password_self_service_pk_string_clears_flag( mocker: MockerFixture, app_context: None, ) -> None: """Self-service identity holds even if ids arrive as mixed int/str types.""" sm = SupersetSecurityManager(appbuilder) mock_g = SimpleNamespace(user=SimpleNamespace(id=5)) mocker.patch("superset.security.manager.g", new=mock_g) mocker.patch( "flask_appbuilder.security.manager.BaseSecurityManager.reset_password", return_value=None, ) mock_clear = mocker.patch( "superset.security.password_change.clear_password_must_change" ) sm.reset_password("5", "new-password") # Coerced to int when clearing, regardless of the inbound id type. mock_clear.assert_called_once_with(5)