Compare commits

...

4 Commits

Author SHA1 Message Date
Evan
42648f7293 fix(export): harden chart id stabilization for native/cross filters
Make _stabilize_chart_ids tolerant of malformed chart UUIDs (skip the
node instead of aborting the whole export) and remap the remaining
integer-keyed metadata structures that store chart IDs:
native_filter_configuration scope.excluded/chartsInScope,
global_chart_configuration, and chart_configuration. Without these
remaps, those IDs stayed env-local and got dropped on import because the
import-side id_map keys off the now-stabilized position IDs, silently
changing filter/cross-filter scoping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:50:37 -07:00
Evan
d861fe344f fix(export): strip env-local chartId from dashboard position_json export (#32972)
Dashboard exports embedded meta.chartId — the source environment's
auto-increment primary key — in every CHART position node. That integer
differs across environments, so re-exporting an imported dashboard
produced a different bundle for the same logical content.

Derive meta.chartId from the chart's stable UUID on export and remap the
legacy integer-keyed metadata references (filter_scopes, default_filters,
expanded_slices, timed_refresh_immune_slices) to match, keeping the bundle
internally consistent. The importer's update_id_refs continues to rewire
these derived ids to destination-local ids, so the export/import
round-trip is preserved while the export becomes environment-independent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:12:58 -07:00
Evan
506db607b2 style(test): remove time-sensitive language and redundant import in #32972 regression test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:33:26 -07:00
Evan
a5ef943592 test(export): dashboard export must not leak env-local chartIds
Regression for #32972: exported position_json contains env-local
integer chartIds that differ across environments, making exports
non-deterministic. UUIDs should be the stable identifiers.

Closes #32972

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:54:30 -07:00
2 changed files with 469 additions and 0 deletions

View File

@@ -19,6 +19,7 @@
import logging
import random
import string
import uuid as uuid_module
from typing import Any, Optional, Callable
from collections.abc import Iterator
@@ -105,6 +106,166 @@ def append_charts(position: dict[str, Any], charts: set[Slice]) -> dict[str, Any
return position
# Bound the derived id to a signed 31-bit positive integer so it stays well
# within the range a database auto-increment primary key would occupy and
# never collides with the sign bit. The concrete value is irrelevant — only
# its stability across environments matters.
_STABLE_CHART_ID_MODULO = 2_147_483_647
def stable_chart_id(chart_uuid: str) -> int:
"""Derive a deterministic, environment-independent integer from a chart UUID.
The dashboard export format historically embedded ``meta.chartId`` — the
source environment's auto-increment primary key — inside every ``CHART-*``
position node (issue #32972). That integer differs between environments, so
re-exporting an imported dashboard produced a different bundle for the same
logical content. Deriving the id from the (stable) UUID instead makes the
export reproducible while still giving the importer an integer it can use to
rewire the legacy, integer-keyed metadata references back to local IDs.
"""
return (uuid_module.UUID(chart_uuid).int % _STABLE_CHART_ID_MODULO) + 1
def _stabilize_chart_ids(payload: dict[str, Any]) -> None:
"""Replace env-local integer chart IDs in ``payload`` with UUID-derived ones.
Rewrites ``meta.chartId`` in every ``CHART`` position node to a value derived
from ``meta.uuid`` and remaps the legacy, integer-keyed metadata references
(filter scopes, default filters, expanded slices, native filter scopes, and
cross-filter/chart configuration) accordingly so the bundle stays internally
consistent and the import-side id remap resolves against the same IDs.
Mappings are applied defensively — a reference whose source id is unknown is
dropped rather than raising, and a node with a malformed ``meta.uuid`` is
skipped — so a partially corrupt position never aborts the export. See
``stable_chart_id`` and issue #32972 for the motivation.
"""
position = payload.get("position")
if not isinstance(position, dict):
return
# Map each chart's env-local integer id -> stable UUID-derived id.
id_map: dict[int, int] = {}
for node in position.values():
if (
isinstance(node, dict)
and node.get("type") == "CHART"
and isinstance(node.get("meta"), dict)
):
meta = node["meta"]
chart_uuid = meta.get("uuid")
old_id = meta.get("chartId")
if chart_uuid is None:
continue
try:
new_id = stable_chart_id(str(chart_uuid))
except ValueError:
# A malformed ``meta.uuid`` (corrupt position_json) must not
# abort the whole export — skip stabilizing this single node
# and leave its existing chartId untouched.
logger.warning(
"Skipping chart id stabilization for invalid uuid %r",
chart_uuid,
)
continue
if isinstance(old_id, int):
id_map[old_id] = new_id
meta["chartId"] = new_id
if not id_map:
return
metadata = payload.get("metadata")
if not isinstance(metadata, dict):
return
def remap_id(old_id: Any) -> Optional[int]:
try:
return id_map.get(int(old_id))
except (TypeError, ValueError):
return None
def remap_ids(old_ids: Any) -> list[int]:
return [
new_id for old_id in old_ids if (new_id := remap_id(old_id)) is not None
]
if isinstance(metadata.get("timed_refresh_immune_slices"), list):
metadata["timed_refresh_immune_slices"] = remap_ids(
metadata["timed_refresh_immune_slices"]
)
if isinstance(metadata.get("filter_scopes"), dict):
metadata["filter_scopes"] = {
str(new_key): columns
for old_key, columns in metadata["filter_scopes"].items()
if (new_key := remap_id(old_key)) is not None
}
for columns in metadata["filter_scopes"].values():
if not isinstance(columns, dict):
continue
for attributes in columns.values():
if isinstance(attributes, dict) and isinstance(
attributes.get("immune"), list
):
attributes["immune"] = remap_ids(attributes["immune"])
if isinstance(metadata.get("expanded_slices"), dict):
metadata["expanded_slices"] = {
str(new_key): value
for old_key, value in metadata["expanded_slices"].items()
if (new_key := remap_id(old_key)) is not None
}
if metadata.get("default_filters"):
try:
default_filters = json.loads(metadata["default_filters"])
except (TypeError, json.JSONDecodeError):
default_filters = None
if isinstance(default_filters, dict):
metadata["default_filters"] = json.dumps(
{
str(new_key): value
for old_key, value in default_filters.items()
if (new_key := remap_id(old_key)) is not None
}
)
def remap_scope(container: Any) -> None:
"""Remap ``scope.excluded`` and ``chartsInScope`` of a filter container.
Shared by native filters and (global) cross-filter configuration, which
both denormalize the charts a filter applies to into these two lists.
"""
if not isinstance(container, dict):
return
scope = container.get("scope")
if isinstance(scope, dict) and isinstance(scope.get("excluded"), list):
scope["excluded"] = remap_ids(scope["excluded"])
if isinstance(container.get("chartsInScope"), list):
container["chartsInScope"] = remap_ids(container["chartsInScope"])
if isinstance(metadata.get("native_filter_configuration"), list):
for native_filter in metadata["native_filter_configuration"]:
remap_scope(native_filter)
if isinstance(metadata.get("global_chart_configuration"), dict):
remap_scope(metadata["global_chart_configuration"])
if isinstance(metadata.get("chart_configuration"), dict):
new_chart_configuration: dict[str, Any] = {}
for old_key, chart_config in metadata["chart_configuration"].items():
new_key = remap_id(old_key)
if new_key is None:
continue
if isinstance(chart_config, dict):
if isinstance(chart_config.get("id"), int):
chart_config["id"] = new_key
remap_scope(chart_config.get("crossFilters"))
new_chart_configuration[str(new_key)] = chart_config
metadata["chart_configuration"] = new_chart_configuration
class ExportDashboardsCommand(ExportModelsCommand):
dao = DashboardDAO
not_found = DashboardNotFoundError
@@ -182,6 +343,11 @@ class ExportDashboardsCommand(ExportModelsCommand):
if orphan_charts:
payload["position"] = append_charts(payload["position"], orphan_charts)
# Strip env-local integer chart IDs in favor of UUID-derived ones so the
# export is reproducible across environments (issue #32972). Must run
# after orphan charts are appended so their nodes are stabilized too.
_stabilize_chart_ids(payload)
# Add theme UUID for proper cross-system imports
payload["theme_uuid"] = str(model.theme.uuid) if model.theme else None

View File

@@ -256,6 +256,309 @@ def test_file_content_omits_roles_field_when_dashboard_has_no_roles():
assert "roles" not in result
def test_position_json_chart_id_leaks_env_local_integers():
"""
Regression for #32972: dashboard export must produce stable output that does
not vary with env-local integer chartIds.
The export format includes a ``meta.chartId`` field inside each ``CHART-*``
position entry. That integer is the database auto-increment primary key from
the source environment. When the bundle is imported into a different
environment the importer (``update_id_refs``) rewrites those IDs to the
destination-env primary keys. A second export from the destination then
serializes the new env-local integers — the same logical chart produces
different ``chartId`` values in each environment.
This test asserts that two exports of the same logical dashboard are
identical even when the underlying chart has a different integer primary key
in each environment. ``meta.uuid`` is the stable identifier that should be
used instead of ``meta.chartId``.
Fix target: ``superset/commands/dashboard/export.py`` (``append_charts``
and ``_file_content``) — strip or UUID-replace ``chartId`` so the export
format is environment-independent.
"""
from superset.commands.dashboard.export import ExportDashboardsCommand
chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7"
# ------------------------------------------------------------------ #
# Source environment: chart has auto-increment id = 392 #
# ------------------------------------------------------------------ #
source_position = {
"DASHBOARD_VERSION_KEY": "v2",
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
"GRID_ID": {
"children": ["CHART-srcAAA"],
"id": "GRID_ID",
"parents": ["ROOT_ID"],
"type": "GRID",
},
"HEADER_ID": {
"id": "HEADER_ID",
"meta": {"text": "My Dashboard"},
"type": "HEADER",
},
"CHART-srcAAA": {
"children": [],
"id": "CHART-srcAAA",
"meta": {
"chartId": 392, # source-env integer primary key
"height": 20,
"sliceName": "My Wonderful Chart",
"uuid": chart_uuid,
"width": 4,
},
"parents": ["ROOT_ID", "GRID_ID"],
"type": "CHART",
},
}
src_dashboard = MagicMock()
src_dashboard.dashboard_title = "My Dashboard"
src_dashboard.theme = None
src_dashboard.slices = []
src_dashboard.tags = []
src_dashboard.roles = []
src_dashboard.export_to_dict.return_value = {
"position_json": json.dumps(source_position),
"json_metadata": json.dumps({"native_filter_configuration": []}),
}
with patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
):
first_export_content = ExportDashboardsCommand._file_content(src_dashboard)
first_export = yaml.safe_load(first_export_content)
first_chart_positions = [
node
for node in first_export["position"].values()
if isinstance(node, dict) and node.get("type") == "CHART"
]
assert first_chart_positions, "Export must contain at least one CHART position node"
first_chart_meta = first_chart_positions[0]["meta"]
# uuid must be present — it is the stable cross-environment identifier
assert first_chart_meta.get("uuid") == chart_uuid, (
"meta.uuid must be present in exported position_json; "
"it is the stable identifier that survives re-import."
)
# ------------------------------------------------------------------ #
# Destination environment: same chart receives a different id = 1001 #
# (This is what happens after import — update_id_refs rewrites the #
# chartId field inside position_json to the destination-env integer.) #
# ------------------------------------------------------------------ #
dest_position = {
k: (
{
**v,
"meta": {
**v["meta"],
"chartId": 1001, # destination-env integer primary key
},
}
if isinstance(v, dict)
and v.get("type") == "CHART"
and v.get("meta", {}).get("uuid") == chart_uuid
else v
)
for k, v in source_position.items()
}
dest_dashboard = MagicMock()
dest_dashboard.dashboard_title = "My Dashboard"
dest_dashboard.theme = None
dest_dashboard.slices = []
dest_dashboard.tags = []
dest_dashboard.roles = []
dest_dashboard.export_to_dict.return_value = {
"position_json": json.dumps(dest_position),
"json_metadata": json.dumps({"native_filter_configuration": []}),
}
with patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
):
second_export_content = ExportDashboardsCommand._file_content(dest_dashboard)
second_export = yaml.safe_load(second_export_content)
second_chart_positions = [
node
for node in second_export["position"].values()
if isinstance(node, dict) and node.get("type") == "CHART"
]
assert second_chart_positions, "Second export must contain at least one CHART node"
second_chart_meta = second_chart_positions[0]["meta"]
# uuid must survive the re-import round-trip unchanged
assert second_chart_meta.get("uuid") == chart_uuid, (
"meta.uuid must be stable across export → import → re-export; "
"UUIDs are the cross-environment identity, not integer IDs."
)
# ------------------------------------------------------------------ #
# THE REGRESSION: chartId must be stable (or absent) across envs. #
# #
# With the current (broken) implementation both assertions below #
# fail: #
# - first export emits chartId 392 (source-env integer) #
# - second export emits chartId 1001 (destination-env integer) #
# The fix should either omit chartId entirely or derive it from the #
# UUID so that both exports agree. #
# ------------------------------------------------------------------ #
first_chart_id = first_chart_meta.get("chartId")
second_chart_id = second_chart_meta.get("chartId")
assert first_chart_id == second_chart_id, (
f"meta.chartId must be stable across environments, but the source-env "
f"export produced chartId={first_chart_id!r} while the destination-env "
f"export (after re-import with remapped IDs) produced "
f"chartId={second_chart_id!r}. "
"Env-local integer IDs are leaking into the export format (issue #32972). "
"The fix: strip chartId from exported position_json or replace it with a "
"value derived from meta.uuid so the export is environment-independent."
)
def _export_with_chart(
chart_uuid: str,
chart_id: int,
json_metadata: dict[str, Any],
) -> dict[str, Any]:
"""Export a single-chart dashboard and return the parsed YAML payload."""
from superset.commands.dashboard.export import ExportDashboardsCommand
position = {
"DASHBOARD_VERSION_KEY": "v2",
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
"GRID_ID": {
"children": ["CHART-aaa"],
"id": "GRID_ID",
"parents": ["ROOT_ID"],
"type": "GRID",
},
"CHART-aaa": {
"children": [],
"id": "CHART-aaa",
"meta": {
"chartId": chart_id,
"height": 20,
"sliceName": "Chart",
"uuid": chart_uuid,
"width": 4,
},
"parents": ["ROOT_ID", "GRID_ID"],
"type": "CHART",
},
}
dashboard = MagicMock()
dashboard.dashboard_title = "Test Dashboard"
dashboard.theme = None
dashboard.slices = []
dashboard.tags = []
dashboard.roles = []
dashboard.export_to_dict.return_value = {
"position_json": json.dumps(position),
"json_metadata": json.dumps(json_metadata),
}
with patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
):
content = ExportDashboardsCommand._file_content(dashboard)
return yaml.safe_load(content)
def test_stabilize_chart_ids_skips_invalid_uuid():
"""A malformed meta.uuid must not abort the whole dashboard export."""
result = _export_with_chart(
"not-a-valid-uuid",
392,
{"native_filter_configuration": []},
)
chart_nodes = [
node
for node in result["position"].values()
if isinstance(node, dict) and node.get("type") == "CHART"
]
# Export still succeeds; the unstabilizable node keeps its original chartId.
assert chart_nodes
assert chart_nodes[0]["meta"]["chartId"] == 392
def test_stabilize_chart_ids_remaps_native_filter_scope():
"""Native filter scope.excluded / chartsInScope must track the stabilized id."""
from superset.commands.dashboard.export import stable_chart_id
chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7"
new_id = stable_chart_id(chart_uuid)
result = _export_with_chart(
chart_uuid,
392,
{
"native_filter_configuration": [
{
"id": "NATIVE_FILTER-1",
"scope": {"rootPath": ["ROOT_ID"], "excluded": [392]},
"chartsInScope": [392],
}
]
},
)
native_filter = result["metadata"]["native_filter_configuration"][0]
assert native_filter["scope"]["excluded"] == [new_id]
assert native_filter["chartsInScope"] == [new_id]
def test_stabilize_chart_ids_remaps_cross_filter_configuration():
"""global_chart_configuration and chart_configuration must be remapped."""
from superset.commands.dashboard.export import stable_chart_id
chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7"
new_id = stable_chart_id(chart_uuid)
result = _export_with_chart(
chart_uuid,
392,
{
"native_filter_configuration": [],
"global_chart_configuration": {
"scope": {"rootPath": ["ROOT_ID"], "excluded": [392]},
"chartsInScope": [392],
},
"chart_configuration": {
"392": {
"id": 392,
"crossFilters": {
"scope": {"rootPath": ["ROOT_ID"], "excluded": [392]},
"chartsInScope": [392],
},
}
},
},
)
metadata = result["metadata"]
assert metadata["global_chart_configuration"]["scope"]["excluded"] == [new_id]
assert metadata["global_chart_configuration"]["chartsInScope"] == [new_id]
# chart_configuration is re-keyed and its inner id / scopes are remapped.
assert str(new_id) in metadata["chart_configuration"]
chart_config = metadata["chart_configuration"][str(new_id)]
assert chart_config["id"] == new_id
assert chart_config["crossFilters"]["scope"]["excluded"] == [new_id]
assert chart_config["crossFilters"]["chartsInScope"] == [new_id]
def test_file_content_missing_dataset_preserves_dataset_id():
"""
When DatasetDAO.find_by_id returns None for a display control target,