This is an automated email from the ASF dual-hosted git repository.

rusackas 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 d744f5715c4 fix(dashboard-export): include and re-attach roles in 
import/export (#21000) (#40136)
d744f5715c4 is described below

commit d744f5715c489c76de599e3390942eea4531a4cf
Author: Evan Rusackas <[email protected]>
AuthorDate: Wed May 27 10:49:07 2026 -0700

    fix(dashboard-export): include and re-attach roles in import/export 
(#21000) (#40136)
    
    Co-authored-by: Claude Code <[email protected]>
    Co-authored-by: SBIN2010 <[email protected]>
---
 superset/commands/dashboard/export.py              | 10 ++++
 superset/commands/dashboard/importers/v1/utils.py  | 26 +++++++++
 superset/dashboards/schemas.py                     |  1 +
 tests/unit_tests/commands/dashboard/export_test.py | 68 ++++++++++++++++++++++
 4 files changed, 105 insertions(+)

diff --git a/superset/commands/dashboard/export.py 
b/superset/commands/dashboard/export.py
index e31c4e91c4b..68a32f7b944 100644
--- a/superset/commands/dashboard/export.py
+++ b/superset/commands/dashboard/export.py
@@ -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
diff --git a/superset/commands/dashboard/importers/v1/utils.py 
b/superset/commands/dashboard/importers/v1/utils.py
index 01739047ede..db4717a9039 100644
--- a/superset/commands/dashboard/importers/v1/utils.py
+++ b/superset/commands/dashboard/importers/v1/utils.py
@@ -281,6 +281,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)
@@ -296,4 +301,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
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index 06f3efc7987..a71b90a1afd 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -519,6 +519,7 @@ class ImportV1DashboardSchema(Schema):
     tags = fields.List(fields.String(), allow_none=True)
     theme_uuid = fields.UUID(allow_none=True)
     theme_id = fields.Integer(allow_none=True)
+    roles = fields.List(fields.String(), allow_none=True)
 
 
 class EmbeddedDashboardConfigSchema(Schema):
diff --git a/tests/unit_tests/commands/dashboard/export_test.py 
b/tests/unit_tests/commands/dashboard/export_test.py
index 16fbb393b5c..c739ed70197 100644
--- a/tests/unit_tests/commands/dashboard/export_test.py
+++ b/tests/unit_tests/commands/dashboard/export_test.py
@@ -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,

Reply via email to