Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/install-pre-commit/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile.ci
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
@@ -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"]
)
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
62 changes: 56 additions & 6 deletions airflow/providers/fab/auth_manager/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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
Expand Down Expand Up @@ -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"),
)

Expand All @@ -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)

Expand Down
6 changes: 5 additions & 1 deletion airflow/providers/fab/auth_manager/models/anonymous_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions airflow/providers/fab/auth_manager/security_manager/override.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -79,6 +86,7 @@
from airflow.models import DagBag, DagModel
from airflow.providers.fab.auth_manager.models import (
Action,
Group,
Permission,
RegisterUser,
Resource,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)}
Expand Down
2 changes: 1 addition & 1 deletion airflow/providers/fab/provider.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading