Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Code
90e86ae3a4 fix(dashboard-export): include and re-attach roles in import/export
Dashboards restricted via `DASHBOARD_RBAC` had their role assignments
silently dropped from export bundles, so a round-trip recreated the
dashboard with no access restriction — a 'least privilege' dashboard
became 'all roles can access' on import. `Dashboard.export_fields`
doesn't include `roles` (it's a many-to-many relationship, not a
column), so `export_to_dict` skipped it entirely.

This change:

- **Export** (`export.py:_file_content`): emit `roles` as a list of
  role *names* in the YAML when the dashboard has any. Names rather
  than IDs because IDs are environment-local — names are the only
  thing that can cross environments. Omits the key entirely when
  there are no role restrictions (older import code treats "missing"
  as "no restriction"; an empty list could confuse importers that
  distinguish the two states).

- **Import** (`importers/v1/utils.py:import_dashboard`): pop `roles`
  before handing config to `import_from_dict` (the standard SQLAlchemy
  path doesn't resolve role names into role objects), then resolve each
  name via `security_manager.find_role` and assign `dashboard.roles`
  after creation. Roles that don't exist in the destination are skipped
  with a warning rather than failing the whole import.

Companion regression tests (added in this PR's earlier commit) cover
both the populated-roles and no-roles cases on the export side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 01:15:24 -07:00
Claude Code
94083497a6 test(dashboard-export): tighten no-roles assertion per bot review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:39:45 -07:00
Claude Code
ce6e9f4a02 test(dashboard-export): assert roles included in export bundle
Closes #21000

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:04:02 -07:00
3 changed files with 104 additions and 0 deletions

View File

@@ -185,6 +185,16 @@ class ExportDashboardsCommand(ExportModelsCommand):
# Add theme UUID for proper cross-system imports
payload["theme_uuid"] = str(model.theme.uuid) if model.theme else None
# Include role assignments (DASHBOARD_RBAC). Role IDs are
# environment-local, so emit names — the import side resolves them
# back to roles in the destination environment. The key is omitted
# entirely when there are no role restrictions; older import code
# treats "missing" as "no restriction" and an empty list could
# confuse importers that distinguish the two states.
role_names = sorted(role.name for role in (model.roles or []))
if role_names:
payload["roles"] = role_names
payload["version"] = EXPORT_VERSION
# Check if the TAGGING_SYSTEM feature is enabled

View File

@@ -256,6 +256,11 @@ def import_dashboard( # noqa: C901
# Note: theme_id handling moved to higher level import logic
# Pop roles before handing config to import_from_dict — it's a
# relationship, not a column, and the standard SQLAlchemy import path
# doesn't resolve role *names* into role objects. We re-attach below.
role_names = config.pop("roles", None)
for key, new_name in JSON_KEYS.items():
if config.get(key) is not None:
value = config.pop(key)
@@ -271,4 +276,25 @@ def import_dashboard( # noqa: C901
if (user := get_user()) and user not in dashboard.owners:
dashboard.owners.append(user)
# Re-attach DASHBOARD_RBAC role assignments by name. Role IDs are
# environment-local; names are how exports cross environments. Roles
# that don't exist in the destination are skipped with a warning
# rather than failing the import — admins may need to create them
# before the access restriction takes effect.
if isinstance(role_names, list) and role_names:
resolved_roles = []
for name in role_names:
role = security_manager.find_role(name)
if role is not None:
resolved_roles.append(role)
else:
logger.warning(
"Dashboard '%s': role %r referenced in export does not "
"exist in this environment; access restriction will not "
"be applied for that role",
dashboard.dashboard_title,
name,
)
dashboard.roles = resolved_roles
return dashboard

View File

@@ -188,6 +188,74 @@ def test_file_content_null_chart_customization_config_does_not_raise():
assert result["metadata"]["chart_customization_config"] is None
def test_file_content_includes_roles_for_dashboard_with_role_restrictions():
"""
Regression guard for #21000: dashboards restricted via DASHBOARD_RBAC must
have their role assignments included in the exported YAML. Without this,
importing the bundle into another environment recreates the dashboard with
no role restriction — silently turning a restricted dashboard into a
publicly accessible one.
The export bundle is the canonical source of truth for migrating
dashboards across environments; dropping roles silently is a security
regression (a "least privilege" dashboard becomes "all privileges" on
import). The user, not the export pipeline, should decide whether to
strip roles before sharing a bundle.
We assert against role *names* rather than IDs because role IDs are
environment-local; the import side resolves names back to the destination
environment's roles.
"""
from superset.commands.dashboard.export import ExportDashboardsCommand
role_alpha = MagicMock()
role_alpha.name = "Finance"
role_beta = MagicMock()
role_beta.name = "Executives"
mock_dashboard = _make_mock_dashboard({"native_filter_configuration": []})
mock_dashboard.roles = [role_alpha, role_beta]
with patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
):
content = ExportDashboardsCommand._file_content(mock_dashboard)
result = yaml.safe_load(content)
assert "roles" in result, (
"Dashboard export must include role names; without them, importing "
"into a fresh environment loses the role-based access restriction "
"and the dashboard becomes accessible to all roles by default."
)
assert sorted(result["roles"]) == ["Executives", "Finance"]
def test_file_content_omits_roles_field_when_dashboard_has_no_roles():
"""
A dashboard with no role restrictions must not emit an empty ``roles: []``
key. Older bundles in the wild were written without the key at all, and
the import side treats "missing" as "no restriction"; emitting an empty
list could trip importers that distinguish the two states.
"""
from superset.commands.dashboard.export import ExportDashboardsCommand
mock_dashboard = _make_mock_dashboard({"native_filter_configuration": []})
mock_dashboard.roles = []
with patch(
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
return_value=False,
):
content = ExportDashboardsCommand._file_content(mock_dashboard)
result = yaml.safe_load(content)
# Strict: the key must be absent (not an empty list). The import side
# treats "missing" as "no restriction"; emitting an empty list could
# trip importers that distinguish the two states.
assert "roles" not in result
def test_file_content_missing_dataset_preserves_dataset_id():
"""
When DatasetDAO.find_by_id returns None for a display control target,