From 0c48c57f32b492fb833bbab96e3a1d419f2340ad Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 3 Feb 2025 13:58:34 +0000 Subject: [PATCH 01/17] feat: security, user group support --- superset/security/manager.py | 33 ++++++++++++++++++++++++++++----- superset/views/utils.py | 4 ++-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/superset/security/manager.py b/superset/security/manager.py index 33cbc814d496..c00749ca8cf7 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -238,6 +238,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods "List Roles", "ResetPasswordView", "RoleModelView", + "UserGroupModelView", "Row Level Security", "Row Level Security Filters", "RowLevelSecurityFiltersModelView", @@ -731,14 +732,36 @@ def user_view_menu_names(self, permission_name: str) -> set[str]: ) if not g.user.is_anonymous: - # filter by user id - view_menu_names = ( - base_query.join(assoc_user_role) - .join(self.user_model) + from sqlalchemy.orm import aliased + from flask_appbuilder.security.sqla.models import assoc_user_group, assoc_group_role + + user_group = aliased(assoc_user_group) + group_role = aliased(assoc_group_role) + + # Query to fetch permissions via groups' roles + view_menu_names_group_roles = ( + self.get_session.query(self.viewmenu_model.name) + .join(self.permissionview_model, self.viewmenu_model.id == self.permissionview_model.view_menu_id) + .join(self.permission_model, self.permission_model.id == self.permissionview_model.permission_id) + .join(assoc_permissionview_role, assoc_permissionview_role.c.permission_view_id == self.permissionview_model.id) + .join(self.role_model, self.role_model.id == assoc_permissionview_role.c.role_id) + .join(group_role, group_role.c.role_id == self.role_model.id) + .join(self.group_model, self.group_model.id == group_role.c.group_id) + .join(user_group, user_group.c.group_id == self.group_model.id) + .join(self.user_model, self.user_model.id == user_group.c.user_id) .filter(self.user_model.id == get_user_id()) .filter(self.permission_model.name == permission_name) ).all() - return {s.name for s in view_menu_names} + return {s.name for s in view_menu_names_group_roles} + + # filter by user id + # view_menu_names_user_roles = ( + # base_query.join(assoc_user_role) + # .join(self.user_model) + # .filter(self.user_model.id == get_user_id()) + # .filter(self.permission_model.name == permission_name) + # ).all() + # return {s.name for s in view_menu_names} # Properly treat anonymous user if public_role := self.get_public_role(): diff --git a/superset/views/utils.py b/superset/views/utils.py index f2d06e7a3a4c..1f6a4348fd28 100644 --- a/superset/views/utils.py +++ b/superset/views/utils.py @@ -103,8 +103,8 @@ def bootstrap_user_data(user: User, include_perms: bool = False) -> dict[str, An def get_permissions( user: User, ) -> tuple[dict[str, list[tuple[str]]], DefaultDict[str, list[str]]]: - if not user.roles: - raise AttributeError("User object does not have roles") + if not user.roles and not user.groups: + raise AttributeError("User object does not have roles or groups") data_permissions = defaultdict(set) roles_permissions = security_manager.get_user_roles_permissions(user) From 38a38c6dffe0601aabe50999ac561a4be9db4aa9 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 24 Feb 2025 13:54:37 +0000 Subject: [PATCH 02/17] test FAB PR --- pyproject.toml | 2 +- requirements/base.txt | 2 +- requirements/development.txt | 2 +- ...12-46_ee02b34ff052_add_fab_group_entity.py | 84 +++++++++++++++++++ superset/security/manager.py | 30 +++++-- 5 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 superset/migrations/versions/2025-02-24_12-46_ee02b34ff052_add_fab_group_entity.py diff --git a/pyproject.toml b/pyproject.toml index f0208d661665..6dbbd26b0bef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography>=42.0.4, <45.0.0", "deprecation>=2.1.0, <2.2.0", "flask>=2.2.5, <3.0.0", - "flask-appbuilder>=4.5.3, <5.0.0", + "flask-appbuilder==4.6.0.dev1", "flask-caching>=2.1.0, <3", "flask-compress>=1.13, <2.0", "flask-talisman>=1.0.0, <2.0", diff --git a/requirements/base.txt b/requirements/base.txt index 391290ba39ad..f8d0d2cd0e52 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -108,7 +108,7 @@ flask==2.3.3 # flask-session # flask-sqlalchemy # flask-wtf -flask-appbuilder==4.5.3 +flask-appbuilder==4.6.0.dev1 # via apache-superset (pyproject.toml) flask-babel==2.0.0 # via flask-appbuilder diff --git a/requirements/development.txt b/requirements/development.txt index 230f14150d19..a24ace9a4e8e 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -190,7 +190,7 @@ flask==2.3.3 # flask-sqlalchemy # flask-testing # flask-wtf -flask-appbuilder==4.5.3 +flask-appbuilder==4.6.0.dev1 # via # -c requirements/base.txt # apache-superset diff --git a/superset/migrations/versions/2025-02-24_12-46_ee02b34ff052_add_fab_group_entity.py b/superset/migrations/versions/2025-02-24_12-46_ee02b34ff052_add_fab_group_entity.py new file mode 100644 index 000000000000..9277cc21c03e --- /dev/null +++ b/superset/migrations/versions/2025-02-24_12-46_ee02b34ff052_add_fab_group_entity.py @@ -0,0 +1,84 @@ +# 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. +"""add_fab_group_entity + +Revision ID: ee02b34ff052 +Revises: 74ad1125881c +Create Date: 2025-02-24 12:46:52.007952 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "ee02b34ff052" +down_revision = "74ad1125881c" + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "ab_group", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("label", sa.String(length=150), nullable=True), + sa.Column("description", sa.String(length=512), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "ab_group_role", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("group_id", sa.Integer(), nullable=True), + sa.Column("role_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["group_id"], ["ab_group.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["role_id"], ["ab_role.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("group_id", "role_id"), + ) + op.create_index("idx_group_id", "ab_group_role", ["group_id"], unique=False) + op.create_index("idx_group_role_id", "ab_group_role", ["role_id"], unique=False) + op.create_table( + "ab_user_group", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("group_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["group_id"], ["ab_group.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["ab_user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "group_id"), + ) + op.create_index("idx_user_group_id", "ab_user_group", ["group_id"], unique=False) + op.create_index("idx_user_id", "ab_user_group", ["user_id"], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("idx_role_id", table_name="ab_permission_view_role") + op.drop_index("idx_permission_view_id", table_name="ab_permission_view_role") + op.drop_index("idx_view_menu_id", table_name="ab_permission_view") + op.drop_index("idx_permission_id", table_name="ab_permission_view") + op.drop_index("idx_user_id", table_name="ab_user_group") + op.drop_index("idx_user_group_id", table_name="ab_user_group") + op.drop_table("ab_user_group") + op.drop_index("idx_group_role_id", table_name="ab_group_role") + op.drop_index("idx_group_id", table_name="ab_group_role") + op.drop_table("ab_group_role") + op.drop_table("ab_group") + # ### end Alembic commands ### diff --git a/superset/security/manager.py b/superset/security/manager.py index 8a970936cf59..e021fb3ca526 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -28,7 +28,6 @@ from flask_appbuilder.security.sqla.manager import SecurityManager from flask_appbuilder.security.sqla.models import ( assoc_permissionview_role, - assoc_user_role, Permission, PermissionView, Role, @@ -236,6 +235,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods "Log", "List Users", "List Roles", + "List Groups", "ResetPasswordView", "RoleModelView", "UserGroupModelView", @@ -734,8 +734,11 @@ def user_view_menu_names(self, permission_name: str) -> set[str]: ) if not g.user.is_anonymous: + from flask_appbuilder.security.sqla.models import ( + assoc_group_role, + assoc_user_group, + ) from sqlalchemy.orm import aliased - from flask_appbuilder.security.sqla.models import assoc_user_group, assoc_group_role user_group = aliased(assoc_user_group) group_role = aliased(assoc_group_role) @@ -743,10 +746,23 @@ def user_view_menu_names(self, permission_name: str) -> set[str]: # Query to fetch permissions via groups' roles view_menu_names_group_roles = ( self.get_session.query(self.viewmenu_model.name) - .join(self.permissionview_model, self.viewmenu_model.id == self.permissionview_model.view_menu_id) - .join(self.permission_model, self.permission_model.id == self.permissionview_model.permission_id) - .join(assoc_permissionview_role, assoc_permissionview_role.c.permission_view_id == self.permissionview_model.id) - .join(self.role_model, self.role_model.id == assoc_permissionview_role.c.role_id) + .join( + self.permissionview_model, + self.viewmenu_model.id == self.permissionview_model.view_menu_id, + ) + .join( + self.permission_model, + self.permission_model.id == self.permissionview_model.permission_id, + ) + .join( + assoc_permissionview_role, + assoc_permissionview_role.c.permission_view_id + == self.permissionview_model.id, + ) + .join( + self.role_model, + self.role_model.id == assoc_permissionview_role.c.role_id, + ) .join(group_role, group_role.c.role_id == self.role_model.id) .join(self.group_model, self.group_model.id == group_role.c.group_id) .join(user_group, user_group.c.group_id == self.group_model.id) @@ -2447,7 +2463,7 @@ def get_user_roles(self, user: Optional[User] = None) -> list[Role]: if user.is_anonymous: public_role = current_app.config.get("AUTH_ROLE_PUBLIC") return [self.get_public_role()] if public_role else [] - return user.roles + return super().get_user_roles(user) def get_guest_rls_filters( self, dataset: "BaseDatasource" From 353f2866bcb36a8de50f178690b453a5233c1a11 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 24 Feb 2025 14:08:57 +0000 Subject: [PATCH 03/17] remove mig --- ...12-46_ee02b34ff052_add_fab_group_entity.py | 84 ------------------- 1 file changed, 84 deletions(-) delete mode 100644 superset/migrations/versions/2025-02-24_12-46_ee02b34ff052_add_fab_group_entity.py diff --git a/superset/migrations/versions/2025-02-24_12-46_ee02b34ff052_add_fab_group_entity.py b/superset/migrations/versions/2025-02-24_12-46_ee02b34ff052_add_fab_group_entity.py deleted file mode 100644 index 9277cc21c03e..000000000000 --- a/superset/migrations/versions/2025-02-24_12-46_ee02b34ff052_add_fab_group_entity.py +++ /dev/null @@ -1,84 +0,0 @@ -# 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. -"""add_fab_group_entity - -Revision ID: ee02b34ff052 -Revises: 74ad1125881c -Create Date: 2025-02-24 12:46:52.007952 - -""" - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "ee02b34ff052" -down_revision = "74ad1125881c" - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "ab_group", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(length=100), nullable=False), - sa.Column("label", sa.String(length=150), nullable=True), - sa.Column("description", sa.String(length=512), nullable=True), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - op.create_table( - "ab_group_role", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("group_id", sa.Integer(), nullable=True), - sa.Column("role_id", sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(["group_id"], ["ab_group.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["role_id"], ["ab_role.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("group_id", "role_id"), - ) - op.create_index("idx_group_id", "ab_group_role", ["group_id"], unique=False) - op.create_index("idx_group_role_id", "ab_group_role", ["role_id"], unique=False) - op.create_table( - "ab_user_group", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.Integer(), nullable=True), - sa.Column("group_id", sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(["group_id"], ["ab_group.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["user_id"], ["ab_user.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("user_id", "group_id"), - ) - op.create_index("idx_user_group_id", "ab_user_group", ["group_id"], unique=False) - op.create_index("idx_user_id", "ab_user_group", ["user_id"], unique=False) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index("idx_role_id", table_name="ab_permission_view_role") - op.drop_index("idx_permission_view_id", table_name="ab_permission_view_role") - op.drop_index("idx_view_menu_id", table_name="ab_permission_view") - op.drop_index("idx_permission_id", table_name="ab_permission_view") - op.drop_index("idx_user_id", table_name="ab_user_group") - op.drop_index("idx_user_group_id", table_name="ab_user_group") - op.drop_table("ab_user_group") - op.drop_index("idx_group_role_id", table_name="ab_group_role") - op.drop_index("idx_group_id", table_name="ab_group_role") - op.drop_table("ab_group_role") - op.drop_table("ab_group") - # ### end Alembic commands ### From 91cf09e88d05ae6af36b64af83053806ae1248a3 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 24 Feb 2025 15:15:35 +0000 Subject: [PATCH 04/17] fix --- superset/security/manager.py | 47 ++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/superset/security/manager.py b/superset/security/manager.py index e021fb3ca526..702657c38454 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -28,6 +28,7 @@ from flask_appbuilder.security.sqla.manager import SecurityManager from flask_appbuilder.security.sqla.models import ( assoc_permissionview_role, + assoc_user_role, Permission, PermissionView, Role, @@ -738,13 +739,8 @@ def user_view_menu_names(self, permission_name: str) -> set[str]: assoc_group_role, assoc_user_group, ) - from sqlalchemy.orm import aliased - user_group = aliased(assoc_user_group) - group_role = aliased(assoc_group_role) - - # Query to fetch permissions via groups' roles - view_menu_names_group_roles = ( + view_menu_names = ( self.get_session.query(self.viewmenu_model.name) .join( self.permissionview_model, @@ -763,23 +759,32 @@ def user_view_menu_names(self, permission_name: str) -> set[str]: self.role_model, self.role_model.id == assoc_permissionview_role.c.role_id, ) - .join(group_role, group_role.c.role_id == self.role_model.id) - .join(self.group_model, self.group_model.id == group_role.c.group_id) - .join(user_group, user_group.c.group_id == self.group_model.id) - .join(self.user_model, self.user_model.id == user_group.c.user_id) - .filter(self.user_model.id == get_user_id()) + .outerjoin( + assoc_group_role, assoc_group_role.c.role_id == self.role_model.id + ) # Keep roles without groups + .outerjoin( + self.group_model, self.group_model.id == assoc_group_role.c.group_id + ) + .outerjoin( + assoc_user_group, assoc_user_group.c.group_id == self.group_model.id + ) + .outerjoin( + self.user_model, self.user_model.id == assoc_user_group.c.user_id + ) + .outerjoin( + assoc_user_role, assoc_user_role.c.role_id == self.role_model.id + ) # Direct role assignment + .filter( + or_( + self.user_model.id == get_user_id(), # Either through groups + assoc_user_role.c.user_id + == get_user_id(), # Or directly assigned + ) + ) .filter(self.permission_model.name == permission_name) ).all() - return {s.name for s in view_menu_names_group_roles} - - # filter by user id - # view_menu_names_user_roles = ( - # base_query.join(assoc_user_role) - # .join(self.user_model) - # .filter(self.user_model.id == get_user_id()) - # .filter(self.permission_model.name == permission_name) - # ).all() - # return {s.name for s in view_menu_names} + + return {s.name for s in view_menu_names} # Properly treat anonymous user if public_role := self.get_public_role(): From a67c2e411f8289ea8e5921753e964dadcd406e40 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 25 Feb 2025 10:30:57 +0000 Subject: [PATCH 05/17] fix test and optimize --- superset/security/manager.py | 59 +++++-------------- .../charts/commands_tests.py | 6 +- 2 files changed, 20 insertions(+), 45 deletions(-) diff --git a/superset/security/manager.py b/superset/security/manager.py index 702657c38454..803cc0525c8a 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -51,6 +51,7 @@ from sqlalchemy.orm import eagerload from sqlalchemy.orm.mapper import Mapper from sqlalchemy.orm.query import Query as SqlaQuery +from sqlalchemy.sql import exists from superset.constants import RouteMethod from superset.errors import ErrorLevel, SupersetError, SupersetErrorType @@ -740,50 +741,22 @@ def user_view_menu_names(self, permission_name: str) -> set[str]: assoc_user_group, ) - view_menu_names = ( - self.get_session.query(self.viewmenu_model.name) - .join( - self.permissionview_model, - self.viewmenu_model.id == self.permissionview_model.view_menu_id, - ) - .join( - self.permission_model, - self.permission_model.id == self.permissionview_model.permission_id, - ) - .join( - assoc_permissionview_role, - assoc_permissionview_role.c.permission_view_id - == self.permissionview_model.id, - ) - .join( - self.role_model, - self.role_model.id == assoc_permissionview_role.c.role_id, - ) - .outerjoin( - assoc_group_role, assoc_group_role.c.role_id == self.role_model.id - ) # Keep roles without groups - .outerjoin( - self.group_model, self.group_model.id == assoc_group_role.c.group_id - ) - .outerjoin( - assoc_user_group, assoc_user_group.c.group_id == self.group_model.id - ) - .outerjoin( - self.user_model, self.user_model.id == assoc_user_group.c.user_id - ) - .outerjoin( - assoc_user_role, assoc_user_role.c.role_id == self.role_model.id - ) # Direct role assignment - .filter( - or_( - self.user_model.id == get_user_id(), # Either through groups - assoc_user_role.c.user_id - == get_user_id(), # Or directly assigned - ) - ) - .filter(self.permission_model.name == permission_name) - ).all() + user_id = get_user_id() + + user_roles_filter = or_( + exists().where( + (assoc_user_role.c.user_id == user_id) + & (assoc_user_role.c.role_id == self.role_model.id) + ), + exists().where( + (assoc_user_group.c.user_id == user_id) + & (assoc_user_group.c.group_id == self.group_model.id) + & (assoc_group_role.c.group_id == self.group_model.id) + & (assoc_group_role.c.role_id == Role.id) + ), + ) + view_menu_names = base_query.filter(user_roles_filter).all() return {s.name for s in view_menu_names} # Properly treat anonymous user diff --git a/tests/integration_tests/charts/commands_tests.py b/tests/integration_tests/charts/commands_tests.py index 3a193b40e21d..f50a9ea96853 100644 --- a/tests/integration_tests/charts/commands_tests.py +++ b/tests/integration_tests/charts/commands_tests.py @@ -104,11 +104,13 @@ def test_export_chart_command(self, mock_g): "query_context": None, } + @patch("superset.utils.core.g") @patch("superset.security.manager.g") @pytest.mark.usefixtures("load_energy_table_with_slice") - def test_export_chart_command_no_access(self, mock_g): + def test_export_chart_command_no_access(self, utils_mock_g, manager_mock_g): """Test that users can't export datasets they don't have access to""" - mock_g.user = security_manager.find_user("gamma") + manager_mock_g.user = security_manager.find_user("gamma") + utils_mock_g.user = manager_mock_g.user example_chart = db.session.query(Slice).all()[0] command = ExportChartsCommand([example_chart.id]) From 4ee81e84328f7dcce28ff50b049f0e23f0ba4bb7 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 25 Feb 2025 10:50:19 +0000 Subject: [PATCH 06/17] fix guest user --- superset/security/guest_token.py | 1 + 1 file changed, 1 insertion(+) diff --git a/superset/security/guest_token.py b/superset/security/guest_token.py index 6727330afe92..0f0b2188d1d5 100644 --- a/superset/security/guest_token.py +++ b/superset/security/guest_token.py @@ -84,5 +84,6 @@ def __init__(self, token: GuestToken, roles: list[Role]): self.first_name = user.get("first_name", "Guest") self.last_name = user.get("last_name", "User") self.roles = roles + self.groups = [] # Guest users don't belong to any groups self.resources = token["resources"] self.rls = token.get("rls_rules", []) From ff8d32c0c421de643e4e4ff2dd1ea87bd4b9457e Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 25 Feb 2025 10:58:02 +0000 Subject: [PATCH 07/17] fix lint --- superset/security/guest_token.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/superset/security/guest_token.py b/superset/security/guest_token.py index 0f0b2188d1d5..4d1b25030f46 100644 --- a/superset/security/guest_token.py +++ b/superset/security/guest_token.py @@ -16,7 +16,7 @@ # under the License. from typing import Optional, TypedDict, Union -from flask_appbuilder.security.sqla.models import Role +from flask_appbuilder.security.sqla.models import Group, Role from flask_login import AnonymousUserMixin from superset.utils.backports import StrEnum @@ -84,6 +84,6 @@ def __init__(self, token: GuestToken, roles: list[Role]): self.first_name = user.get("first_name", "Guest") self.last_name = user.get("last_name", "User") self.roles = roles - self.groups = [] # Guest users don't belong to any groups + self.groups: list[Group] = [] # Guest users don't belong to any groups self.resources = token["resources"] self.rls = token.get("rls_rules", []) From 08857eb8e54b58ccd90a21fdc960b08e7538740d Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 25 Feb 2025 14:43:28 +0000 Subject: [PATCH 08/17] add tests --- tests/integration_tests/security_tests.py | 26 ++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index a89bb47af78f..c4e406f19511 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -1871,12 +1871,36 @@ def test_raise_for_access_rbac( } ) - def test_get_user_roles(self): + def test_get_admin_user_roles(self): admin = security_manager.find_user("admin") with override_user(admin): roles = security_manager.get_user_roles() assert admin.roles == roles + def test_get_gamma_user_roles(self): + admin = security_manager.find_user("gamma") + with override_user(admin): + roles = security_manager.get_user_roles() + assert admin.roles == roles + + # def test_get_user_roles_with_groups(self): + # gamma_role = security_manager.find_role("Gamma") + # group = security_manager.add_group("group1","","", roles=[gamma_role]) + # user = security_manager.add_user( + # "gamma_with_groups", + # "gamma", + # "user", + # "gamma_with_groups", + # role=[], + # groups=[group], + # ) + # with override_user(user): + # roles = security_manager.get_user_roles() + # assert user.roles == roles + # security_manager.get_session.delete(user) + # security_manager.get_session.delete(group) + # security_manager.get_session.commit() + def test_get_anonymous_roles(self): with override_user(security_manager.get_anonymous_user()): roles = security_manager.get_user_roles() From 56c0faf8d42bdbd53f6f909cd0922bd9c745507a Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 25 Feb 2025 23:14:38 +0000 Subject: [PATCH 09/17] add tests --- superset/security/manager.py | 2 + tests/integration_tests/fixtures/users.py | 64 +++++++++++++++++++++++ tests/integration_tests/security_tests.py | 60 +++++++++++++++------ 3 files changed, 109 insertions(+), 17 deletions(-) diff --git a/superset/security/manager.py b/superset/security/manager.py index 803cc0525c8a..b95a59b2f76e 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -747,12 +747,14 @@ def user_view_menu_names(self, permission_name: str) -> set[str]: exists().where( (assoc_user_role.c.user_id == user_id) & (assoc_user_role.c.role_id == self.role_model.id) + & (self.permission_model.name == permission_name) ), exists().where( (assoc_user_group.c.user_id == user_id) & (assoc_user_group.c.group_id == self.group_model.id) & (assoc_group_role.c.group_id == self.group_model.id) & (assoc_group_role.c.role_id == Role.id) + & (self.permission_model.name == permission_name) ), ) diff --git a/tests/integration_tests/fixtures/users.py b/tests/integration_tests/fixtures/users.py index 1a2073bf90b3..c39ad3c3326b 100644 --- a/tests/integration_tests/fixtures/users.py +++ b/tests/integration_tests/fixtures/users.py @@ -22,6 +22,70 @@ from tests.integration_tests.constants import GAMMA_SQLLAB_NO_DATA_USERNAME +@pytest.fixture +def create_gamma_user_group(app_context: AppContext): + gamma_role = security_manager.find_role("Gamma") + group = security_manager.add_group("group1", "", "", roles=[gamma_role]) + user = security_manager.add_user( + "gamma_with_groups", + "gamma", + "user", + "gamma_with_groups", + password="password1", # noqa: S106 + role=[], + groups=[group], + ) + yield + security_manager.get_session.delete(user) + security_manager.get_session.delete(group) + security_manager.get_session.commit() + + +@pytest.fixture +def create_user_group_with_dar(app_context: AppContext): + pvm = security_manager.add_permission_view_menu( + "datasource_access", "[examples].[birth_names](id:1)]" + ) + dar_role = security_manager.add_role("dar", [pvm]) + group = security_manager.add_group("group1", "", "", roles=[dar_role]) + user = security_manager.add_user( + "gamma_with_groups", + "gamma", + "user", + "gamma_with_groups", + password="password1", # noqa: S106 + role=[], + groups=[group], + ) + yield + security_manager.get_session.delete(user) + security_manager.get_session.delete(group) + security_manager.get_session.commit() + + +@pytest.fixture +def create_gamma_user_group_with_dar(app_context: AppContext): + pvm = security_manager.add_permission_view_menu( + "datasource_access", "[examples].[birth_names](id:1)]" + ) + dar_role = security_manager.add_role("dar", [pvm]) + gamma_role = security_manager.find_role("Gamma") + group = security_manager.add_group("group1", "", "", roles=[dar_role, gamma_role]) + user = security_manager.add_user( + "gamma_with_groups", + "gamma", + "user", + "gamma_with_groups", + password="password1", # noqa: S106 + role=[], + groups=[group], + ) + yield + security_manager.get_session.delete(user) + security_manager.get_session.delete(group) + security_manager.get_session.commit() + + @pytest.fixture def create_gamma_sqllab_no_data(app_context: AppContext): gamma_role = db.session.query(Role).filter(Role.name == "Gamma").one_or_none() diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index c4e406f19511..30c8685ee8a3 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -63,6 +63,11 @@ load_world_bank_dashboard_with_slices, # noqa: F401 load_world_bank_data, # noqa: F401 ) +from tests.integration_tests.fixtures.users import ( + create_gamma_user_group, # noqa: F401 + create_user_group_with_dar, # noqa: F401 + create_gamma_user_group_with_dar, # noqa: F401 +) NEW_SECURITY_CONVERGE_VIEWS = ( "Annotation", @@ -1883,23 +1888,44 @@ def test_get_gamma_user_roles(self): roles = security_manager.get_user_roles() assert admin.roles == roles - # def test_get_user_roles_with_groups(self): - # gamma_role = security_manager.find_role("Gamma") - # group = security_manager.add_group("group1","","", roles=[gamma_role]) - # user = security_manager.add_user( - # "gamma_with_groups", - # "gamma", - # "user", - # "gamma_with_groups", - # role=[], - # groups=[group], - # ) - # with override_user(user): - # roles = security_manager.get_user_roles() - # assert user.roles == roles - # security_manager.get_session.delete(user) - # security_manager.get_session.delete(group) - # security_manager.get_session.commit() + @pytest.mark.usefixtures("create_gamma_user_group") + def test_get_user_roles_with_groups(self): + user = security_manager.find_user("gamma_with_groups") + with override_user(user): + roles = security_manager.get_user_roles() + assert user.groups[0].roles == roles + + @pytest.mark.usefixtures("create_gamma_user_group_with_dar") + def test_get_user_roles_with_groups_dar(self): + user = security_manager.find_user("gamma_with_groups") + with override_user(user): + roles = security_manager.get_user_roles() + assert roles[0].name == "Gamma" + assert roles[1].name == "dar" + + @pytest.mark.usefixtures("create_user_group_with_dar") + def test_user_view_menu_names_with_groups_dar(self): + user = security_manager.find_user("gamma_with_groups") + with override_user(user): + assert security_manager.user_view_menu_names("datasource_access") == { + "[examples].[birth_names](id:1)]" + } + + @pytest.mark.usefixtures("create_gamma_user_group_with_dar") + def test_gamma_user_view_menu_names_with_groups_dar(self): + user = security_manager.find_user("gamma_with_groups") + with override_user(user): + # assert pvm for dar role + assert security_manager.user_view_menu_names("datasource_access") == { + "[examples].[birth_names](id:1)]" + } + # assert pvm for gamma role + assert security_manager.user_view_menu_names("can_external_metadata") == { + "Datasource" + } + assert security_manager.user_view_menu_names("can_recent_activity") == { + "Log" + } def test_get_anonymous_roles(self): with override_user(security_manager.get_anonymous_user()): From 1701d84997c737bcd658b29bc0df2b00161ccdf4 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 26 Feb 2025 11:48:41 +0000 Subject: [PATCH 10/17] add tests --- .../integration_tests/databases/api_tests.py | 15 +++ tests/integration_tests/fixtures/users.py | 94 +++++++++++-------- 2 files changed, 70 insertions(+), 39 deletions(-) diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py index 03327e0e2674..e6fc52042983 100644 --- a/tests/integration_tests/databases/api_tests.py +++ b/tests/integration_tests/databases/api_tests.py @@ -78,6 +78,9 @@ load_unicode_dashboard_with_position, # noqa: F401 load_unicode_data, # noqa: F401 ) +from tests.integration_tests.fixtures.users import ( + create_gamma_user_group_with_all_database, # noqa: F401 +) from tests.integration_tests.test_app import app @@ -259,6 +262,18 @@ def test_get_items_not_allowed(self): response = json.loads(rv.data.decode("utf-8")) assert response["count"] == 0 + @pytest.mark.usefixtures("create_gamma_user_group_with_all_database") + def test_get_items_gamma_group(self): + """ + Database API: Test get items gamma with group + """ + self.login("gamma_with_groups", "password1") + uri = "api/v1/database/" + rv = self.client.get(uri) + assert rv.status_code == 200 + response = json.loads(rv.data.decode("utf-8")) + assert response["count"] == 1 + def test_create_database(self): """ Database API: Test create diff --git a/tests/integration_tests/fixtures/users.py b/tests/integration_tests/fixtures/users.py index c39ad3c3326b..af03d1d4954f 100644 --- a/tests/integration_tests/fixtures/users.py +++ b/tests/integration_tests/fixtures/users.py @@ -22,68 +22,84 @@ from tests.integration_tests.constants import GAMMA_SQLLAB_NO_DATA_USERNAME -@pytest.fixture -def create_gamma_user_group(app_context: AppContext): - gamma_role = security_manager.find_role("Gamma") - group = security_manager.add_group("group1", "", "", roles=[gamma_role]) +def create_role_with_permissions(role_name: str, permissions: list[tuple[str, str]]): + pvm_list = [ + security_manager.add_permission_view_menu(p[0], p[1]) for p in permissions + ] + return security_manager.add_role(role_name, pvm_list) + + +def create_user_and_group( + group_name: str, + username: str, + roles: list[Role], + password: str = "password1", # noqa: S107 +): + group = security_manager.add_group(group_name, "", "", roles=roles) user = security_manager.add_user( - "gamma_with_groups", + username, "gamma", "user", - "gamma_with_groups", - password="password1", # noqa: S106 + username, + password=password, # noqa: S106 role=[], groups=[group], ) - yield + return user, group + + +def cleanup(user, group): security_manager.get_session.delete(user) security_manager.get_session.delete(group) security_manager.get_session.commit() +@pytest.fixture +def create_gamma_user_group(app_context: AppContext): + gamma_role = security_manager.find_role("Gamma") + user, group = create_user_and_group("group1", "gamma_with_groups", [gamma_role]) + yield + cleanup(user, group) + + @pytest.fixture def create_user_group_with_dar(app_context: AppContext): - pvm = security_manager.add_permission_view_menu( - "datasource_access", "[examples].[birth_names](id:1)]" - ) - dar_role = security_manager.add_role("dar", [pvm]) - group = security_manager.add_group("group1", "", "", roles=[dar_role]) - user = security_manager.add_user( - "gamma_with_groups", - "gamma", - "user", - "gamma_with_groups", - password="password1", # noqa: S106 - role=[], - groups=[group], + dar_role = create_role_with_permissions( + "dar", [("datasource_access", "[examples].[birth_names](id:1)]")] ) + user, group = create_user_and_group("group1", "gamma_with_groups", [dar_role]) yield - security_manager.get_session.delete(user) - security_manager.get_session.delete(group) - security_manager.get_session.commit() + cleanup(user, group) @pytest.fixture def create_gamma_user_group_with_dar(app_context: AppContext): - pvm = security_manager.add_permission_view_menu( - "datasource_access", "[examples].[birth_names](id:1)]" + dar_role = create_role_with_permissions( + "dar", + [ + ("datasource_access", "[examples].[birth_names](id:1)]"), + ("all_database_access", "all_database_access"), + ], ) - dar_role = security_manager.add_role("dar", [pvm]) gamma_role = security_manager.find_role("Gamma") - group = security_manager.add_group("group1", "", "", roles=[dar_role, gamma_role]) - user = security_manager.add_user( - "gamma_with_groups", - "gamma", - "user", - "gamma_with_groups", - password="password1", # noqa: S106 - role=[], - groups=[group], + user, group = create_user_and_group( + "group1", "gamma_with_groups", [dar_role, gamma_role] ) yield - security_manager.get_session.delete(user) - security_manager.get_session.delete(group) - security_manager.get_session.commit() + cleanup(user, group) + + +@pytest.fixture +def create_gamma_user_group_with_all_database(app_context: AppContext): + dar_role = create_role_with_permissions( + "dar", [("all_database_access", "all_database_access")] + ) + gamma_role = security_manager.find_role("Gamma") + user, group = create_user_and_group( + "group1", "gamma_with_groups", [dar_role, gamma_role] + ) + yield + cleanup(user, group) @pytest.fixture From 2f22c16df6aead7b77ec574ba5e53607f13be94a Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 26 Feb 2025 11:59:48 +0000 Subject: [PATCH 11/17] fix test --- tests/integration_tests/databases/api_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py index e6fc52042983..a2f2777d0015 100644 --- a/tests/integration_tests/databases/api_tests.py +++ b/tests/integration_tests/databases/api_tests.py @@ -272,7 +272,7 @@ def test_get_items_gamma_group(self): rv = self.client.get(uri) assert rv.status_code == 200 response = json.loads(rv.data.decode("utf-8")) - assert response["count"] == 1 + assert response["count"] > 0 def test_create_database(self): """ From d9f600fa03448f5a5272144dc19a7a0fea73fe93 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 26 Feb 2025 12:49:24 +0000 Subject: [PATCH 12/17] add test --- tests/integration_tests/databases/api_tests.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py index a2f2777d0015..224b10a9210a 100644 --- a/tests/integration_tests/databases/api_tests.py +++ b/tests/integration_tests/databases/api_tests.py @@ -2246,6 +2246,17 @@ def test_get_database_related_objects_not_found(self): rv = self.get_assert_metric(uri, "related_objects") assert rv.status_code == 404 + @pytest.mark.usefixtures("create_gamma_user_group_with_all_database") + def test_get_database_related_objects_gamma_group(self): + """ + Database API: Test related objects with gamma group with role all database + """ + database = get_example_database() + self.login("gamma_with_groups", "password1") + uri = f"api/v1/database/{database.id}/related_objects/" + rv = self.get_assert_metric(uri, "related_objects") + assert rv.status_code == 200 + def test_export_database(self): """ Database API: Test export database From 5fe21afed91fca9c5c5c74e10e54d0481261245a Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 26 Feb 2025 12:53:18 +0000 Subject: [PATCH 13/17] fix imports --- superset/security/manager.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/superset/security/manager.py b/superset/security/manager.py index b95a59b2f76e..a6e591f9ec6a 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -27,7 +27,9 @@ from flask_appbuilder import Model from flask_appbuilder.security.sqla.manager import SecurityManager from flask_appbuilder.security.sqla.models import ( + assoc_group_role, assoc_permissionview_role, + assoc_user_group, assoc_user_role, Permission, PermissionView, @@ -736,11 +738,6 @@ def user_view_menu_names(self, permission_name: str) -> set[str]: ) if not g.user.is_anonymous: - from flask_appbuilder.security.sqla.models import ( - assoc_group_role, - assoc_user_group, - ) - user_id = get_user_id() user_roles_filter = or_( From 6e5d2b23d7da0d89ba12b5a2e99f460b94b89581 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 26 Feb 2025 12:56:17 +0000 Subject: [PATCH 14/17] bump to FAB dev2 release --- pyproject.toml | 2 +- requirements/base.txt | 2 +- requirements/development.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6dbbd26b0bef..07d2bf344711 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography>=42.0.4, <45.0.0", "deprecation>=2.1.0, <2.2.0", "flask>=2.2.5, <3.0.0", - "flask-appbuilder==4.6.0.dev1", + "flask-appbuilder==4.6.0.dev2", "flask-caching>=2.1.0, <3", "flask-compress>=1.13, <2.0", "flask-talisman>=1.0.0, <2.0", diff --git a/requirements/base.txt b/requirements/base.txt index f8d0d2cd0e52..b53e9b995f6d 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -108,7 +108,7 @@ flask==2.3.3 # flask-session # flask-sqlalchemy # flask-wtf -flask-appbuilder==4.6.0.dev1 +flask-appbuilder==4.6.0.dev2 # via apache-superset (pyproject.toml) flask-babel==2.0.0 # via flask-appbuilder diff --git a/requirements/development.txt b/requirements/development.txt index a24ace9a4e8e..61a8bc4f2fc3 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -190,7 +190,7 @@ flask==2.3.3 # flask-sqlalchemy # flask-testing # flask-wtf -flask-appbuilder==4.6.0.dev1 +flask-appbuilder==4.6.0.dev2 # via # -c requirements/base.txt # apache-superset From 27b3890ed0c2bfaf36e9cec8eb41146d2b399ceb Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 28 Feb 2025 10:10:14 +0000 Subject: [PATCH 15/17] test: fab 4.6.0rc1 --- pyproject.toml | 2 +- requirements/base.txt | 2 +- requirements/development.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 07d2bf344711..145ec95673a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography>=42.0.4, <45.0.0", "deprecation>=2.1.0, <2.2.0", "flask>=2.2.5, <3.0.0", - "flask-appbuilder==4.6.0.dev2", + "flask-appbuilder==4.6.0rc1", "flask-caching>=2.1.0, <3", "flask-compress>=1.13, <2.0", "flask-talisman>=1.0.0, <2.0", diff --git a/requirements/base.txt b/requirements/base.txt index b53e9b995f6d..e0641dbd00e7 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -108,7 +108,7 @@ flask==2.3.3 # flask-session # flask-sqlalchemy # flask-wtf -flask-appbuilder==4.6.0.dev2 +flask-appbuilder==4.6.0rc1 # via apache-superset (pyproject.toml) flask-babel==2.0.0 # via flask-appbuilder diff --git a/requirements/development.txt b/requirements/development.txt index 61a8bc4f2fc3..7f4488de86fc 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -190,7 +190,7 @@ flask==2.3.3 # flask-sqlalchemy # flask-testing # flask-wtf -flask-appbuilder==4.6.0.dev2 +flask-appbuilder==4.6.0rc1 # via # -c requirements/base.txt # apache-superset From df337583fecec3742e2f3a2ebdc7f9161a179f3f Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 28 Feb 2025 11:31:32 +0000 Subject: [PATCH 16/17] fix test and address AI comment --- superset/security/manager.py | 2 +- tests/integration_tests/security_tests.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/superset/security/manager.py b/superset/security/manager.py index a6e591f9ec6a..80a1fb957dd6 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -750,7 +750,7 @@ def user_view_menu_names(self, permission_name: str) -> set[str]: (assoc_user_group.c.user_id == user_id) & (assoc_user_group.c.group_id == self.group_model.id) & (assoc_group_role.c.group_id == self.group_model.id) - & (assoc_group_role.c.role_id == Role.id) + & (assoc_group_role.c.role_id == self.role_model.id) & (self.permission_model.name == permission_name) ), ) diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index 30c8685ee8a3..96886fab3b2e 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -1899,9 +1899,10 @@ def test_get_user_roles_with_groups(self): def test_get_user_roles_with_groups_dar(self): user = security_manager.find_user("gamma_with_groups") with override_user(user): - roles = security_manager.get_user_roles() - assert roles[0].name == "Gamma" - assert roles[1].name == "dar" + role_names = [role.name for role in security_manager.get_user_roles()] + assert "Gamma" in role_names + assert "dar" in role_names + assert len(role_names) == 2 @pytest.mark.usefixtures("create_user_group_with_dar") def test_user_view_menu_names_with_groups_dar(self): From e2384ed654ff84cced98f8dee50f18fe7bf80d6a Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 28 Feb 2025 13:57:01 +0000 Subject: [PATCH 17/17] use final FAB version --- pyproject.toml | 2 +- requirements/base.txt | 2 +- requirements/development.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 145ec95673a4..5074a2035371 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography>=42.0.4, <45.0.0", "deprecation>=2.1.0, <2.2.0", "flask>=2.2.5, <3.0.0", - "flask-appbuilder==4.6.0rc1", + "flask-appbuilder>=4.6.0, <5.0.0", "flask-caching>=2.1.0, <3", "flask-compress>=1.13, <2.0", "flask-talisman>=1.0.0, <2.0", diff --git a/requirements/base.txt b/requirements/base.txt index e0641dbd00e7..5d6a8a84d0f9 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -108,7 +108,7 @@ flask==2.3.3 # flask-session # flask-sqlalchemy # flask-wtf -flask-appbuilder==4.6.0rc1 +flask-appbuilder==4.6.0 # via apache-superset (pyproject.toml) flask-babel==2.0.0 # via flask-appbuilder diff --git a/requirements/development.txt b/requirements/development.txt index 7f4488de86fc..5a17596ac681 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -190,7 +190,7 @@ flask==2.3.3 # flask-sqlalchemy # flask-testing # flask-wtf -flask-appbuilder==4.6.0rc1 +flask-appbuilder==4.6.0 # via # -c requirements/base.txt # apache-superset