mirror of
https://github.com/apache/superset.git
synced 2026-07-06 06:45:34 +00:00
2169 lines
69 KiB
Python
2169 lines
69 KiB
Python
# 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)
|