From a7ea9b245676fcd8a2a03f689a9ba7bc3debe89f Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 21 Oct 2025 20:47:21 -0400 Subject: [PATCH 1/2] Update uv and pip --- .github/actions/install-pre-commit/action.yml | 2 +- Dockerfile | 4 ++-- Dockerfile.ci | 4 ++-- dev/MANUALLY_GENERATING_IMAGE_CACHE_AND_CONSTRAINTS.md | 2 +- dev/breeze/doc/ci/02_images.md | 4 ++-- .../airflow_breeze/commands/release_management_commands.py | 4 ++-- dev/breeze/src/airflow_breeze/global_constants.py | 4 ++-- scripts/ci/install_breeze.sh | 4 ++-- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/actions/install-pre-commit/action.yml b/.github/actions/install-pre-commit/action.yml index ed649f4731d46..a240816a630fd 100644 --- a/.github/actions/install-pre-commit/action.yml +++ b/.github/actions/install-pre-commit/action.yml @@ -24,7 +24,7 @@ inputs: default: "3.9" uv-version: description: 'uv version to use' - default: "0.7.16" # Keep this comment to allow automatic replacement of uv version + default: "0.9.4" # Keep this comment to allow automatic replacement of uv version pre-commit-version: description: 'pre-commit version to use' default: "3.5.0" # Keep this comment to allow automatic replacement of pre-commit version diff --git a/Dockerfile b/Dockerfile index 40ed0810bc55e..f29cdd1e42148 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,9 +53,9 @@ ARG PYTHON_BASE_IMAGE="python:3.9-slim-bookworm" # You can swap comments between those two args to test pip from the main version # When you attempt to test if the version of `pip` from specified branch works for our builds # Also use `force pip` label on your PR to swap all places we use `uv` to `pip` -ARG AIRFLOW_PIP_VERSION=25.1.1 +ARG AIRFLOW_PIP_VERSION=25.2 # ARG AIRFLOW_PIP_VERSION="git+https://github.com/pypa/pip.git@main" -ARG AIRFLOW_UV_VERSION=0.7.16 +ARG AIRFLOW_UV_VERSION=0.9.4 ARG AIRFLOW_USE_UV="false" ARG UV_HTTP_TIMEOUT="300" ARG AIRFLOW_IMAGE_REPOSITORY="https://github.com/apache/airflow" diff --git a/Dockerfile.ci b/Dockerfile.ci index 9bbcfcf86b68c..c45e96c3c7366 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -1249,9 +1249,9 @@ COPY --from=scripts common.sh install_packaging_tools.sh install_additional_depe # You can swap comments between those two args to test pip from the main version # When you attempt to test if the version of `pip` from specified branch works for our builds # Also use `force pip` label on your PR to swap all places we use `uv` to `pip` -ARG AIRFLOW_PIP_VERSION=25.1.1 +ARG AIRFLOW_PIP_VERSION=25.2 # ARG AIRFLOW_PIP_VERSION="git+https://github.com/pypa/pip.git@main" -ARG AIRFLOW_UV_VERSION=0.7.16 +ARG AIRFLOW_UV_VERSION=0.9.4 # TODO(potiuk): automate with upgrade check (possibly) ARG AIRFLOW_PRE_COMMIT_VERSION="3.5.0" diff --git a/dev/MANUALLY_GENERATING_IMAGE_CACHE_AND_CONSTRAINTS.md b/dev/MANUALLY_GENERATING_IMAGE_CACHE_AND_CONSTRAINTS.md index e969858e59f1c..f1bedcab8bd60 100644 --- a/dev/MANUALLY_GENERATING_IMAGE_CACHE_AND_CONSTRAINTS.md +++ b/dev/MANUALLY_GENERATING_IMAGE_CACHE_AND_CONSTRAINTS.md @@ -191,7 +191,7 @@ Latest version google-cloud-aiplatform==1.30.1 release date: 2023-08-11 21:19:50 Latest version grpcio-status==1.57.0 release date: 2023-08-10 15:54:17. In current constraints: 1.56.2) Latest version grpcio==1.57.0 release date: 2023-08-10 15:51:52. In current constraints: 1.56.2) Latest version mypy==1.5.0 release date: 2023-08-10 12:46:43. In current constraints: 1.2.0) -Latest version pyzmq==25.1.1 release date: 2023-08-10 09:01:18. In current constraints: 25.1.0) +Latest version pyzmq==25.2 release date: 2023-08-10 09:01:18. In current constraints: 25.1.0) Latest version tornado==6.3.3 release date: 2023-08-11 15:21:47. In current constraints: 6.3.2) Latest version tqdm==4.66.1 release date: 2023-08-10 11:38:57. In current constraints: 4.66.0) Latest version virtualenv==20.24.3 release date: 2023-08-11 15:52:32. In current constraints: 20.24.1) diff --git a/dev/breeze/doc/ci/02_images.md b/dev/breeze/doc/ci/02_images.md index c45adabb5d20a..3d1f647aebc47 100644 --- a/dev/breeze/doc/ci/02_images.md +++ b/dev/breeze/doc/ci/02_images.md @@ -442,8 +442,8 @@ can be used for CI images: | `DEV_APT_DEPS` | | Dev APT dependencies installed in the first part of the image (default empty means default dependencies are used) | | `ADDITIONAL_DEV_APT_DEPS` | | Additional apt dev dependencies installed in the first part of the image | | `ADDITIONAL_DEV_APT_ENV` | | Additional env variables defined when installing dev deps | -| `AIRFLOW_PIP_VERSION` | `25.1.1` | `pip` version used. | -| `AIRFLOW_UV_VERSION` | `0.7.16` | `uv` version used. | +| `AIRFLOW_PIP_VERSION` | `25.2` | `pip` version used. | +| `AIRFLOW_UV_VERSION` | `0.9.4` | `uv` version used. | | `AIRFLOW_PRE_COMMIT_VERSION` | `3.5.0` | `pre-commit` version used. | | `AIRFLOW_USE_UV` | `true` | Whether to use UV for installation. | | `PIP_PROGRESS_BAR` | `on` | Progress bar for PIP installation | diff --git a/dev/breeze/src/airflow_breeze/commands/release_management_commands.py b/dev/breeze/src/airflow_breeze/commands/release_management_commands.py index 1195bbe6a3865..9baad22989f40 100644 --- a/dev/breeze/src/airflow_breeze/commands/release_management_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/release_management_commands.py @@ -233,8 +233,8 @@ class VersionedFile(NamedTuple): file_name: str -AIRFLOW_PIP_VERSION = "25.1.1" -AIRFLOW_UV_VERSION = "0.7.16" +AIRFLOW_PIP_VERSION = "25.2" +AIRFLOW_UV_VERSION = "0.9.4" AIRFLOW_USE_UV = False # TODO: automate these as well WHEEL_VERSION = "0.44.0" diff --git a/dev/breeze/src/airflow_breeze/global_constants.py b/dev/breeze/src/airflow_breeze/global_constants.py index 6fa91b584577d..d6cd201f43f97 100644 --- a/dev/breeze/src/airflow_breeze/global_constants.py +++ b/dev/breeze/src/airflow_breeze/global_constants.py @@ -186,8 +186,8 @@ ALLOWED_INSTALL_MYSQL_CLIENT_TYPES = ["mariadb", "mysql"] -PIP_VERSION = "25.1.1" -UV_VERSION = "0.7.16" +PIP_VERSION = "25.2" +UV_VERSION = "0.9.4" DEFAULT_UV_HTTP_TIMEOUT = 300 DEFAULT_WSL2_HTTP_TIMEOUT = 900 diff --git a/scripts/ci/install_breeze.sh b/scripts/ci/install_breeze.sh index 1743f549be8f6..d4e343d3248ec 100755 --- a/scripts/ci/install_breeze.sh +++ b/scripts/ci/install_breeze.sh @@ -21,8 +21,8 @@ cd "$( dirname "${BASH_SOURCE[0]}" )/../../" PYTHON_ARG="" -PIP_VERSION="25.1.1" -UV_VERSION="0.7.16" +PIP_VERSION="25.2" +UV_VERSION="0.9.4" if [[ ${PYTHON_VERSION=} != "" ]]; then PYTHON_ARG="--python=$(which python"${PYTHON_VERSION}") " fi From b7f9f50aaada479d3e39e4d7c1c0b806f1701b04 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Fri, 17 Oct 2025 14:29:50 -0400 Subject: [PATCH 2/2] Backport Flask-AppBuilder 4.6.3 support to v2-11 - Add database migration for Group tables required by FAB 4.6.3 - Fix test compatibility with FAB 4.6.3 group handling - Fix duplicate DAG access control conversion logic - Add import compatibility for UserGroupModelView --- ...3_2_11_0_add_group_tables_for_fab_4_6_3.py | 154 ++++++++++++++++++ .../role_and_permission_endpoint.py | 4 + .../api_endpoints/user_endpoint.py | 4 + .../fab/auth_manager/models/__init__.py | 62 ++++++- .../fab/auth_manager/models/anonymous_user.py | 6 +- .../auth_manager/security_manager/override.py | 12 ++ airflow/providers/fab/provider.yaml | 2 +- dev/breeze/tests/test_packages.py | 6 +- generated/provider_dependencies.json | 2 +- .../models/test_anonymous_user.py | 3 +- .../fab/auth_manager/test_security.py | 16 +- 11 files changed, 249 insertions(+), 22 deletions(-) create mode 100644 airflow/migrations/versions/0153_2_11_0_add_group_tables_for_fab_4_6_3.py diff --git a/airflow/migrations/versions/0153_2_11_0_add_group_tables_for_fab_4_6_3.py b/airflow/migrations/versions/0153_2_11_0_add_group_tables_for_fab_4_6_3.py new file mode 100644 index 0000000000000..61403fd256c9a --- /dev/null +++ b/airflow/migrations/versions/0153_2_11_0_add_group_tables_for_fab_4_6_3.py @@ -0,0 +1,154 @@ +# +# 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 Group tables for flask-appbuilder 4.6.3 compatibility. + +Revision ID: a1b2c3d4e5f6 +Revises: 5f2621c13b39 +Create Date: 2025-10-17 15:55:00.000000 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f6" +down_revision = "5f2621c13b39" +branch_labels = None +depends_on = None +airflow_version = "2.11.0" + + +def upgrade() -> None: + """Apply migration.""" + # Create ab_group table + 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", name=op.f("ab_group_pkey")), + sa.UniqueConstraint("name", name=op.f("ab_group_name_uq")), + if_not_exists=True, + ) + + # Create ab_group_role association table + 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"], name=op.f("ab_group_role_group_id_fkey"), ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["role_id"], ["ab_role.id"], name=op.f("ab_group_role_role_id_fkey"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("ab_group_role_pkey")), + sa.UniqueConstraint("group_id", "role_id", name=op.f("ab_group_role_group_id_role_id_uq")), + if_not_exists=True, + ) + with op.batch_alter_table("ab_group_role", schema=None) as batch_op: + batch_op.create_index("idx_group_id", ["group_id"], unique=False, if_not_exists=True) + batch_op.create_index("idx_group_role_id", ["role_id"], unique=False, if_not_exists=True) + + # Create ab_user_group association table + 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"], name=op.f("ab_user_group_group_id_fkey"), ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["user_id"], ["ab_user.id"], name=op.f("ab_user_group_user_id_fkey"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("ab_user_group_pkey")), + sa.UniqueConstraint("user_id", "group_id", name=op.f("ab_user_group_user_id_group_id_uq")), + if_not_exists=True, + ) + with op.batch_alter_table("ab_user_group", schema=None) as batch_op: + batch_op.create_index("idx_user_group_id", ["group_id"], unique=False, if_not_exists=True) + batch_op.create_index("idx_user_id", ["user_id"], unique=False, if_not_exists=True) + + # Update ab_user_role table to add CASCADE deletes if not already present + # This is needed for flask-appbuilder 4.6.3 compatibility + with op.batch_alter_table("ab_user_role", schema=None) as batch_op: + # Drop existing foreign keys and recreate with CASCADE + batch_op.drop_constraint("ab_user_role_user_id_fkey", type_="foreignkey") + batch_op.drop_constraint("ab_user_role_role_id_fkey", type_="foreignkey") + batch_op.create_foreign_key( + "ab_user_role_user_id_fkey", "ab_user", ["user_id"], ["id"], ondelete="CASCADE" + ) + batch_op.create_foreign_key( + "ab_user_role_role_id_fkey", "ab_role", ["role_id"], ["id"], ondelete="CASCADE" + ) + + # Update ab_permission_view_role table to add CASCADE deletes if not already present + with op.batch_alter_table("ab_permission_view_role", schema=None) as batch_op: + # Drop existing foreign keys and recreate with CASCADE + batch_op.drop_constraint("ab_permission_view_role_permission_view_id_fkey", type_="foreignkey") + batch_op.drop_constraint("ab_permission_view_role_role_id_fkey", type_="foreignkey") + batch_op.create_foreign_key( + "ab_permission_view_role_permission_view_id_fkey", + "ab_permission_view", + ["permission_view_id"], + ["id"], + ondelete="CASCADE" + ) + batch_op.create_foreign_key( + "ab_permission_view_role_role_id_fkey", "ab_role", ["role_id"], ["id"], ondelete="CASCADE" + ) + + +def downgrade() -> None: + """Unapply migration.""" + # Drop the new tables in reverse order + op.drop_table("ab_user_group") + op.drop_table("ab_group_role") + op.drop_table("ab_group") + + # Revert foreign key constraints back to original state + with op.batch_alter_table("ab_user_role", schema=None) as batch_op: + batch_op.drop_constraint("ab_user_role_user_id_fkey", type_="foreignkey") + batch_op.drop_constraint("ab_user_role_role_id_fkey", type_="foreignkey") + batch_op.create_foreign_key( + "ab_user_role_user_id_fkey", "ab_user", ["user_id"], ["id"] + ) + batch_op.create_foreign_key( + "ab_user_role_role_id_fkey", "ab_role", ["role_id"], ["id"] + ) + + with op.batch_alter_table("ab_permission_view_role", schema=None) as batch_op: + batch_op.drop_constraint("ab_permission_view_role_permission_view_id_fkey", type_="foreignkey") + batch_op.drop_constraint("ab_permission_view_role_role_id_fkey", type_="foreignkey") + batch_op.create_foreign_key( + "ab_permission_view_role_permission_view_id_fkey", + "ab_permission_view", + ["permission_view_id"], + ["id"] + ) + batch_op.create_foreign_key( + "ab_permission_view_role_role_id_fkey", "ab_role", ["role_id"], ["id"] + ) \ No newline at end of file diff --git a/airflow/providers/fab/auth_manager/api_endpoints/role_and_permission_endpoint.py b/airflow/providers/fab/auth_manager/api_endpoints/role_and_permission_endpoint.py index ed42f91163982..28fcde9545816 100644 --- a/airflow/providers/fab/auth_manager/api_endpoints/role_and_permission_endpoint.py +++ b/airflow/providers/fab/auth_manager/api_endpoints/role_and_permission_endpoint.py @@ -123,6 +123,8 @@ def patch_role(*, role_name: str, update_mask: UpdateMask = None) -> APIResponse """Update a role.""" security_manager = cast(FabAirflowSecurityManagerOverride, get_auth_manager().security_manager) body = request.json + if body is None: + raise BadRequest("Request body is required") try: data = role_schema.load(body) except ValidationError as err: @@ -156,6 +158,8 @@ def post_role() -> APIResponse: """Create a new role.""" security_manager = cast(FabAirflowSecurityManagerOverride, get_auth_manager().security_manager) body = request.json + if body is None: + raise BadRequest("Request body is required") try: data = role_schema.load(body) except ValidationError as err: diff --git a/airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py b/airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py index 665b7f52d896f..5d7f924047576 100644 --- a/airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py +++ b/airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py @@ -89,6 +89,8 @@ def get_users(*, limit: int, order_by: str = "id", offset: str | None = None) -> @requires_access_custom_view("POST", permissions.RESOURCE_USER) def post_user() -> APIResponse: """Create a new user.""" + if request.json is None: + raise BadRequest("Request body is required") try: data = user_schema.load(request.json) except ValidationError as e: @@ -132,6 +134,8 @@ def post_user() -> APIResponse: @requires_access_custom_view("PUT", permissions.RESOURCE_USER) def patch_user(*, username: str, update_mask: UpdateMask = None) -> APIResponse: """Update a user.""" + if request.json is None: + raise BadRequest("Request body is required") try: data = user_schema.load(request.json) except ValidationError as e: diff --git a/airflow/providers/fab/auth_manager/models/__init__.py b/airflow/providers/fab/auth_manager/models/__init__.py index bf4e43f275fab..de70b0ba8ea79 100644 --- a/airflow/providers/fab/auth_manager/models/__init__.py +++ b/airflow/providers/fab/auth_manager/models/__init__.py @@ -89,11 +89,37 @@ def __repr__(self): "ab_permission_view_role", Model.metadata, Column("id", Integer, primary_key=True), - Column("permission_view_id", Integer, ForeignKey("ab_permission_view.id")), - Column("role_id", Integer, ForeignKey("ab_role.id")), + Column( + "permission_view_id", + Integer, + ForeignKey("ab_permission_view.id", ondelete="CASCADE"), + ), + Column("role_id", Integer, ForeignKey("ab_role.id", ondelete="CASCADE")), UniqueConstraint("permission_view_id", "role_id"), ) +assoc_user_group = Table( + "ab_user_group", + Model.metadata, + Column("id", Integer, primary_key=True), + Column("user_id", Integer, ForeignKey("ab_user.id", ondelete="CASCADE")), + Column("group_id", Integer, ForeignKey("ab_group.id", ondelete="CASCADE")), + UniqueConstraint("user_id", "group_id"), + Index("idx_user_id", "user_id"), + Index("idx_user_group_id", "group_id"), +) + +assoc_group_role = Table( + "ab_group_role", + Model.metadata, + Column("id", Integer, primary_key=True), + Column("group_id", Integer, ForeignKey("ab_group.id", ondelete="CASCADE")), + Column("role_id", Integer, ForeignKey("ab_role.id", ondelete="CASCADE")), + UniqueConstraint("group_id", "role_id"), + Index("idx_group_id", "group_id"), + Index("idx_group_role_id", "role_id"), +) + class Role(Model): """Represents a user role to which permissions can be assigned.""" @@ -102,7 +128,29 @@ class Role(Model): id = Column(Integer, primary_key=True) name = Column(String(64), unique=True, nullable=False) - permissions = relationship("Permission", secondary=assoc_permission_role, backref="role", lazy="joined") + permissions = relationship( + "Permission", + secondary=assoc_permission_role, + backref="role", + lazy="joined", + passive_deletes=True, + ) + + def __repr__(self): + return self.name + + +class Group(Model): + """Represents a user group.""" + + __tablename__ = "ab_group" + + id = Column(Integer, primary_key=True) + name = Column(String(100), unique=True, nullable=False) + label = Column(String(150)) + description = Column(String(512)) + users = relationship("User", secondary=assoc_user_group, backref="groups", passive_deletes=True) + roles = relationship("Role", secondary=assoc_group_role, backref="groups", passive_deletes=True) def __repr__(self): return self.name @@ -135,8 +183,8 @@ def __repr__(self): "ab_user_role", Model.metadata, Column("id", Integer, primary_key=True), - Column("user_id", Integer, ForeignKey("ab_user.id")), - Column("role_id", Integer, ForeignKey("ab_role.id")), + Column("user_id", Integer, ForeignKey("ab_user.id", ondelete="CASCADE")), + Column("role_id", Integer, ForeignKey("ab_role.id", ondelete="CASCADE")), UniqueConstraint("user_id", "role_id"), ) @@ -157,7 +205,9 @@ class User(Model, BaseUser): last_login = Column(DateTime) login_count = Column(Integer) fail_login_count = Column(Integer) - roles = relationship("Role", secondary=assoc_user_role, backref="user", lazy="selectin") + roles = relationship( + "Role", secondary=assoc_user_role, backref="user", lazy="selectin", passive_deletes=True + ) created_on = Column(DateTime, default=datetime.datetime.now, nullable=True) changed_on = Column(DateTime, default=datetime.datetime.now, nullable=True) diff --git a/airflow/providers/fab/auth_manager/models/anonymous_user.py b/airflow/providers/fab/auth_manager/models/anonymous_user.py index ba75de0d3c6e3..1fb9cb3d38cf1 100644 --- a/airflow/providers/fab/auth_manager/models/anonymous_user.py +++ b/airflow/providers/fab/auth_manager/models/anonymous_user.py @@ -34,13 +34,17 @@ def roles(self): if not self._roles: public_role = current_app.appbuilder.get_app.config["AUTH_ROLE_PUBLIC"] self._roles = {current_app.appbuilder.sm.find_role(public_role)} if public_role else set() - return self._roles + return list(self._roles) @roles.setter def roles(self, roles): self._roles = roles self._perms = set() + @property + def groups(self): + return [] + @property def perms(self): if not self._perms: diff --git a/airflow/providers/fab/auth_manager/security_manager/override.py b/airflow/providers/fab/auth_manager/security_manager/override.py index e2208e5fb409f..5a24c1630b50f 100644 --- a/airflow/providers/fab/auth_manager/security_manager/override.py +++ b/airflow/providers/fab/auth_manager/security_manager/override.py @@ -61,6 +61,13 @@ AuthView, RegisterUserModelView, ) + +# Handle FAB 4.6.3+ compatibility for UserGroupModelView +try: + from flask_appbuilder.security.views import UserGroupModelView +except ImportError: + # Fallback for older FAB versions that don't have UserGroupModelView + UserGroupModelView = None from flask_appbuilder.views import expose from flask_babel import lazy_gettext from flask_jwt_extended import JWTManager, current_user as current_user_jwt @@ -79,6 +86,7 @@ from airflow.models import DagBag, DagModel from airflow.providers.fab.auth_manager.models import ( Action, + Group, Permission, RegisterUser, Resource, @@ -171,6 +179,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2): """ Models """ user_model = User role_model = Role + group_model = Group action_model = Action resource_model = Resource permission_model = Permission @@ -195,6 +204,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2): actionmodelview = ActionModelView permissionmodelview = PermissionPairModelView rolemodelview = CustomRoleModelView + groupmodelview = UserGroupModelView # May be None for FAB versions < 4.6.3 registeruser_model = RegisterUser registerusermodelview = RegisterUserModelView resourcemodelview = ResourceModelView @@ -1190,6 +1200,8 @@ def _get_or_create_dag_permission(action_name: str, dag_resource_name: str) -> P f"'{rolename}', but that role does not exist" ) + # Handle both old-style (set of actions) and new-style (dict of resource->actions) formats + # This maintains backward compatibility for tests that call _sync_dag_view_permissions directly if isinstance(resource_actions, (set, list)): # Support for old-style access_control where only the actions are specified resource_actions = {permissions.RESOURCE_DAG: set(resource_actions)} diff --git a/airflow/providers/fab/provider.yaml b/airflow/providers/fab/provider.yaml index 52c6dbe053b11..f74afd13653d5 100644 --- a/airflow/providers/fab/provider.yaml +++ b/airflow/providers/fab/provider.yaml @@ -51,7 +51,7 @@ dependencies: # Every time we update FAB version here, please make sure that you review the classes and models in # `airflow/providers/fab/auth_manager/security_manager/override.py` with their upstream counterparts. # In particular, make sure any breaking changes, for example any new methods, are accounted for. - - flask-appbuilder==4.5.2 + - flask-appbuilder==4.6.3 - flask-login>=0.6.2 - google-re2>=1.0 - jmespath>=0.7.0 diff --git a/dev/breeze/tests/test_packages.py b/dev/breeze/tests/test_packages.py index 1fa56a409c14d..7731131fd24e1 100644 --- a/dev/breeze/tests/test_packages.py +++ b/dev/breeze/tests/test_packages.py @@ -156,7 +156,7 @@ def test_find_matching_long_package_name_bad_filter(): "", """ "apache-airflow>=2.9.0", - "flask-appbuilder==4.5.2", + "flask-appbuilder==4.6.3", "flask-login>=0.6.2", "flask>=2.2,<2.3", "google-re2>=1.0", @@ -169,7 +169,7 @@ def test_find_matching_long_package_name_bad_filter(): "dev0", """ "apache-airflow>=2.9.0.dev0", - "flask-appbuilder==4.5.2", + "flask-appbuilder==4.6.3", "flask-login>=0.6.2", "flask>=2.2,<2.3", "google-re2>=1.0", @@ -182,7 +182,7 @@ def test_find_matching_long_package_name_bad_filter(): "beta0", """ "apache-airflow>=2.9.0b0", - "flask-appbuilder==4.5.2", + "flask-appbuilder==4.6.3", "flask-login>=0.6.2", "flask>=2.2,<2.3", "google-re2>=1.0", diff --git a/generated/provider_dependencies.json b/generated/provider_dependencies.json index b038b7b111f51..1039eca487173 100644 --- a/generated/provider_dependencies.json +++ b/generated/provider_dependencies.json @@ -551,7 +551,7 @@ "fab": { "deps": [ "apache-airflow>=2.9.0", - "flask-appbuilder==4.5.2", + "flask-appbuilder==4.6.3", "flask-login>=0.6.2", "flask>=2.2,<2.3", "google-re2>=1.0", diff --git a/tests/providers/fab/auth_manager/models/test_anonymous_user.py b/tests/providers/fab/auth_manager/models/test_anonymous_user.py index 4e365e3c8b705..bc4cfbd7e7652 100644 --- a/tests/providers/fab/auth_manager/models/test_anonymous_user.py +++ b/tests/providers/fab/auth_manager/models/test_anonymous_user.py @@ -28,7 +28,8 @@ def test_roles(self): roles = {"role1"} user = AnonymousUser() user.roles = roles - assert user.roles == roles + # AnonymousUser.roles returns a list in flask-appbuilder 4.6.3 + assert user.roles == list(roles) def test_perms(self): perms = {"perms1"} diff --git a/tests/providers/fab/auth_manager/test_security.py b/tests/providers/fab/auth_manager/test_security.py index ac89018c995f2..498b772dc5af2 100644 --- a/tests/providers/fab/auth_manager/test_security.py +++ b/tests/providers/fab/auth_manager/test_security.py @@ -354,7 +354,7 @@ def test_verify_default_anon_user_has_no_accessible_dag_ids( mock_is_logged_in.return_value = False user = AnonymousUser() app.config["AUTH_ROLE_PUBLIC"] = "Public" - assert security_manager.get_user_roles(user) == {security_manager.get_public_role()} + assert set(security_manager.get_user_roles(user)) == {security_manager.get_public_role()} with _create_dag_model_context("test_dag_id", session, security_manager): security_manager.sync_roles() @@ -366,7 +366,7 @@ def test_verify_default_anon_user_has_no_access_to_specific_dag(app, session, se with app.app_context(): user = AnonymousUser() app.config["AUTH_ROLE_PUBLIC"] = "Public" - assert security_manager.get_user_roles(user) == {security_manager.get_public_role()} + assert set(security_manager.get_user_roles(user)) == {security_manager.get_public_role()} dag_id = "test_dag_id" with _create_dag_model_context(dag_id, session, security_manager): @@ -393,7 +393,7 @@ def test_verify_anon_user_with_admin_role_has_all_dag_access( mock_is_logged_in.return_value = False user = AnonymousUser() - assert security_manager.get_user_roles(user) == {security_manager.get_public_role()} + assert set(security_manager.get_user_roles(user)) == {security_manager.get_public_role()} security_manager.sync_roles() @@ -409,7 +409,7 @@ def test_verify_anon_user_with_admin_role_has_access_to_each_dag( # Call `.get_user_roles` bc `user` is a mock and the `user.roles` prop needs to be set. user.roles = security_manager.get_user_roles(user) - assert user.roles == {security_manager.get_public_role()} + assert set(user.roles) == {security_manager.get_public_role()} test_dag_ids = ["test_dag_id_1", "test_dag_id_2", "test_dag_id_3", "test_dag_id_4.with_dot"] @@ -915,11 +915,9 @@ def test_correct_roles_have_perms_to_read_config(security_manager): def test_create_dag_specific_permissions(session, security_manager, monkeypatch, sample_dags): - access_control = ( - {"Public": {"DAGs": {permissions.ACTION_CAN_READ}}} - if hasattr(permissions, "resource_name") - else {"Public": {permissions.ACTION_CAN_READ}} - ) + # The DAG object has old-style access control which gets passed as-is to _sync_dag_view_permissions + # The conversion happens inside _sync_dag_view_permissions, not before the call + access_control = {"Public": {permissions.ACTION_CAN_READ}} collect_dags_from_db_mock = mock.Mock() dagbag_mock = mock.Mock()