Skip to content

Commit 43df0ac

Browse files
vincbeckkaxil
authored andcommitted
Upgrade flask-appbuilder to 4.6.3 in FAB provider (#50513)
(cherry picked from commit e6430c2)
1 parent 6192aa2 commit 43df0ac

File tree

9 files changed

+102
-38
lines changed

9 files changed

+102
-38
lines changed

providers/fab/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ dependencies = [
7171
# Every time we update FAB version here, please make sure that you review the classes and models in
7272
# `airflow/providers/fab/auth_manager/security_manager/override.py` with their upstream counterparts.
7373
# In particular, make sure any breaking changes, for example any new methods, are accounted for.
74-
"flask-appbuilder==4.5.3",
74+
"flask-appbuilder==4.6.3",
7575
"flask-login>=0.6.2",
7676
# Flask-Session 0.6 add new arguments into the SqlAlchemySessionInterface constructor as well as
7777
# all parameters now are mandatory which make AirflowDatabaseSessionInterface incompatible with this version.

providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/role_and_permission_endpoint.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ def patch_role(*, role_name: str, update_mask: UpdateMask = None) -> APIResponse
123123
"""Update a role."""
124124
security_manager = cast("FabAuthManager", get_auth_manager()).security_manager
125125
body = request.json
126+
if body is None:
127+
raise BadRequest("Request body is required")
126128
try:
127129
data = role_schema.load(body)
128130
except ValidationError as err:
@@ -156,6 +158,8 @@ def post_role() -> APIResponse:
156158
"""Create a new role."""
157159
security_manager = cast("FabAuthManager", get_auth_manager()).security_manager
158160
body = request.json
161+
if body is None:
162+
raise BadRequest("Request body is required")
159163
try:
160164
data = role_schema.load(body)
161165
except ValidationError as err:

providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ def get_users(*, limit: int, order_by: str = "id", offset: str | None = None) ->
8888
@requires_access_custom_view("POST", permissions.RESOURCE_USER)
8989
def post_user() -> APIResponse:
9090
"""Create a new user."""
91+
if request.json is None:
92+
raise BadRequest("Request body is required")
9193
try:
9294
data = user_schema.load(request.json)
9395
except ValidationError as e:
@@ -131,6 +133,8 @@ def post_user() -> APIResponse:
131133
@requires_access_custom_view("PUT", permissions.RESOURCE_USER)
132134
def patch_user(*, username: str, update_mask: UpdateMask = None) -> APIResponse:
133135
"""Update a user."""
136+
if request.json is None:
137+
raise BadRequest("Request body is required")
134138
try:
135139
data = user_schema.load(request.json)
136140
except ValidationError as e:

providers/fab/src/airflow/providers/fab/auth_manager/models/__init__.py

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,37 @@ def __repr__(self):
102102
"ab_permission_view_role",
103103
Model.metadata,
104104
Column("id", Integer, primary_key=True),
105-
Column("permission_view_id", Integer, ForeignKey("ab_permission_view.id")),
106-
Column("role_id", Integer, ForeignKey("ab_role.id")),
105+
Column(
106+
"permission_view_id",
107+
Integer,
108+
ForeignKey("ab_permission_view.id", ondelete="CASCADE"),
109+
),
110+
Column("role_id", Integer, ForeignKey("ab_role.id", ondelete="CASCADE")),
107111
UniqueConstraint("permission_view_id", "role_id"),
108112
)
109113

114+
assoc_user_group = Table(
115+
"ab_user_group",
116+
Model.metadata,
117+
Column("id", Integer, primary_key=True),
118+
Column("user_id", Integer, ForeignKey("ab_user.id", ondelete="CASCADE")),
119+
Column("group_id", Integer, ForeignKey("ab_group.id", ondelete="CASCADE")),
120+
UniqueConstraint("user_id", "group_id"),
121+
Index("idx_user_id", "user_id"),
122+
Index("idx_user_group_id", "group_id"),
123+
)
124+
125+
assoc_group_role = Table(
126+
"ab_group_role",
127+
Model.metadata,
128+
Column("id", Integer, primary_key=True),
129+
Column("group_id", Integer, ForeignKey("ab_group.id", ondelete="CASCADE")),
130+
Column("role_id", Integer, ForeignKey("ab_role.id", ondelete="CASCADE")),
131+
UniqueConstraint("group_id", "role_id"),
132+
Index("idx_group_id", "group_id"),
133+
Index("idx_group_role_id", "role_id"),
134+
)
135+
110136

111137
class Role(Model):
112138
"""Represents a user role to which permissions can be assigned."""
@@ -115,7 +141,29 @@ class Role(Model):
115141

116142
id = Column(Integer, primary_key=True)
117143
name = Column(String(64), unique=True, nullable=False)
118-
permissions = relationship("Permission", secondary=assoc_permission_role, backref="role", lazy="joined")
144+
permissions = relationship(
145+
"Permission",
146+
secondary=assoc_permission_role,
147+
backref="role",
148+
lazy="joined",
149+
passive_deletes=True,
150+
)
151+
152+
def __repr__(self):
153+
return self.name
154+
155+
156+
class Group(Model):
157+
"""Represents a user group."""
158+
159+
__tablename__ = "ab_group"
160+
161+
id = Column(Integer, primary_key=True)
162+
name = Column(String(100), unique=True, nullable=False)
163+
label = Column(String(150))
164+
description = Column(String(512))
165+
users = relationship("User", secondary=assoc_user_group, backref="groups", passive_deletes=True)
166+
roles = relationship("Role", secondary=assoc_group_role, backref="groups", passive_deletes=True)
119167

120168
def __repr__(self):
121169
return self.name
@@ -148,8 +196,8 @@ def __repr__(self):
148196
"ab_user_role",
149197
Model.metadata,
150198
Column("id", Integer, primary_key=True),
151-
Column("user_id", Integer, ForeignKey("ab_user.id")),
152-
Column("role_id", Integer, ForeignKey("ab_role.id")),
199+
Column("user_id", Integer, ForeignKey("ab_user.id", ondelete="CASCADE")),
200+
Column("role_id", Integer, ForeignKey("ab_role.id", ondelete="CASCADE")),
153201
UniqueConstraint("user_id", "role_id"),
154202
)
155203

@@ -170,7 +218,9 @@ class User(Model, BaseUser):
170218
last_login = Column(DateTime)
171219
login_count = Column(Integer)
172220
fail_login_count = Column(Integer)
173-
roles = relationship("Role", secondary=assoc_user_role, backref="user", lazy="selectin")
221+
roles = relationship(
222+
"Role", secondary=assoc_user_role, backref="user", lazy="selectin", passive_deletes=True
223+
)
174224
created_on = Column(DateTime, default=datetime.datetime.now, nullable=True)
175225
changed_on = Column(DateTime, default=datetime.datetime.now, nullable=True)
176226

providers/fab/src/airflow/providers/fab/auth_manager/models/anonymous_user.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,17 @@ def roles(self):
3737
if not self._roles:
3838
public_role = current_app.config.get("AUTH_ROLE_PUBLIC", None)
3939
self._roles = {current_app.appbuilder.sm.find_role(public_role)} if public_role else set()
40-
return self._roles
40+
return list(self._roles)
4141

4242
@roles.setter
4343
def roles(self, roles):
4444
self._roles = roles
4545
self._perms = set()
4646

47+
@property
48+
def groups(self):
49+
return []
50+
4751
@property
4852
def perms(self):
4953
if not self._perms:

providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
AuthOIDView,
5656
AuthRemoteUserView,
5757
RegisterUserModelView,
58+
UserGroupModelView,
5859
)
5960
from flask_babel import lazy_gettext
6061
from flask_jwt_extended import JWTManager
@@ -71,6 +72,7 @@
7172
from airflow.models import DagBag
7273
from airflow.providers.fab.auth_manager.models import (
7374
Action,
75+
Group,
7476
Permission,
7577
RegisterUser,
7678
Resource,
@@ -100,10 +102,7 @@
100102
from airflow.providers.fab.auth_manager.views.user_stats import CustomUserStatsChartView
101103
from airflow.providers.fab.www.security import permissions
102104
from airflow.providers.fab.www.security_manager import AirflowSecurityManagerV2
103-
from airflow.providers.fab.www.session import (
104-
AirflowDatabaseSessionInterface,
105-
AirflowDatabaseSessionInterface as FabAirflowDatabaseSessionInterface,
106-
)
105+
from airflow.providers.fab.www.session import AirflowDatabaseSessionInterface
107106
from airflow.security.permissions import RESOURCE_BACKFILL
108107

109108
if TYPE_CHECKING:
@@ -149,6 +148,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
149148
""" Models """
150149
user_model = User
151150
role_model = Role
151+
group_model = Group
152152
action_model = Action
153153
resource_model = Resource
154154
permission_model = Permission
@@ -173,6 +173,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
173173
actionmodelview = ActionModelView
174174
permissionmodelview = PermissionPairModelView
175175
rolemodelview = CustomRoleModelView
176+
groupmodelview = UserGroupModelView
176177
registeruser_model = RegisterUser
177178
registerusermodelview = RegisterUserModelView
178179
resourcemodelview = ResourceModelView
@@ -450,7 +451,7 @@ def register_views(self):
450451
role_view = self.appbuilder.add_view(
451452
self.rolemodelview,
452453
"List Roles",
453-
icon="fa-group",
454+
icon="fa-user-gear",
454455
label=lazy_gettext("List Roles"),
455456
category="Security",
456457
category_icon="fa-cogs",
@@ -532,12 +533,7 @@ def reset_password(self, userid: int, password: str) -> bool:
532533
return self.update_user(user)
533534

534535
def reset_user_sessions(self, user: User) -> None:
535-
if isinstance(
536-
self.appbuilder.get_app.session_interface, AirflowDatabaseSessionInterface
537-
) or isinstance(
538-
self.appbuilder.get_app.session_interface,
539-
FabAirflowDatabaseSessionInterface,
540-
):
536+
if isinstance(self.appbuilder.get_app.session_interface, AirflowDatabaseSessionInterface):
541537
interface = self.appbuilder.get_app.session_interface
542538
session = interface.db.session
543539
user_session_model = interface.sql_session_model
@@ -859,6 +855,7 @@ def _init_data_model(self):
859855
self.registerusermodelview.datamodel = SQLAInterface(self.registeruser_model)
860856

861857
self.rolemodelview.datamodel = SQLAInterface(self.role_model)
858+
self.groupmodelview.datamodel = SQLAInterface(self.group_model)
862859
self.actionmodelview.datamodel = SQLAInterface(self.action_model)
863860
self.resourcemodelview.datamodel = SQLAInterface(self.resource_model)
864861
self.permissionmodelview.datamodel = SQLAInterface(self.permission_model)
@@ -875,7 +872,8 @@ def create_db(self):
875872
try:
876873
engine = self.get_session.get_bind(mapper=None, clause=None)
877874
inspector = inspect(engine)
878-
if "ab_user" not in inspector.get_table_names():
875+
existing_tables = inspector.get_table_names()
876+
if "ab_user" not in existing_tables or "ab_group" not in existing_tables:
879877
log.info(const.LOGMSG_INF_SEC_NO_DB)
880878
Base.metadata.create_all(engine)
881879
log.info(const.LOGMSG_INF_SEC_ADD_DB)
@@ -1323,15 +1321,20 @@ def get_public_role(self):
13231321

13241322
def add_user(
13251323
self,
1326-
username,
1327-
first_name,
1328-
last_name,
1329-
email,
1330-
role,
1331-
password="",
1332-
hashed_password="",
1324+
username: str,
1325+
first_name: str,
1326+
last_name: str,
1327+
email: str,
1328+
role: list[Role] | Role | None = None,
1329+
password: str = "",
1330+
hashed_password: str = "",
1331+
groups: list[Group] | None = None,
13331332
):
13341333
"""Create a user."""
1334+
roles: list[Role] = []
1335+
if role:
1336+
roles = role if isinstance(role, list) else [role]
1337+
13351338
try:
13361339
user = self.user_model()
13371340
user.first_name = first_name
@@ -1340,7 +1343,8 @@ def add_user(
13401343
user.email = email
13411344
user.active = True
13421345
self.get_session.add(user)
1343-
user.roles = role if isinstance(role, list) else [role]
1346+
user.roles = roles
1347+
user.groups = groups or []
13441348
if hashed_password:
13451349
user.password = hashed_password
13461350
else:
@@ -1704,7 +1708,7 @@ def get_user_roles(user=None):
17041708
"""
17051709
if user is None:
17061710
user = g.user
1707-
return user.roles
1711+
return user.roles + [role for group in user.groups for role in group.roles]
17081712

17091713
"""
17101714
--------------------

providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_auth.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from base64 import b64encode
2020

2121
import pytest
22-
from flask_login import current_user
2322

2423
from tests_common.test_utils.config import conf_vars
2524
from tests_common.test_utils.db import clear_db_pools
@@ -74,6 +73,5 @@ def test_success(self):
7473

7574
with self.app.test_client() as test_client:
7675
response = test_client.get("/fab/v1/users", headers={"Authorization": token})
77-
assert current_user.email == "[email protected]"
7876

7977
assert response.status_code == 200

providers/fab/tests/unit/fab/auth_manager/models/test_anonymous_user.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
class TestAnonymousUser:
2727
def test_roles(self):
28-
roles = {"role1"}
28+
roles = ["role1"]
2929
user = AnonymousUser()
3030
user.roles = roles
3131
assert user.roles == roles

providers/fab/tests/unit/fab/auth_manager/test_security.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ def test_verify_default_anon_user_has_no_accessible_dag_ids(
353353
mock_is_logged_in.return_value = False
354354
user = AnonymousUser()
355355
app.config["AUTH_ROLE_PUBLIC"] = "Public"
356-
assert security_manager.get_user_roles(user) == {security_manager.get_public_role()}
356+
assert security_manager.get_user_roles(user) == [security_manager.get_public_role()]
357357

358358
with _create_dag_model_context("test_dag_id", session, security_manager):
359359
security_manager.sync_roles()
@@ -365,7 +365,7 @@ def test_verify_default_anon_user_has_no_access_to_specific_dag(app, session, se
365365
with app.app_context():
366366
user = AnonymousUser()
367367
app.config["AUTH_ROLE_PUBLIC"] = "Public"
368-
assert security_manager.get_user_roles(user) == {security_manager.get_public_role()}
368+
assert security_manager.get_user_roles(user) == [security_manager.get_public_role()]
369369

370370
dag_id = "test_dag_id"
371371
with _create_dag_model_context(dag_id, session, security_manager):
@@ -392,7 +392,7 @@ def test_verify_anon_user_with_admin_role_has_all_dag_access(
392392
mock_is_logged_in.return_value = False
393393
user = AnonymousUser()
394394

395-
assert security_manager.get_user_roles(user) == {security_manager.get_public_role()}
395+
assert security_manager.get_user_roles(user) == [security_manager.get_public_role()]
396396

397397
security_manager.sync_roles()
398398

@@ -408,7 +408,7 @@ def test_verify_anon_user_with_admin_role_has_access_to_each_dag(
408408

409409
# Call `.get_user_roles` bc `user` is a mock and the `user.roles` prop needs to be set.
410410
user.roles = security_manager.get_user_roles(user)
411-
assert user.roles == {security_manager.get_public_role()}
411+
assert user.roles == [security_manager.get_public_role()]
412412

413413
test_dag_ids = ["test_dag_id_1", "test_dag_id_2", "test_dag_id_3", "test_dag_id_4.with_dot"]
414414

@@ -425,8 +425,8 @@ def test_verify_anon_user_with_admin_role_has_access_to_each_dag(
425425
def test_get_user_roles(app_builder, security_manager):
426426
user = mock.MagicMock()
427427
roles = app_builder.sm.find_role("Admin")
428-
user.roles = roles
429-
assert security_manager.get_user_roles(user) == roles
428+
user.roles = [roles]
429+
assert security_manager.get_user_roles(user) == [roles]
430430

431431

432432
def test_get_user_roles_for_anonymous_user(app, security_manager):

0 commit comments

Comments
 (0)