You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/05/29 17:17:45 UTC

(superset) 01/01: fix(security): correct class_permission_name case for Security menu views

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

rusackas pushed a commit to branch fix/security-menu-case-mysql
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 2063484ef4cb6c490a8a9929f89d2dd1751fa33b
Author: Evan <ev...@preset.io>
AuthorDate: Fri May 29 10:17:16 2026 -0700

    fix(security): correct class_permission_name case for Security menu views
    
    Five views that back the Security menu navbar items declared
    `class_permission_name = "security"` (lowercase). Flask-AppBuilder uses
    this name to look up or insert the corresponding `ab_view_menu` row. On
    MySQL's default case-insensitive collation, the lookup finds whatever
    row exists first, meaning the DB can end up storing `"security"` instead
    of `"Security"`. Because Superset's Python comparisons in
    `sync_role_definitions` and `SupersetSecurityManager` are case-sensitive,
    `"security" != "Security"` → the Security menu disappears from the UI on
    MySQL/MariaDB deployments.
    
    Fixes:
    - Change `class_permission_name` to `"Security"` in users_list.py,
      roles.py, groups.py, logs.py, and user_registrations.py.
    - Add migration `b4a3f2e1d0c9` that normalises any existing lowercase
      `"security"` row: renames it to `"Security"` if it is the only row,
      or merges its `ab_permission_view` entries into the correctly-cased
      row and deletes the duplicate if both exist.
    
    Closes #40330
    
    Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
 ...-00_b4a3f2e1d0c9_fix_security_view_menu_case.py | 115 +++++++++++++++++++++
 superset/views/groups.py                           |   2 +-
 superset/views/logs.py                             |   2 +-
 superset/views/roles.py                            |   2 +-
 superset/views/user_registrations.py               |   2 +-
 superset/views/users_list.py                       |   2 +-
 6 files changed, 120 insertions(+), 5 deletions(-)

