Skip to content

fix(mcp): reject unsafe dashboard layout replacements - #43476

Open
aminghadersohi wants to merge 4 commits into
apache:masterfrom
aminghadersohi:aminghadersohi/ch117987/validate-dashboard-layout-updates
Open

fix(mcp): reject unsafe dashboard layout replacements#43476
aminghadersohi wants to merge 4 commits into
apache:masterfrom
aminghadersohi:aminghadersohi/ch117987/validate-dashboard-layout-updates

Conversation

@aminghadersohi

@aminghadersohi aminghadersohi commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

SUMMARY

Reject unsafe full position_json replacements in the MCP update_dashboard tool before they overwrite a valid saved layout.

Superset renders only layout nodes reachable from ROOT_ID, but dashboard hydration indexes every CHART node in position_json. An unreachable chart therefore prevents the normal missing-chart fallback while remaining invisible, which can leave a dashboard blank even though the chart nodes and slice associations still exist.

This change validates renderer-required component shape, the v2 marker, graph references, parent/child type compatibility, nesting depth, cycles, component IDs, reachability, and root/top-level-tab shape. Traversal is iterative, imported decimal-string chart IDs are normalized through a helper shared with chart removal, and DYNAMIC nodes are rejected because the backend cannot validate the frontend registry. Reachable chart IDs must exactly match the charts associated with the dashboard.

Invalid updates return InvalidDashboardLayout before any field mutation or commit, preserving the previous layout. The validator handles Superset's two reserved exceptions: detached HEADER_ID, and the empty detached GRID_ID retained by top-level tab layouts.

Topology is derived rather than trusted. The frontend treats both parents and depth as derived metadata and recomputes them during hydration, so saved layouts can legitimately carry stale or missing parents; the validated children edges are authoritative for both.

The MCP schema now explicitly states that get_dashboard_layout is a summary rather than a round-trippable raw tree, so callers are not directed into unsafe incremental full replacements.

Tracking: SC-117987 [sc-117987]

Related: #43133 contains generate-only fallback validation as part of native AI authoring. This PR provides the stronger shared validator for that path to reuse after rebase while keeping update rejection separate from generate fallback behavior.

