mirror of
https://github.com/apache/superset.git
synced 2026-08-24 09:01:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
893925f0eb | ||
|
|
698181e93d |
@@ -18,6 +18,8 @@ import logging
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand, CreateMixin
|
||||
from superset.commands.tag.exceptions import TagCreateFailedError, TagInvalidError
|
||||
@@ -97,9 +99,14 @@ class CreateCustomTagCommand(CreateMixin, BaseCommand):
|
||||
f"Access validation not supported for {object_type}"
|
||||
)
|
||||
)
|
||||
except SupersetSecurityException:
|
||||
except (SupersetSecurityException, TemplateError):
|
||||
# A TemplateError can surface when authorizing a saved query whose
|
||||
# Jinja-templated SQL must be parsed to resolve table references; a
|
||||
# malformed template is a validation failure, not an unhandled 500.
|
||||
exceptions.append(
|
||||
TagCreateFailedError(f"Access denied for {object_type} {object_id}")
|
||||
TagCreateFailedError(
|
||||
f"Could not validate access for {object_type} {object_id}"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ import logging
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.tag.exceptions import (
|
||||
@@ -109,26 +107,7 @@ class DeleteTaggedObjectCommand(DeleteMixin, BaseCommand):
|
||||
elif object_type == ObjectType.chart:
|
||||
security_manager.raise_for_access(chart=target_object)
|
||||
elif object_type == ObjectType.query:
|
||||
# Authorizing a query without blanket database access parses its
|
||||
# Jinja-templated SQL, which can raise ``TemplateError`` for
|
||||
# malformed templates. Convert that into a validation error
|
||||
# rather than letting it surface as an opaque 500.
|
||||
try:
|
||||
security_manager.raise_for_access(query=target_object)
|
||||
except TemplateError as ex:
|
||||
logger.warning(
|
||||
"Failed to render Jinja SQL while validating access "
|
||||
"for %s %s: %s",
|
||||
object_type,
|
||||
object_id,
|
||||
ex,
|
||||
)
|
||||
exceptions.append(
|
||||
TaggedObjectDeleteFailedError(
|
||||
f"Access validation failed for {object_type} "
|
||||
f"{object_id}: {ex}"
|
||||
)
|
||||
)
|
||||
security_manager.raise_for_access(query=target_object)
|
||||
elif object_type == ObjectType.dataset:
|
||||
security_manager.raise_for_access(datasource=target_object)
|
||||
else:
|
||||
|
||||
@@ -213,9 +213,7 @@ def orderby_from_form_data(
|
||||
# The drag-and-drop "sort by" control persists a list; the frontend unwraps it
|
||||
# with ``ensureIsArray(...)[0]`` (``plugin-chart-table/src/buildQuery.ts:67``).
|
||||
# Read raw, a list would nest inside ``orderby`` and fail the query.
|
||||
raw_sort_metric = form_data.get("series_limit_metric") or form_data.get(
|
||||
"timeseries_limit_metric"
|
||||
)
|
||||
raw_sort_metric = form_data.get("timeseries_limit_metric")
|
||||
sort_metric = (
|
||||
next(iter(as_list(raw_sort_metric)), None) if raw_sort_metric else None
|
||||
) or (metrics[0] if form_data.get("sort_by_metric") else None)
|
||||
|
||||
@@ -290,7 +290,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
groupby=["name"],
|
||||
adhoc_filters=[gen_filter("gender", "girl")],
|
||||
row_limit=50,
|
||||
series_limit_metric=metric,
|
||||
timeseries_limit_metric=metric,
|
||||
metrics=[metric],
|
||||
),
|
||||
editors=[],
|
||||
@@ -321,7 +321,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
groupby=["name"],
|
||||
adhoc_filters=[gen_filter("gender", "boy")],
|
||||
row_limit=50,
|
||||
series_limit_metric=metric,
|
||||
timeseries_limit_metric=metric,
|
||||
metrics=[metric],
|
||||
),
|
||||
editors=[],
|
||||
@@ -498,7 +498,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
viz_type="echarts_timeseries_line",
|
||||
granularity_sqla="ds",
|
||||
groupby=["name"],
|
||||
series_limit_metric={
|
||||
timeseries_limit_metric={
|
||||
"expressionType": "SIMPLE",
|
||||
"column": {
|
||||
"column_name": "num_california",
|
||||
@@ -522,7 +522,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
metrics=metrics,
|
||||
groupby=["name"],
|
||||
row_limit=50,
|
||||
series_limit_metric={
|
||||
timeseries_limit_metric={
|
||||
"expressionType": "SIMPLE",
|
||||
"column": {
|
||||
"column_name": "num_california",
|
||||
|
||||
@@ -36,8 +36,8 @@ params:
|
||||
metrics:
|
||||
- sum__num
|
||||
row_limit: 50
|
||||
series_limit_metric: sum__num
|
||||
time_range: '100 years ago : now'
|
||||
timeseries_limit_metric: sum__num
|
||||
viz_type: table
|
||||
query_context: null
|
||||
slice_name: Boys
|
||||
|
||||
@@ -36,8 +36,8 @@ params:
|
||||
metrics:
|
||||
- sum__num
|
||||
row_limit: 50
|
||||
series_limit_metric: sum__num
|
||||
time_range: '100 years ago : now'
|
||||
timeseries_limit_metric: sum__num
|
||||
viz_type: table
|
||||
query_context: null
|
||||
slice_name: Girls
|
||||
|
||||
@@ -218,31 +218,6 @@ def test_orderby_uses_timeseries_limit_metric_and_order_desc() -> None:
|
||||
assert query["orderby"] == [["revenue", True]]
|
||||
|
||||
|
||||
def test_orderby_uses_series_limit_metric_and_order_desc() -> None:
|
||||
# series_limit_metric is the current field name; timeseries_limit_metric is
|
||||
# the deprecated alias kept above for back-compat with old saved charts.
|
||||
form_data = {
|
||||
"metrics": ["count"],
|
||||
"groupby": ["c"],
|
||||
"series_limit_metric": "revenue",
|
||||
"order_desc": False,
|
||||
}
|
||||
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
|
||||
assert query["orderby"] == [["revenue", True]]
|
||||
|
||||
|
||||
def test_orderby_prefers_series_limit_metric_over_deprecated_alias() -> None:
|
||||
form_data = {
|
||||
"metrics": ["count"],
|
||||
"groupby": ["c"],
|
||||
"series_limit_metric": "revenue",
|
||||
"timeseries_limit_metric": "profit",
|
||||
"order_desc": False,
|
||||
}
|
||||
query = build_query_context_from_form_data(form_data, DATASOURCE)["queries"][0]
|
||||
assert query["orderby"] == [["revenue", True]]
|
||||
|
||||
|
||||
def test_orderby_pie_sort_by_metric() -> None:
|
||||
form_data = {"metric": "count", "groupby": ["c"], "sort_by_metric": True}
|
||||
query = build_query_context_from_form_data(form_data, DATASOURCE, viz_type="pie")[
|
||||
|
||||
@@ -108,6 +108,40 @@ def test_create_command_success(session_with_data: Session, mocker: MockerFixtur
|
||||
)
|
||||
|
||||
|
||||
def test_validate_object_access_query_malformed_jinja(
|
||||
session_with_data: Session, mocker: MockerFixture
|
||||
):
|
||||
"""A saved query whose Jinja-templated SQL fails to parse during access
|
||||
checks must surface as a validation error, not an unhandled
|
||||
``jinja2.TemplateError`` escaping as a 500.
|
||||
|
||||
When ``raise_for_access(query=...)`` authorizes a saved query via
|
||||
per-table permissions it parses the query's Jinja SQL (e.g. an unclosed
|
||||
``{% if %}`` block raises ``TemplateSyntaxError``). Mock that call to raise
|
||||
the ``TemplateError`` directly so the test stays hermetic and does not open
|
||||
a live DB connection to introspect table-level perms.
|
||||
"""
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset.commands.tag.create import CreateCustomTagCommand
|
||||
from superset.commands.tag.exceptions import TagInvalidError
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
from superset.tags.models import ObjectType
|
||||
|
||||
query = db.session.query(SavedQuery).first()
|
||||
|
||||
mocker.patch("superset.commands.tag.create.to_object_model", return_value=query)
|
||||
mocker.patch(
|
||||
"superset.commands.tag.create.security_manager.raise_for_access",
|
||||
side_effect=TemplateError("unclosed {% if %}"),
|
||||
)
|
||||
|
||||
command = CreateCustomTagCommand(ObjectType.query, query.id, ["tag"])
|
||||
|
||||
with pytest.raises(TagInvalidError):
|
||||
command.validate()
|
||||
|
||||
|
||||
def test_create_command_success_clear(
|
||||
session_with_data: Session, mocker: MockerFixture
|
||||
):
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
# 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.
|
||||
import pytest
|
||||
from jinja2.exceptions import TemplateSyntaxError
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_with_data(session: Session):
|
||||
from superset.models.core import Database
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
from superset.tags.models import ObjectType, Tag, TaggedObject
|
||||
|
||||
engine = session.get_bind()
|
||||
Tag.metadata.create_all(engine) # pylint: disable=no-member
|
||||
|
||||
database = Database(database_name="my_database", sqlalchemy_uri="postgresql://")
|
||||
|
||||
saved_query = SavedQuery(
|
||||
id=1, label="test_query", database=database, sql="select {{ unclosed"
|
||||
)
|
||||
|
||||
tag = Tag(name="test_name", description="test_description")
|
||||
|
||||
session.add(database)
|
||||
session.add(saved_query)
|
||||
session.add(tag)
|
||||
session.commit()
|
||||
|
||||
session.add(
|
||||
TaggedObject(object_id=saved_query.id, object_type=ObjectType.query, tag=tag)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
return session
|
||||
|
||||
|
||||
def test_delete_command_query_template_error_becomes_validation_error(
|
||||
session_with_data: Session, mocker: MockerFixture
|
||||
):
|
||||
"""Regression test: a Jinja ``TemplateError`` raised while authorizing a
|
||||
query must surface as ``TagInvalidError`` (422), not an opaque 500.
|
||||
|
||||
``raise_for_access`` is mocked directly so the test stays hermetic and does
|
||||
not depend on a live database to reach ``process_jinja_sql``.
|
||||
"""
|
||||
from superset.commands.tag.delete import DeleteTaggedObjectCommand
|
||||
from superset.commands.tag.exceptions import TagInvalidError
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
from superset.tags.models import ObjectType
|
||||
|
||||
query = session_with_data.query(SavedQuery).first()
|
||||
|
||||
template_error_message = "unexpected end of template"
|
||||
mocker.patch(
|
||||
"superset.security.SupersetSecurityManager.raise_for_access",
|
||||
side_effect=TemplateSyntaxError(template_error_message, lineno=1),
|
||||
)
|
||||
|
||||
with pytest.raises(TagInvalidError) as excinfo:
|
||||
DeleteTaggedObjectCommand(
|
||||
object_type=ObjectType.query,
|
||||
object_id=query.id,
|
||||
tag="test_name",
|
||||
).validate()
|
||||
|
||||
# The real template error text must be preserved in the collected exceptions
|
||||
collected = " ".join(
|
||||
str(ex)
|
||||
for ex in excinfo.value._exceptions # noqa: SLF001
|
||||
)
|
||||
assert template_error_message in collected
|
||||
Reference in New Issue
Block a user