| # 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 |
| |
| 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 ( |
| 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 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) |