diff --git a/superset/migrations/versions/2026-05-29_00-00_b4a3f2e1d0c9_fix_security_view_menu_case.py b/superset/migrations/versions/2026-05-29_00-00_b4a3f2e1d0c9_fix_security_view_menu_case.py
new file mode 100644
index 00000000000..6d1978ca9de
--- /dev/null
+++ b/superset/migrations/versions/2026-05-29_00-00_b4a3f2e1d0c9_fix_security_view_menu_case.py
@@ -0,0 +1,115 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""fix Security view menu case
+
+Revision ID: b4a3f2e1d0c9
+Revises: a1b2c3d4e5f6
+Create Date: 2026-05-29 00:00:00.000000
+
+On MySQL with the default case-insensitive collation, five Superset views that
+declare ``class_permission_name = "security"`` (lowercase) cause FAB to look up
+or insert a view-menu entry named ``"security"``.  MySQL's case-insensitive
+``UNIQUE`` constraint means the lookup finds an existing ``"Security"`` row and
+uses it, OR a fresh install stores ``"security"`` because that view is
+registered before the FAB built-in views register ``"Security"``.
+
+Python string comparisons in ``sync_role_definitions`` and the menu-hiding
+logic are case-sensitive, so an all-lowercase ``"security"`` row breaks the
+Security menu for MySQL (and MariaDB) deployments.
+
+This migration normalises the row:
+* If only the lowercase ``"security"`` row exists: rename it to ``"Security"``.
+* If both rows exist (SQLite / PostgreSQL fresh-install with the old code):
+  reassign every ``ab_permission_view`` from the lowercase row to the
+  correctly-cased row, then delete the duplicate.
+"""
+
+# revision identifiers, used by Alembic.
+revision = "b4a3f2e1d0c9"
+down_revision = "a1b2c3d4e5f6"
+
+import logging  # noqa: E402
+
+from alembic import op  # noqa: E402
+from sqlalchemy.orm import Session  # noqa: E402
+
+from superset.migrations.shared.security_converge import (  # noqa: E402
+    PermissionView,
+    ViewMenu,
+)
+
+logger = logging.getLogger("alembic.env")
+
+
+def upgrade() -> None:
+    bind = op.get_bind()
+    session = Session(bind=bind)
+
+    # Fetch all view menus and compare in Python (SQL filter_by is
+    # case-insensitive on MySQL, so we cannot rely on it here).
+    all_vms: list[ViewMenu] = session.query(ViewMenu).all()
+    security_upper = next((vm for vm in all_vms if vm.name == "Security"), None)
+    security_lower = next((vm for vm in all_vms if vm.name == "security"), None)
+
+    if security_lower is None:
+        logger.info("No lowercase 'security' view-menu found; nothing to do.")
+        return
+
+    if security_upper is None:
+        # Simple rename — only the incorrect row exists.
+        logger.info(
+            "Renaming ab_view_menu 'security' -> 'Security' (id=%d)",
+            security_lower.id,
+        )
+        security_lower.name = "Security"
+        session.flush()
+    else:
+        # Both rows exist.  Re-home every permission_view from the lowercase
+        # entry to the correctly-cased entry, then drop the duplicate.
+        logger.info(
+            "Both 'security' (id=%d) and 'Security' (id=%d) found; merging.",
+            security_lower.id,
+            security_upper.id,
+        )
+        pvms_lower: list[PermissionView] = (
+            session.query(PermissionView)
+            .filter(PermissionView.view_menu_id == security_lower.id)
+            .all()
+        )
+        for pvm in pvms_lower:
+            conflict = (
+                session.query(PermissionView)
+                .filter(
+                    PermissionView.view_menu_id == security_upper.id,
+                    PermissionView.permission_id == pvm.permission_id,
+                )
+                .one_or_none()
+            )
+            if conflict:
+                session.delete(pvm)
+            else:
+                pvm.view_menu_id = security_upper.id
+        session.flush()
+        session.delete(security_lower)
+
+    session.commit()
+
+
+def downgrade() -> None:
+    # There is no safe way to determine the original state (whether the row was
+    # lowercase or whether there were two rows), so downgrade is a no-op.
+    pass
diff --git a/superset/views/groups.py b/superset/views/groups.py
index 61dc9dc659d..4c2b78ef1fd 100644
--- a/superset/views/groups.py
+++ b/superset/views/groups.py
@@ -25,7 +25,7 @@ from .base import BaseSupersetView
 
 class GroupsListView(BaseSupersetView):
     route_base = "/"
-    class_permission_name = "security"
+    class_permission_name = "Security"
 
     @expose("/list_groups/")
     @has_access
diff --git a/superset/views/logs.py b/superset/views/logs.py
index 39fe10d0a3e..2f8b91ac322 100644
--- a/superset/views/logs.py
+++ b/superset/views/logs.py
@@ -25,7 +25,7 @@ from .base import BaseSupersetView
 
 class ActionLogView(BaseSupersetView):
     route_base = "/"
-    class_permission_name = "security"
+    class_permission_name = "Security"
 
     @expose("/actionlog/list")
     @has_access
diff --git a/superset/views/roles.py b/superset/views/roles.py
index 29f293f51e5..38b38972033 100644
--- a/superset/views/roles.py
+++ b/superset/views/roles.py
@@ -25,7 +25,7 @@ from .base import BaseSupersetView
 
 class RolesListView(BaseSupersetView):
     route_base = "/"
-    class_permission_name = "security"
+    class_permission_name = "Security"
 
     @expose("/roles/")
     @has_access
diff --git a/superset/views/user_registrations.py b/superset/views/user_registrations.py
index 10c8fab2dcf..60ed5d42159 100644
--- a/superset/views/user_registrations.py
+++ b/superset/views/user_registrations.py
@@ -25,7 +25,7 @@ from .base import BaseSupersetView
 
 class UserRegistrationsView(BaseSupersetView):
     route_base = "/"
-    class_permission_name = "security"
+    class_permission_name = "Security"
 
     @expose("/registrations/")
     @has_access
diff --git a/superset/views/users_list.py b/superset/views/users_list.py
index 42f93fce9ff..0ed5f64b02c 100644
--- a/superset/views/users_list.py
+++ b/superset/views/users_list.py
@@ -25,7 +25,7 @@ from .base import BaseSupersetView
 
 class UsersListView(BaseSupersetView):
     route_base = "/"
-    class_permission_name = "security"
+    class_permission_name = "Security"
 
     @expose("/users/")
     @has_access