(Recreated after the original #43367 was closed automatically when its source fork was deleted; review history from that PR is preserved there.)

TESTING INSTRUCTIONS

  • pytest tests/unit_tests/mcp_service/dashboard/test_layout_validation.py tests/unit_tests/mcp_service/dashboard/tool/test_update_dashboard.py
  • pre-commit run --files superset/mcp_service/dashboard/layout_validation.py superset/mcp_service/dashboard/schemas.py superset/mcp_service/dashboard/tool/update_dashboard.py superset/mcp_service/dashboard/tool/remove_chart_from_dashboard.py tests/unit_tests/mcp_service/dashboard/test_layout_validation.py tests/unit_tests/mcp_service/dashboard/tool/test_update_dashboard.py
  • ruff check and ruff format --check pass for all changed Python files.
  • git diff --check
  • 26 structural validator regression cases pass; the three new rejection tests each fail without their corresponding fix.
  • All 9 dashboards under superset/examples/*/dashboard.yaml validate clean, guarding against false rejection of real saved layouts.

ADDITIONAL INFORMATION

  • Has associated issue: SC-117987
  • Required feature flags
  • Changes UI
  • Includes DB Migration
  • Introduces new feature or API
  • Removes existing feature or API

@dosubot dosubot Bot added change:backend Requires changing the backend dashboard Namespace | Anything related to the Dashboard labels Aug 24, 2026
@bito-code-review

bito-code-review Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #bb4f3f

Actionable Suggestions - 0
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/mcp_service/dashboard/tool/update_dashboard.py - 1
Review Details
  • Files reviewed - 6 · Commit Range: b1406ef..037c7ea
    • superset/mcp_service/dashboard/layout_validation.py
    • superset/mcp_service/dashboard/schemas.py
    • superset/mcp_service/dashboard/tool/remove_chart_from_dashboard.py
    • superset/mcp_service/dashboard/tool/update_dashboard.py
    • tests/unit_tests/mcp_service/dashboard/test_layout_validation.py
    • tests/unit_tests/mcp_service/dashboard/tool/test_update_dashboard.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@netlify

netlify Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 037c7ea
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a8c806374b3aa0008dcd8e0
😎 Deploy Preview https://deploy-preview-43476--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

if isinstance(node, dict)
and node.get("type") == "CHART"
and (node.get("meta") or {}).get("chartId") in (chart_id, str(chart_id))
and normalize_chart_id((node.get("meta") or {}).get("chartId")) == chart_id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: normalize_chart_id accepts non-canonical decimal strings such as 001, so this line removes the matching layout chart even though _clean_json_metadata only removes the canonical forms 1 and "1". The chart can therefore be detached while stale 001 references remain in timed-refresh, immune-slice, filter-scope, expanded-slice, or default-filter metadata. Reject leading-zero strings in normalization or make metadata cleanup use the same normalization. [logic error]

Severity Level: Minor 🧹
- ⚠️ Chart removal leaves stale expanded-slice metadata.
- ⚠️ Timed-refresh metadata can retain detached chart IDs.
- ⚠️ Filter and default-filter references may target removed charts.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/dashboard/tool/remove_chart_from_dashboard.py
**Line:** 68:68
**Comment:**
	*Logic Error: `normalize_chart_id` accepts non-canonical decimal strings such as `001`, so this line removes the matching layout chart even though `_clean_json_metadata` only removes the canonical forms `1` and `"1"`. The chart can therefore be detached while stale `001` references remain in timed-refresh, immune-slice, filter-scope, expanded-slice, or default-filter metadata. Reject leading-zero strings in normalization or make metadata cleanup use the same normalization.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7669bba. normalize_chart_id now rejects non-canonical decimal strings, so "001" no longer matches a chart during layout removal. That keeps it consistent with _clean_json_metadata, which keys off str(chart_id) — the two paths can no longer disagree about whether a reference matches, so a chart cannot be detached while stale 001 entries survive in expanded_slices, timed_refresh_immune_slices, filter_scopes, or default_filters. Added test_rejects_leading_zero_string_chart_id, which fails without the change.

@bito-code-review

Copy link
Copy Markdown
Contributor

The flagged issue is correct. The normalize_chart_id function currently accepts leading-zero strings (e.g., "001"), which can lead to inconsistencies if the metadata cleanup logic expects only canonical forms (e.g., 1).

To resolve this, you should update normalize_chart_id to reject strings with leading zeros. Here is the corrected implementation:

def normalize_chart_id(value: Any) -> int | None:
    """Normalize an integer or canonical decimal-string chart ID."""
    if isinstance(value, bool):
        return None
    if isinstance(value, int):
        return value if value > 0 else None
    if isinstance(value, str) and value.isascii() and value.isdecimal():
        if value != "0" and value.startswith("0"):
            return None
        normalized = int(value)
        return normalized if normalized > 0 else None
    return None

Would you like me to fetch all other comments on this PR to validate and implement fixes for them as well?

superset/mcp_service/dashboard/layout_validation.py

def normalize_chart_id(value: Any) -> int | None:
    """Normalize an integer or canonical decimal-string chart ID."""
    if isinstance(value, bool):
        return None
    if isinstance(value, int):
        return value if value > 0 else None
    if isinstance(value, str) and value.isascii() and value.isdecimal():
        if value != "0" and value.startswith("0"):
            return None
        normalized = int(value)
        return normalized if normalized > 0 else None
    return None

Comment on lines +84 to +86
if isinstance(value, str) and value.isascii() and value.isdecimal():
normalized = int(value)
return normalized if normalized > 0 else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: int(value) can raise ValueError for an excessively long decimal string when Python's integer string conversion limit is enabled. Because this value comes from the MCP payload and is not caught, a malformed chart ID causes the validator to escape instead of returning InvalidDashboardLayout; bound the string length or catch the conversion error and return None. [type error]

Severity Level: Major ⚠️
- ❌ Malformed layout requests escape structured MCP validation.
- ⚠️ Clients receive an unexpected tool exception.
- ⚠️ Invalid updates may not produce consistent error types.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/dashboard/layout_validation.py
**Line:** 84:86
**Comment:**
	*Type Error: `int(value)` can raise `ValueError` for an excessively long decimal string when Python's integer string conversion limit is enabled. Because this value comes from the MCP payload and is not caught, a malformed chart ID causes the validator to escape instead of returning `InvalidDashboardLayout`; bound the string length or catch the conversion error and return `None`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7669bba. The decimal-string length is now bounded before int(), so an oversized value cannot hit CPython's integer string conversion limit (confirmed: 4300 digits by default) and raise ValueError out of the validator. Malformed IDs return None and surface as a structured InvalidDashboardLayout like every other invalid input. Added test_rejects_oversized_string_chart_id, which fails without the change.

Comment on lines +168 to +169
continue
stack: list[tuple[str, bool]] = [(start_id, False)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The validator does not enforce the frontend's maximum nesting depths from parentMaxDepthLookup. An MCP replacement can therefore persist ROW/COLUMN nesting that the dashboard editor explicitly rejects, producing layouts outside the supported parent/depth contract. Track the effective depth during iterative traversal and reject edges exceeding the corresponding frontend limit. [incomplete implementation]

Severity Level: Major ⚠️
- ⚠️ MCP can persist layouts outside frontend depth limits.
- ⚠️ Dashboard editor operations reject persisted deep nesting.
- ⚠️ Users may need manual layout repair before editing.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/dashboard/layout_validation.py
**Line:** 168:169
**Comment:**
	*Incomplete Implementation: The validator does not enforce the frontend's maximum nesting depths from `parentMaxDepthLookup`. An MCP replacement can therefore persist ROW/COLUMN nesting that the dashboard editor explicitly rejects, producing layouts outside the supported parent/depth contract. Track the effective depth during iterative traversal and reject edges exceeding the corresponding frontend limit.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7669bba. Fair point — the validator was mirroring the type half of isValidChild.ts but not the depth half, which is inconsistent since _ALLOWED_CHILD_TYPES was itself derived from parentMaxDepthLookup's shape.

The allowed-child-type table is now derived directly from the depth table (_PARENT_MAX_DEPTH), so the two cannot drift apart. Depth is tracked on the existing iterative reachability walk rather than recursively, and it is derived from the validated children edges rather than trusted from the payload — same reasoning as parents in 037c7ea. TABS and TAB pass their depth through to children, matching the worked examples in the header comment of isValidChild.ts.

I checked this against the regression class from the earlier parents discussion before committing: all 9 shipped example dashboards still validate clean under the depth check, so this does not reintroduce false rejection of real saved layouts.

Tests added: test_rejects_nesting_beyond_frontend_depth_limit (fails without the change), plus test_accepts_maximum_supported_nesting_depth and test_accepts_tabs_without_consuming_depth to pin the upper bound and the tab pass-through so a future tightening can't silently over-reject.

Comment on lines +241 to +244
if request.position_json is not None:
chart_ids = [chart.id for chart in dashboard.slices]
if error := validate_dashboard_layout(request.position_json, chart_ids):
return DashboardError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The chart set is snapshotted from dashboard.slices before the later mutation and commit, with no optimistic-lock or revalidation. If another operation adds a chart after this snapshot but before this commit, this replacement can be accepted without that chart and leave the newly associated chart unreachable; if a chart is removed concurrently, a valid replacement can instead be rejected. Validate and persist against the same locked/versioned dashboard state, or recheck the associations immediately before commit. [race condition]

Severity Level: Major ⚠️
- ⚠️ Concurrent chart additions can become unreachable.
- ⚠️ Concurrent removals can leave stale chart nodes.
- ❌ Dashboard layout and chart associations become inconsistent.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/dashboard/tool/update_dashboard.py
**Line:** 241:244
**Comment:**
	*Race Condition: The chart set is snapshotted from `dashboard.slices` before the later mutation and commit, with no optimistic-lock or revalidation. If another operation adds a chart after this snapshot but before this commit, this replacement can be accepted without that chart and leave the newly associated chart unreachable; if a chart is removed concurrently, a valid replacement can instead be rejected. Validate and persist against the same locked/versioned dashboard state, or recheck the associations immediately before commit.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one, but I traced the consequence rather than waving it off.

The window is real — dashboard.slices is read in _validate_update_request and the commit happens later in the same request — but both directions of the race degrade into states the frontend already handles, and neither is the blank-dashboard class this PR exists to prevent:

  • Chart added concurrently (missing from the committed layout): hydration has no CHART node for it at all, so chartIdToLayoutId[key] is unset and hydrate.ts auto-places it into a new row (superset-frontend/src/dashboard/actions/hydrate.ts, the if (!chartIdToLayoutId[key] && layout[parentId]) branch). This is the self-healing "associated but unplaced" state.
  • Chart removed concurrently (stale node in the committed layout): the node is reachable, so it renders through the normal missing-chart placeholder path.

The failure this PR targets is specifically present but unreachable — a CHART node that hydration indexes (suppressing the auto-place fallback) while the renderer never reaches it. The validator guarantees every node in the committed tree is reachable from ROOT_ID, and a concurrently-added chart has no node at all rather than an unreachable one, so this race cannot produce that state.

Closing the window properly would need SELECT ... FOR UPDATE or a version column on the dashboard, which is a broader change than this PR and would be inconsistent with the REST UpdateDashboardCommand path, which has the same read-then-commit shape today. Re-reading dashboard.slices just before commit would narrow the window without closing it while adding a query, so it buys correctness theater rather than correctness. Happy to file it separately if you think dashboard-level optimistic locking is worth doing across both paths.

…7987]

Addresses review feedback on the dashboard layout validator.

Reject non-canonical decimal-string chart IDs. `normalize_chart_id`
accepted leading-zero forms such as "001", while the json_metadata
cleanup in remove_chart_from_dashboard keys off `str(chart_id)`. A chart
could therefore be detached from the layout while stale "001" references
survived in expanded_slices, timed_refresh_immune_slices, filter_scopes,
and default_filters. Normalization now accepts only canonical forms, so
both paths agree on whether a reference matches.

Bound the decimal-string length before `int()`. An oversized value from
the MCP payload hit CPython's integer string conversion limit and raised
ValueError out of the validator, escaping the structured
InvalidDashboardLayout contract instead of returning it.

Enforce the frontend's `parentMaxDepthLookup` nesting limits. The
validator already mirrored the type half of isValidChild.ts but not the
depth half, so MCP could persist ROW/COLUMN nesting the dashboard editor
rejects. The allowed-child-type table is now derived from the depth
table so the two cannot drift, and depth is tracked on the existing
iterative reachability walk. Depth is derived rather than trusted, and
TABS/TAB pass their depth through to children as they do in the
frontend.

All 9 shipped example dashboards still validate clean, so this does not
reintroduce the class of false rejection fixed in 037c7ea.
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.04110% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.88%. Comparing base (9781254) to head (7669bba).
⚠️ Report is 31 commits behind head on master.

Files with missing lines Patch % Lines
...uperset/mcp_service/dashboard/layout_validation.py 88.57% 8 Missing and 8 partials ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #43476       +/-   ##
===========================================
+ Coverage   66.81%   78.88%   +12.07%     
===========================================
  Files        2876     2878        +2     
  Lines      164454   164976      +522     
  Branches    37960    38072      +112     
===========================================
+ Hits       109873   130135    +20262     
+ Misses      52388    32383    -20005     
- Partials     2193     2458      +265     
Flag Coverage Δ
hive 37.99% <17.80%> (-0.09%) ⬇️
mysql 57.69% <17.80%> (-0.07%) ⬇️
postgres 57.72% <17.80%> (-0.08%) ⬇️
presto 39.91% <17.80%> (-0.11%) ⬇️
python 83.62% <89.04%> (+24.39%) ⬆️
sqlite 57.42% <17.80%> (-0.07%) ⬇️
unit 73.69% <89.04%> (-26.31%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@bito-code-review

bito-code-review Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #c11dea

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: 037c7ea..7669bba
    • superset/mcp_service/dashboard/layout_validation.py
    • tests/unit_tests/mcp_service/dashboard/test_layout_validation.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend dashboard Namespace | Anything related to the Dashboard size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant