This is an automated email from the ASF dual-hosted git repository.
sadpandajoe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 1d752a0ced6 fix(embedded): load guest charts with missing or stale
query_context (#42150)
1d752a0ced6 is described below
commit 1d752a0ced6925d0b9b73a303660de497bc30ea1
Author: Joe Li <[email protected]>
AuthorDate: Mon Jul 27 14:14:55 2026 -0700
fix(embedded): load guest charts with missing or stale query_context
(#42150)
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
superset/security/manager.py | 136 ++++++-
tests/unit_tests/security/manager_test.py | 567 ++++++++++++++++++++++++++++++
2 files changed, 698 insertions(+), 5 deletions(-)
diff --git a/superset/security/manager.py b/superset/security/manager.py
index 1040e00beff..ff214c50908 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -568,6 +568,67 @@ def freeze_value(value: Any) -> str:
return json.dumps(_strip_overridable_keys(value), sort_keys=True)
+# Frontend-only markers that ``normalizeTimeColumn`` adds when it synthesizes a
+# chart's x-axis into a ``BASE_AXIS`` column. Like ``timeGrain`` they decorate
+# the column without changing which data is queried, so they must not count as
+# payload tampering.
+BASE_AXIS_SYNTHETIC_KEYS = frozenset({"columnType", "isColumnReference"})
+
+
+def _denormalize_base_axis_column(value: Any) -> Any:
+ """
+ Reduce a synthesized ``BASE_AXIS`` x-axis column back to the reference it
+ stands for.
+
+ Before a chart is queried, ``normalizeTimeColumn`` (superset-ui-core,
+ ``normalizeTimeColumn.ts``) replaces the chart's saved x-axis in the query
+ with an adhoc column tagged ``columnType: "BASE_AXIS"``:
+
+ * for a *physical* x-axis it emits a pure column reference
+ (``isColumnReference: true`` with ``sqlExpression`` == ``label`` == the
+ physical column name), and
+ * for an *adhoc* x-axis it copies the saved adhoc column and adds the
+ markers (plus the time grain, already dropped by ``freeze_value``).
+
+ Neither form selects data beyond the saved x-axis, yet neither appears
+ verbatim in the chart's stored ``params``/``query_context`` (the x-axis is
+ stored under its own ``x_axis`` control, and for charts whose saved
+ ``query_context`` is NULL there is nothing to compare against at all). So a
+ guest merely loading such a chart would otherwise be rejected as a
tamperer.
+
+ This collapses the synthesized column to its underlying reference, so it
+ compares equal to the stored x-axis: a physical reference becomes its
column
+ name, and an adhoc reference becomes the underlying adhoc column without
the
+ synthesized markers. The collapsed value must still match a value stored on
+ the chart, so tagging an unrelated column or free-form SQL as ``BASE_AXIS``
+ grants no additional access. Non-``BASE_AXIS`` values are returned
unchanged.
+
+ This has to reduce the value rather than merely drop keys the way
+ ``GUEST_OVERRIDABLE_VALUE_KEYS`` handles ``timeGrain``: a physical x-axis
is
+ stored as a bare string (e.g. ``"order_date"``) but sent as a dict, so no
+ amount of key-stripping makes the two compare equal; the dict must collapse
+ back to the string.
+ """
+ if not isinstance(value, dict) or value.get("columnType") != "BASE_AXIS":
+ return value
+ if value.get("isColumnReference") and
isinstance(value.get("sqlExpression"), str):
+ return value["sqlExpression"]
+ return {k: v for k, v in value.items() if k not in
BASE_AXIS_SYNTHETIC_KEYS}
+
+
+def _payload_value_identity(value: Any, *, is_metric: bool) -> str:
+ """
+ Comparison identity for a column or metric in the guest anti-tamper check.
+
+ Metrics compare by exact frozen value. Columns additionally collapse a
+ synthesized ``BASE_AXIS`` x-axis to the column it references, so a guest is
+ not rejected for a column the frontend derived from the chart's own x-axis.
+ """
+ if is_metric:
+ return freeze_value(value)
+ return freeze_value(_denormalize_base_axis_column(value))
+
+
def _native_filter_allowed_targets(
query_context: "QueryContext", form_data: dict[str, Any]
) -> Optional[tuple[set[str], set[str]]]:
@@ -795,6 +856,16 @@ def _collect_sortable_identifiers(
column_config,
is_metric=False,
)
+ # The x-axis is a visible dimension held under its own control; a guest may
+ # legitimately sort an embedded chart by it (the request sends it as the
+ # synthesized BASE_AXIS column, reduced to its reference below).
+ if params.get("x_axis"):
+ _add_visible_sort_targets(
+ allowed,
+ [params["x_axis"]],
+ column_config,
+ is_metric=False,
+ )
_add_visible_sort_targets(
allowed,
params.get("metrics"),
@@ -982,7 +1053,12 @@ def _orderby_modified(
return True
if freeze_value(entry) in stored_orderby_entries:
continue
- if not _requested_sort_target_identifiers(entry[0]) & visible_targets:
+ # Reduce a synthesized BASE_AXIS x-axis sort target back to the
reference
+ # it stands for, so sorting by the chart's own temporal dimension is
not
+ # read as a new expression. The reduced target must still be a visible
+ # sort target, so this grants nothing beyond the chart's own columns.
+ target = _denormalize_base_axis_column(entry[0])
+ if not _requested_sort_target_identifiers(target) & visible_targets:
return True
return False
@@ -1048,18 +1124,34 @@ def _columns_metrics_modified(
Whether the requested columns/metrics/group-by read beyond what the stored
chart exposes. Each requested set must be a subset of the values stored on
the chart (params and, when present, the stored query context).
+
+ Column-valued keys compare by an identity that first collapses a
+ frontend-synthesized ``BASE_AXIS`` x-axis back to the column it references
+ (see ``_denormalize_base_axis_column``), and additionally allow the chart's
+ stored ``x_axis``: the x-axis is a saved dimension the query carries in
+ ``columns`` but that is not itself listed under ``columns``/``groupby``.
"""
for key, stored_params_keys, equivalent in [
("metrics", _STORED_METRIC_PARAMS, ["metrics"]),
("columns", _STORED_COLUMN_PARAMS, ["columns", "groupby"]),
("groupby", _STORED_COLUMN_PARAMS, ["columns", "groupby"]),
]:
- requested_values = {freeze_value(value) for value in
form_data.get(key) or []}
+ is_metric = key == "metrics"
+
+ # Requested column values additionally collapse a frontend-synthesized
+ # ``BASE_AXIS`` x-axis to the column it references (see
+ # ``_payload_value_identity``); metrics compare by exact frozen value.
+ requested_values = {
+ _payload_value_identity(value, is_metric=is_metric)
+ for value in form_data.get(key) or []
+ }
# Stored params are read across every control name that can hold a
# metric or column for some chart type: charts whose query is built
# from e.g. ``metric``/``entity``/``groupby`` never store a literal
# ``metrics``/``columns`` key, yet their generated payload uses those,
- # and a guest replaying the chart's own values is not tampering.
+ # and a guest replaying the chart's own values is not tampering. This
+ # set already includes the temporal ``x_axis``; stored raw params never
+ # carry the synthesized ``BASE_AXIS`` shape, so exact matching is
right.
stored_values = _stored_param_values(
stored_chart.params_dict, stored_params_keys
)
@@ -1068,7 +1160,7 @@ def _columns_metrics_modified(
# compare queries in query_context
queries_values = {
- freeze_value(value)
+ _payload_value_identity(value, is_metric=is_metric)
for query in query_context.queries
for value in getattr(query, key, []) or []
}
@@ -1076,7 +1168,8 @@ def _columns_metrics_modified(
for query in stored_query_context.get("queries") or []:
for equiv_key in equivalent:
stored_values.update(
- {freeze_value(value) for value in query.get(equiv_key)
or []}
+ _payload_value_identity(value, is_metric=is_metric)
+ for value in query.get(equiv_key) or []
)
if not queries_values.issubset(stored_values):
@@ -1106,6 +1199,13 @@ def query_context_modified(query_context:
"QueryContext") -> bool:
return False
if form_data.get("slice_id") != stored_chart.id:
+ # Only the chart's own (server-side) id is logged; the guest-supplied
+ # slice_id is not echoed, to avoid logging request-controlled values.
+ logger.warning(
+ "Guest chart payload rejected for slice %s: requested slice_id
does "
+ "not match the chart",
+ stored_chart.id,
+ )
return True
stored_query_context = (
@@ -1114,12 +1214,26 @@ def query_context_modified(query_context:
"QueryContext") -> bool:
else None
)
+ # A rejected guest load is most often a chart whose saved query_context is
+ # NULL or stale rather than genuine tampering, and the generic 403 gives no
+ # way to tell which comparator objected. Log that here (server-side only,
no
+ # payload values) so the failure is diagnosable; whether the stored
+ # query_context was present is the key signal for the missing/stale case.
+ # Use ``is not None`` so an empty-but-present stored context reads as
present.
+ stored_context_state = "present" if stored_query_context is not None else
"missing"
+
# compare columns and metrics in form_data with stored values. Order-by is
# handled separately: a strict subset check there would reject a guest
# legitimately sorting an embedded chart by one of its existing columns.
if _columns_metrics_modified(
query_context, form_data, stored_chart, stored_query_context
):
+ logger.warning(
+ "Guest chart payload rejected for slice %s:
columns/metrics/group-by "
+ "not a subset of the stored chart (stored query_context %s)",
+ stored_chart.id,
+ stored_context_state,
+ )
return True
if _series_limit_metric_modified(
@@ -1128,11 +1242,23 @@ def query_context_modified(query_context:
"QueryContext") -> bool:
stored_chart,
stored_query_context,
):
+ logger.warning(
+ "Guest chart payload rejected for slice %s: series-limit metric
not "
+ "on the stored chart (stored query_context %s)",
+ stored_chart.id,
+ stored_context_state,
+ )
return True
# Order-by may sort only by columns/metrics already present in the stored
# chart; new expressions (e.g. ``random()``) are still rejected.
if _orderby_modified(query_context, stored_chart, stored_query_context):
+ logger.warning(
+ "Guest chart payload rejected for slice %s: order-by references a "
+ "term not on the stored chart (stored query_context %s)",
+ stored_chart.id,
+ stored_context_state,
+ )
return True
return False
diff --git a/tests/unit_tests/security/manager_test.py
b/tests/unit_tests/security/manager_test.py
index 09d85b4cd4b..13f339aeef4 100644
--- a/tests/unit_tests/security/manager_test.py
+++ b/tests/unit_tests/security/manager_test.py
@@ -18,6 +18,7 @@
# pylint: disable=invalid-name, unused-argument, redefined-outer-name
import json # noqa: TID251
+import logging
from types import SimpleNamespace
from typing import Any, Optional
from unittest.mock import MagicMock
@@ -348,6 +349,572 @@ def
test_raise_for_access_guest_user_tampered_queries_columns(
sm.raise_for_access(query_context=query_context)
+def _base_axis_physical_column(name: str, time_grain: str = "P1D") ->
dict[str, Any]:
+ """
+ The synthesized x-axis column ``normalizeTimeColumn`` emits for a
*physical*
+ x-axis (see superset-ui-core ``normalizeTimeColumn.ts``): a pure column
+ reference wrapped with the BASE_AXIS markers and the chart's time grain.
+ """
+ return {
+ "timeGrain": time_grain,
+ "columnType": "BASE_AXIS",
+ "sqlExpression": name,
+ "label": name,
+ "expressionType": "SQL",
+ "isColumnReference": True,
+ }
+
+
+def _guest_query(columns: list[Any], metrics: list[AdhocMetric]) ->
QueryObject:
+ """A QueryObject carrying the given columns/metrics, as a guest request
would."""
+ return QueryObject(columns=columns, metrics=metrics) # type:
ignore[arg-type]
+
+
+def test_raise_for_access_guest_user_ok_base_axis_physical_x_axis(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ A guest may load a chart whose saved ``query_context`` is NULL and whose
+ x-axis is a physical column. ``normalizeTimeColumn`` sends the x-axis as a
+ synthesized ``BASE_AXIS`` column that never appears verbatim in the stored
+ ``params`` (the x-axis lives under its own ``x_axis`` control), so before
the
+ carve-out this legitimate load was rejected with a 403.
+ """
+ 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 = {
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.queries = [
+ _guest_query([_base_axis_physical_column("order_date")],
stored_metrics)
+ ]
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_ok_base_axis_adhoc_x_axis(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ Same as above for an *adhoc* x-axis: ``normalizeTimeColumn`` copies the
saved
+ adhoc column and adds the BASE_AXIS markers, so the request carries the
saved
+ x-axis plus synthesized decorations and must not read as tampering.
+ """
+ sm = SupersetSecurityManager(appbuilder)
+ mocker.patch.object(sm, "is_guest_user", return_value=True)
+ mocker.patch.object(sm, "can_access", return_value=True)
+
+ adhoc_x_axis: dict[str, Any] = {
+ "label": "order month",
+ "sqlExpression": "DATE_TRUNC('month', order_date)",
+ "expressionType": "SQL",
+ }
+ query_context = mocker.MagicMock()
+ query_context.slice_.id = 42
+ query_context.slice_.query_context = None
+ query_context.slice_.params_dict = {
+ "x_axis": adhoc_x_axis,
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": adhoc_x_axis,
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ synthesized = {"timeGrain": "P1M", "columnType": "BASE_AXIS",
**adhoc_x_axis}
+ query_context.queries = [_guest_query([synthesized], stored_metrics)]
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_base_axis_forged_column_reference(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ The BASE_AXIS carve-out must not become a smuggling channel: a column
tagged
+ ``BASE_AXIS`` that references a column the chart never exposed is still
+ rejected. Here the forged reference points at ``secret_col`` while the
stored
+ x-axis is ``order_date``.
+ """
+ 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 = {
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.queries = [
+ _guest_query([_base_axis_physical_column("secret_col")],
stored_metrics)
+ ]
+ with pytest.raises(SupersetSecurityException):
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_base_axis_forged_adhoc_expression(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ A BASE_AXIS-tagged adhoc column whose SQL body differs from the saved
x-axis
+ is rejected: tagging free-form SQL as BASE_AXIS must not authorize it.
+ """
+ sm = SupersetSecurityManager(appbuilder)
+ mocker.patch.object(sm, "is_guest_user", return_value=True)
+ mocker.patch.object(sm, "can_access", return_value=True)
+
+ adhoc_x_axis: dict[str, Any] = {
+ "label": "order month",
+ "sqlExpression": "DATE_TRUNC('month', order_date)",
+ "expressionType": "SQL",
+ }
+ forged = {
+ "timeGrain": "P1M",
+ "columnType": "BASE_AXIS",
+ "label": "order month",
+ "sqlExpression": "list_secret()",
+ "expressionType": "SQL",
+ }
+ query_context = mocker.MagicMock()
+ query_context.slice_.id = 42
+ query_context.slice_.query_context = None
+ query_context.slice_.params_dict = {
+ "x_axis": adhoc_x_axis,
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": adhoc_x_axis,
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.queries = [_guest_query([forged], stored_metrics)]
+ with pytest.raises(SupersetSecurityException):
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_ok_base_axis_stale_query_context(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ A chart with a *stale* (non-NULL) saved ``query_context`` from an older
+ frontend that predates the BASE_AXIS column still loads: the x-axis
carve-out
+ is sourced from ``params`` rather than the stored ``query_context``, so a
+ stored context that lacks the synthesized column does not reject the guest.
+ """
+ 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 = json.dumps(
+ {"queries": [{"columns": [], "metrics": stored_metrics}]}
+ )
+ query_context.slice_.params_dict = {
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.queries = [
+ _guest_query([_base_axis_physical_column("order_date")],
stored_metrics)
+ ]
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_ok_base_axis_orderby(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ A guest may sort an embedded chart by its own temporal x-axis, which the
+ frontend sends as the synthesized BASE_AXIS column in the order-by term.
+ """
+ sm = SupersetSecurityManager(appbuilder)
+ mocker.patch.object(sm, "is_guest_user", return_value=True)
+ mocker.patch.object(sm, "can_access", return_value=True)
+
+ base_axis = _base_axis_physical_column("order_date")
+ query_context = mocker.MagicMock()
+ query_context.slice_.id = 42
+ query_context.slice_.query_context = None
+ query_context.slice_.params_dict = {
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ "orderby": [[base_axis, True]],
+ }
+ query_context.queries = [_guest_query([base_axis], stored_metrics)]
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_base_axis_orderby_forged(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ The order-by carve-out is bounded: sorting by a BASE_AXIS-tagged column
that
+ references a column the chart never exposed is still rejected.
+ """
+ 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 = {
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ "orderby": [[_base_axis_physical_column("secret_col"), True]],
+ }
+ query_context.queries = [
+ _guest_query([_base_axis_physical_column("order_date")],
stored_metrics)
+ ]
+ with pytest.raises(SupersetSecurityException):
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_ok_base_axis_multiple_queries(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ The carve-out applies across every query in the request: a chart that
issues
+ more than one query (each carrying the synthesized BASE_AXIS x-axis) loads.
+ """
+ sm = SupersetSecurityManager(appbuilder)
+ mocker.patch.object(sm, "is_guest_user", return_value=True)
+ mocker.patch.object(sm, "can_access", return_value=True)
+
+ base_axis = _base_axis_physical_column("order_date")
+ query_context = mocker.MagicMock()
+ query_context.slice_.id = 42
+ query_context.slice_.query_context = None
+ query_context.slice_.params_dict = {
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.queries = [
+ _guest_query([base_axis], stored_metrics),
+ _guest_query([base_axis], stored_metrics),
+ ]
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_ok_x_axis_reused_in_columns(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ The stored x-axis is an allowed column even when requested verbatim (not
+ BASE_AXIS-wrapped): the x-axis dimension is not listed under ``columns`` in
+ ``params`` but is a legitimate selectable dimension.
+ """
+ sm = SupersetSecurityManager(appbuilder)
+ mocker.patch.object(sm, "is_guest_user", return_value=True)
+ mocker.patch.object(sm, "can_access", return_value=True)
+
+ adhoc_x_axis: dict[str, Any] = {
+ "label": "order month",
+ "sqlExpression": "DATE_TRUNC('month', order_date)",
+ "expressionType": "SQL",
+ }
+ query_context = mocker.MagicMock()
+ query_context.slice_.id = 42
+ query_context.slice_.query_context = None
+ query_context.slice_.params_dict = {
+ "x_axis": adhoc_x_axis,
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": adhoc_x_axis,
+ "metrics": stored_metrics,
+ "columns": [adhoc_x_axis],
+ }
+ query_context.queries = [_guest_query([adhoc_x_axis], stored_metrics)]
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_base_axis_non_string_sql_rejected(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ A BASE_AXIS column with a non-string ``sqlExpression`` does not take the
+ physical-reference shortcut; it falls back to the dict comparison, matches
no
+ stored value, and is rejected without raising an unexpected error.
+ """
+ sm = SupersetSecurityManager(appbuilder)
+ mocker.patch.object(sm, "is_guest_user", return_value=True)
+ mocker.patch.object(sm, "can_access", return_value=True)
+
+ forged = {
+ "columnType": "BASE_AXIS",
+ "isColumnReference": True,
+ "sqlExpression": ["order_date"],
+ "label": "order_date",
+ }
+ query_context = mocker.MagicMock()
+ query_context.slice_.id = 42
+ query_context.slice_.query_context = None
+ query_context.slice_.params_dict = {
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.queries = [_guest_query([forged], stored_metrics)]
+ with pytest.raises(SupersetSecurityException):
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_logs_rejection_reason(
+ mocker: MockerFixture,
+ app_context: None,
+ caplog: pytest.LogCaptureFixture,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ A rejected guest payload is logged server-side with which comparator
objected
+ and whether the stored query_context was present, and without any payload
+ values (so column names / SQL are not leaked into logs).
+ """
+ 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": [], "metrics":
stored_metrics}
+ query_context.form_data = {
+ "slice_id": 42,
+ "columns": [
+ {
+ "label": "secret",
+ "sqlExpression": "secret_col",
+ "expressionType": "SQL",
+ }
+ ],
+ "metrics": stored_metrics,
+ }
+ query_context.queries = [_guest_query([], stored_metrics)]
+
+ with caplog.at_level(logging.WARNING, logger="superset.security.manager"):
+ with pytest.raises(SupersetSecurityException):
+ sm.raise_for_access(query_context=query_context)
+
+ assert "columns/metrics/group-by" in caplog.text
+ assert "stored query_context missing" in caplog.text
+ # The rejected column name / SQL must not be logged.
+ assert "secret_col" not in caplog.text
+
+
+def test_raise_for_access_guest_user_ok_base_axis_in_form_data(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ The carve-out applies to the ``form_data`` comparison too, not only the
+ per-query one: a BASE_AXIS x-axis carried in ``form_data["columns"]`` is
+ recognized as the stored x-axis.
+ """
+ sm = SupersetSecurityManager(appbuilder)
+ mocker.patch.object(sm, "is_guest_user", return_value=True)
+ mocker.patch.object(sm, "can_access", return_value=True)
+
+ base_axis = _base_axis_physical_column("order_date")
+ query_context = mocker.MagicMock()
+ query_context.slice_.id = 42
+ query_context.slice_.query_context = None
+ query_context.slice_.params_dict = {
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [base_axis],
+ }
+ query_context.queries = [_guest_query([base_axis], stored_metrics)]
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_ok_base_axis_groupby(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ The x-axis carve-out also applies to the ``groupby`` key, not only
+ ``columns``.
+ """
+ sm = SupersetSecurityManager(appbuilder)
+ mocker.patch.object(sm, "is_guest_user", return_value=True)
+ mocker.patch.object(sm, "can_access", return_value=True)
+
+ base_axis = _base_axis_physical_column("order_date")
+ query_context = mocker.MagicMock()
+ query_context.slice_.id = 42
+ query_context.slice_.query_context = None
+ query_context.slice_.params_dict = {
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "groupby": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "groupby": [base_axis],
+ }
+ query_context.queries = [_guest_query([], stored_metrics)]
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_ok_bare_string_x_axis_in_columns(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ A physical (bare-string) x-axis requested verbatim as a column is allowed
by
+ the x_axis carve-out, without any BASE_AXIS wrapping.
+ """
+ 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 = {
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": ["order_date"],
+ }
+ query_context.queries = [_guest_query(["order_date"], stored_metrics)]
+ sm.raise_for_access(query_context=query_context)
+
+
+def test_raise_for_access_guest_user_base_axis_in_metrics_rejected(
+ mocker: MockerFixture,
+ app_context: None,
+ stored_metrics: list[AdhocMetric],
+) -> None:
+ """
+ The carve-out is column-only: a metric tagged BASE_AXIS is not collapsed,
so
+ it must exact-match a stored metric and an unrelated one is still rejected.
+ """
+ sm = SupersetSecurityManager(appbuilder)
+ mocker.patch.object(sm, "is_guest_user", return_value=True)
+ mocker.patch.object(sm, "can_access", return_value=True)
+
+ forged_metric = {
+ "columnType": "BASE_AXIS",
+ "isColumnReference": True,
+ "sqlExpression": "SUM(revenue)",
+ "label": "SUM(revenue)",
+ "expressionType": "SQL",
+ }
+ query_context = mocker.MagicMock()
+ query_context.slice_.id = 42
+ query_context.slice_.query_context = None
+ query_context.slice_.params_dict = {
+ "x_axis": "order_date",
+ "metrics": stored_metrics,
+ "columns": [],
+ }
+ query_context.form_data = {
+ "slice_id": 42,
+ "metrics": [forged_metric],
+ }
+ query_context.queries = [_guest_query([], stored_metrics)]
+ 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,